From cc8ce22c85df56d7a1356f3694c8bcf50f5b342d Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 16:41:31 +0800 Subject: [PATCH] feat(report): add skill report backend workflow --- .../admin/AdminSkillReportController.java | 79 +++++++++++ .../portal/SkillReportController.java | 65 +++++++++ .../dto/AdminSkillReportActionRequest.java | 5 + .../dto/AdminSkillReportSummaryResponse.java | 19 +++ .../dto/SkillReportMutationResponse.java | 6 + .../dto/SkillReportSubmitRequest.java | 6 + .../service/AdminSkillReportAppService.java | 86 ++++++++++++ .../db/migration/V10__skill_report_tables.sql | 16 +++ .../src/main/resources/messages.properties | 7 + .../src/main/resources/messages_zh.properties | 7 + .../admin/AdminSkillReportControllerTest.java | 116 ++++++++++++++++ .../portal/SkillReportControllerTest.java | 82 +++++++++++ .../skillhub/domain/report/SkillReport.java | 128 ++++++++++++++++++ .../domain/report/SkillReportRepository.java | 15 ++ .../domain/report/SkillReportService.java | 111 +++++++++++++++ .../domain/report/SkillReportStatus.java | 7 + .../domain/report/SkillReportServiceTest.java | 102 ++++++++++++++ .../infra/jpa/SkillReportJpaRepository.java | 21 +++ 18 files changed, 878 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java new file mode 100644 index 00000000..0bdb76fa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.dto.AdminSkillReportActionRequest; +import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillReportMutationResponse; +import com.iflytek.skillhub.service.AdminSkillReportAppService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/skill-reports") +public class AdminSkillReportController extends BaseApiController { + + private final AdminSkillReportAppService adminSkillReportAppService; + private final SkillReportService skillReportService; + + public AdminSkillReportController(AdminSkillReportAppService adminSkillReportAppService, + SkillReportService skillReportService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.adminSkillReportAppService = adminSkillReportAppService; + this.skillReportService = skillReportService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse> listReports( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success", adminSkillReportAppService.listReports(status, page, size)); + } + + @PostMapping("/{reportId}/resolve") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse resolveReport(@PathVariable Long reportId, + @RequestBody(required = false) AdminSkillReportActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var report = skillReportService.resolveReport( + reportId, + principal.userId(), + request != null ? request.comment() : null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); + } + + @PostMapping("/{reportId}/dismiss") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse dismissReport(@PathVariable Long reportId, + @RequestBody(required = false) AdminSkillReportActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var report = skillReportService.dismissReport( + reportId, + principal.userId(), + request != null ? request.comment() : null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java new file mode 100644 index 00000000..ca9c5b3a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReportController.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillReportMutationResponse; +import com.iflytek.skillhub.dto.SkillReportSubmitRequest; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillReportController extends BaseApiController { + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillReportService skillReportService; + + public SkillReportController(NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillReportService skillReportService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.skillReportService = skillReportService; + } + + @PostMapping("/{namespace}/{slug}/reports") + public ApiResponse submitReport(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody SkillReportSubmitRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug); + var report = skillReportService.submitReport( + skill.getId(), + userId, + request.reason(), + request.details(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.created", new SkillReportMutationResponse(report.getId(), report.getStatus().name())); + } + + private Skill findSkill(String namespaceSlug, String skillSlug) { + String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; + Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", cleanNamespace)); + return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java new file mode 100644 index 00000000..92124df1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillReportActionRequest( + String comment +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java new file mode 100644 index 00000000..294784e8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.time.LocalDateTime; + +public record AdminSkillReportSummaryResponse( + Long id, + Long skillId, + String namespace, + String skillSlug, + String skillDisplayName, + String reporterId, + String reason, + String details, + String status, + String handledBy, + String handleComment, + LocalDateTime createdAt, + LocalDateTime handledAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java new file mode 100644 index 00000000..1445d957 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportMutationResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillReportMutationResponse( + Long reportId, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java new file mode 100644 index 00000000..8754b460 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReportSubmitRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillReportSubmitRequest( + String reason, + String details +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java new file mode 100644 index 00000000..fe807e4f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminSkillReportAppService.java @@ -0,0 +1,86 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class AdminSkillReportAppService { + + private final SkillReportRepository skillReportRepository; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + + public AdminSkillReportAppService(SkillReportRepository skillReportRepository, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository) { + this.skillReportRepository = skillReportRepository; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + } + + public PageResponse listReports(String status, int page, int size) { + SkillReportStatus resolvedStatus = parseStatus(status); + var reportPage = skillReportRepository.findByStatus(resolvedStatus, PageRequest.of(page, size)); + + List skillIds = reportPage.getContent().stream().map(SkillReport::getSkillId).distinct().toList(); + Map skillsById = skillIds.isEmpty() + ? Map.of() + : skillRepository.findByIdIn(skillIds).stream().collect(Collectors.toMap(Skill::getId, Function.identity())); + + List namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList(); + Map namespaceSlugs = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream().collect(Collectors.toMap(Namespace::getId, Namespace::getSlug)); + + List items = reportPage.getContent().stream() + .map(report -> toResponse(report, skillsById.get(report.getSkillId()), namespaceSlugs)) + .toList(); + + return new PageResponse<>(items, reportPage.getTotalElements(), reportPage.getNumber(), reportPage.getSize()); + } + + private AdminSkillReportSummaryResponse toResponse(SkillReport report, + Skill skill, + Map namespaceSlugs) { + return new AdminSkillReportSummaryResponse( + report.getId(), + report.getSkillId(), + skill != null ? namespaceSlugs.get(skill.getNamespaceId()) : null, + skill != null ? skill.getSlug() : null, + skill != null ? skill.getDisplayName() : null, + report.getReporterId(), + report.getReason(), + report.getDetails(), + report.getStatus().name(), + report.getHandledBy(), + report.getHandleComment(), + report.getCreatedAt(), + report.getHandledAt() + ); + } + + private SkillReportStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return SkillReportStatus.PENDING; + } + try { + return SkillReportStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.skill.report.status.invalid", status); + } + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql new file mode 100644 index 00000000..c93da0aa --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V10__skill_report_tables.sql @@ -0,0 +1,16 @@ +CREATE TABLE skill_report ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE, + namespace_id BIGINT NOT NULL REFERENCES namespace(id) ON DELETE CASCADE, + reporter_id VARCHAR(128) NOT NULL, + reason VARCHAR(200) NOT NULL, + details TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + handled_by VARCHAR(128), + handle_comment TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + handled_at TIMESTAMP +); + +CREATE INDEX idx_skill_report_status_created_at ON skill_report(status, created_at DESC); +CREATE INDEX idx_skill_report_skill_id ON skill_report(skill_id); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 37da08e6..857470b1 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -87,6 +87,13 @@ error.skill.version.exists=Version already exists: {0} error.skill.version.notFound=Version not found: {0} error.skill.version.notPublished=Version is not published: {0} error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0} +error.skill.report.reason.required=Please provide a report reason +error.skill.report.unavailable=This skill cannot be reported right now: {0} +error.skill.report.self=You cannot report your own skill +error.skill.report.duplicate=You already have a pending report for this skill +error.skill.report.notFound=Skill report not found: {0} +error.skill.report.alreadyHandled=This skill report has already been handled +error.skill.report.status.invalid=Unsupported skill report status: {0} error.skill.version.latest.unavailable=No published version available for skill: {0} error.skill.version.latest.notFound=Latest published version not found error.skill.file.notFound=File not found: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 939d6bdb..5c74b625 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -87,6 +87,13 @@ error.skill.version.exists=版本已存在:{0} error.skill.version.notFound=未找到版本:{0} error.skill.version.notPublished=版本未发布:{0} error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0} +error.skill.report.reason.required=请填写举报原因 +error.skill.report.unavailable=当前无法举报该技能:{0} +error.skill.report.self=不能举报自己发布的技能 +error.skill.report.duplicate=你已经提交过该技能的待处理举报 +error.skill.report.notFound=未找到技能举报:{0} +error.skill.report.alreadyHandled=该技能举报已经处理过 +error.skill.report.status.invalid=不支持的技能举报状态:{0} error.skill.version.latest.unavailable=技能没有可下载的已发布版本:{0} error.skill.version.latest.notFound=未找到最新已发布版本 error.skill.file.notFound=未找到文件:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java new file mode 100644 index 00000000..481d8ef0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AdminSkillReportAppService; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class AdminSkillReportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AdminSkillReportAppService adminSkillReportAppService; + + @MockBean + private SkillReportService skillReportService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listReports_returnsPagedReports() throws Exception { + when(adminSkillReportAppService.listReports("PENDING", 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new AdminSkillReportSummaryResponse( + 99L, + 10L, + "global", + "demo-skill", + "Demo Skill", + "user-1", + "Spam", + "details", + "PENDING", + null, + null, + LocalDateTime.of(2026, 3, 15, 12, 0), + null + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/admin/skill-reports") + .param("status", "PENDING") + .with(authentication(adminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].id").value(99)) + .andExpect(jsonPath("$.data.items[0].skillSlug").value("demo-skill")); + } + + @Test + void resolveReport_returnsUpdatedEnvelope() throws Exception { + SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details"); + ReflectionTestUtils.setField(report, "id", 99L); + report.setStatus(com.iflytek.skillhub.domain.report.SkillReportStatus.RESOLVED); + when(skillReportService.resolveReport(org.mockito.ArgumentMatchers.eq(99L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.eq("handled"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(report); + + mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve") + .with(authentication(adminAuth())) + .with(csrf()) + .contentType(APPLICATION_JSON) + .content("{\"comment\":\"handled\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reportId").value(99)) + .andExpect(jsonPath("$.data.status").value("RESOLVED")); + } + + private UsernamePasswordAuthenticationToken adminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + "admin", "admin", "admin@example.com", "", "github", Set.of("SKILL_ADMIN") + ); + return new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java new file mode 100644 index 00000000..4f886b7a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillReportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private SkillRepository skillRepository; + + @MockBean + private SkillReportService skillReportService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void submitReport_returnsCreatedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + ReflectionTestUtils.setField(namespace, "id", 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", 10L); + SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details"); + ReflectionTestUtils.setField(report, "id", 99L); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class))) + .willReturn(report); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/reports") + .requestAttr("userId", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"Spam\",\"details\":\"details\"}") + .with(user("user-1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.reportId").value(99)) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java new file mode 100644 index 00000000..7329e803 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java @@ -0,0 +1,128 @@ +package com.iflytek.skillhub.domain.report; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "skill_report") +public class SkillReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "reporter_id", nullable = false, length = 128) + private String reporterId; + + @Column(nullable = false, length = 200) + private String reason; + + @Column(columnDefinition = "TEXT") + private String details; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private SkillReportStatus status = SkillReportStatus.PENDING; + + @Column(name = "handled_by", length = 128) + private String handledBy; + + @Column(name = "handle_comment", columnDefinition = "TEXT") + private String handleComment; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "handled_at") + private LocalDateTime handledAt; + + protected SkillReport() { + } + + public SkillReport(Long skillId, Long namespaceId, String reporterId, String reason, String details) { + this.skillId = skillId; + this.namespaceId = namespaceId; + this.reporterId = reporterId; + this.reason = reason; + this.details = details; + } + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getSkillId() { + return skillId; + } + + public Long getNamespaceId() { + return namespaceId; + } + + public String getReporterId() { + return reporterId; + } + + public String getReason() { + return reason; + } + + public String getDetails() { + return details; + } + + public SkillReportStatus getStatus() { + return status; + } + + public void setStatus(SkillReportStatus status) { + this.status = status; + } + + public String getHandledBy() { + return handledBy; + } + + public void setHandledBy(String handledBy) { + this.handledBy = handledBy; + } + + public String getHandleComment() { + return handleComment; + } + + public void setHandleComment(String handleComment) { + this.handleComment = handleComment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getHandledAt() { + return handledAt; + } + + public void setHandledAt(LocalDateTime handledAt) { + this.handledAt = handledAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java new file mode 100644 index 00000000..291e3503 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.report; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SkillReportRepository { + SkillReport save(SkillReport report); + Optional findById(Long id); + boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); + Page findByStatus(SkillReportStatus status, Pageable pageable); + List findBySkillIdIn(Collection skillIds); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java new file mode 100644 index 00000000..17b2722a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -0,0 +1,111 @@ +package com.iflytek.skillhub.domain.report; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import java.time.LocalDateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillReportService { + + private final SkillRepository skillRepository; + private final SkillReportRepository skillReportRepository; + private final AuditLogService auditLogService; + + public SkillReportService(SkillRepository skillRepository, + SkillReportRepository skillReportRepository, + AuditLogService auditLogService) { + this.skillRepository = skillRepository; + this.skillReportRepository = skillReportRepository; + this.auditLogService = auditLogService; + } + + @Transactional + public SkillReport submitReport(Long skillId, + String reporterId, + String reason, + String details, + String clientIp, + String userAgent) { + if (reason == null || reason.isBlank()) { + throw new DomainBadRequestException("error.skill.report.reason.required"); + } + + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + if (skill.getStatus() != SkillStatus.ACTIVE || skill.isHidden()) { + throw new DomainBadRequestException("error.skill.report.unavailable", skill.getSlug()); + } + if (skill.getOwnerId().equals(reporterId)) { + throw new DomainBadRequestException("error.skill.report.self"); + } + if (skillReportRepository.existsBySkillIdAndReporterIdAndStatus(skillId, reporterId, SkillReportStatus.PENDING)) { + throw new DomainBadRequestException("error.skill.report.duplicate"); + } + + SkillReport saved = skillReportRepository.save(new SkillReport( + skillId, + skill.getNamespaceId(), + reporterId, + reason.trim(), + normalize(details) + )); + auditLogService.record(reporterId, "REPORT_SKILL", "SKILL", skillId, null, clientIp, userAgent, + "{\"reportId\":" + saved.getId() + "}"); + return saved; + } + + @Transactional + public SkillReport resolveReport(Long reportId, + String actorUserId, + String comment, + String clientIp, + String userAgent) { + SkillReport report = requirePendingReport(reportId); + report.setStatus(SkillReportStatus.RESOLVED); + report.setHandledBy(actorUserId); + report.setHandleComment(normalize(comment)); + report.setHandledAt(LocalDateTime.now()); + SkillReport saved = skillReportRepository.save(report); + auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + return saved; + } + + @Transactional + public SkillReport dismissReport(Long reportId, + String actorUserId, + String comment, + String clientIp, + String userAgent) { + SkillReport report = requirePendingReport(reportId); + report.setStatus(SkillReportStatus.DISMISSED); + report.setHandledBy(actorUserId); + report.setHandleComment(normalize(comment)); + report.setHandledAt(LocalDateTime.now()); + SkillReport saved = skillReportRepository.save(report); + auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + return saved; + } + + private SkillReport requirePendingReport(Long reportId) { + SkillReport report = skillReportRepository.findById(reportId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.report.notFound", reportId)); + if (report.getStatus() != SkillReportStatus.PENDING) { + throw new DomainBadRequestException("error.skill.report.alreadyHandled"); + } + return report; + } + + private String normalize(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java new file mode 100644 index 00000000..527d8ac5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.report; + +public enum SkillReportStatus { + PENDING, + RESOLVED, + DISMISSED +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java new file mode 100644 index 00000000..5495f397 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -0,0 +1,102 @@ +package com.iflytek.skillhub.domain.report; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SkillReportServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillReportRepository skillReportRepository; + + @Mock + private AuditLogService auditLogService; + + private SkillReportService service; + + @BeforeEach + void setUp() { + service = new SkillReportService(skillRepository, skillReportRepository, auditLogService); + } + + @Test + void submitReport_createsPendingReport() { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(false); + when(skillReportRepository.save(any(SkillReport.class))).thenAnswer(invocation -> { + SkillReport report = invocation.getArgument(0); + setField(report, "id", 99L); + return report; + }); + + SkillReport report = service.submitReport(10L, "user-1", "Inappropriate content", "details", "127.0.0.1", "JUnit"); + + assertThat(report.getStatus()).isEqualTo(SkillReportStatus.PENDING); + assertThat(report.getReason()).isEqualTo("Inappropriate content"); + verify(auditLogService).record("user-1", "REPORT_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reportId\":99}"); + } + + @Test + void submitReport_rejectsDuplicatePendingReport() { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillReportRepository.existsBySkillIdAndReporterIdAndStatus(10L, "user-1", SkillReportStatus.PENDING)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, + () -> service.submitReport(10L, "user-1", "Inappropriate content", null, "127.0.0.1", "JUnit")); + } + + @Test + void submitReport_rejectsSelfReport() { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + + assertThrows(DomainBadRequestException.class, + () -> service.submitReport(10L, "owner", "Inappropriate content", null, "127.0.0.1", "JUnit")); + } + + @Test + void resolveReport_marksReportResolved() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + SkillReport saved = service.resolveReport(99L, "admin", "handled", "127.0.0.1", "JUnit"); + + assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); + assertThat(saved.getHandledBy()).isEqualTo("admin"); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java new file mode 100644 index 00000000..1e352760 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillReportJpaRepository.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SkillReportJpaRepository extends JpaRepository, SkillReportRepository { + boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status); + Page findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable); + List findBySkillIdIn(Collection skillIds); + + @Override + default Page findByStatus(SkillReportStatus status, Pageable pageable) { + return findByStatusOrderByCreatedAtDesc(status, pageable); + } +}