mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
feat(report): add skill report backend workflow
This commit is contained in:
parent
0bdca1ab81
commit
cc8ce22c85
18 changed files with 878 additions and 0 deletions
|
|
@ -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<PageResponse<AdminSkillReportSummaryResponse>> 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<SkillReportMutationResponse> 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<SkillReportMutationResponse> 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillReportMutationResponse> 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillReportActionRequest(
|
||||
String comment
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillReportMutationResponse(
|
||||
Long reportId,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillReportSubmitRequest(
|
||||
String reason,
|
||||
String details
|
||||
) {}
|
||||
|
|
@ -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<AdminSkillReportSummaryResponse> listReports(String status, int page, int size) {
|
||||
SkillReportStatus resolvedStatus = parseStatus(status);
|
||||
var reportPage = skillReportRepository.findByStatus(resolvedStatus, PageRequest.of(page, size));
|
||||
|
||||
List<Long> skillIds = reportPage.getContent().stream().map(SkillReport::getSkillId).distinct().toList();
|
||||
Map<Long, Skill> skillsById = skillIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillRepository.findByIdIn(skillIds).stream().collect(Collectors.toMap(Skill::getId, Function.identity()));
|
||||
|
||||
List<Long> namespaceIds = skillsById.values().stream().map(Skill::getNamespaceId).distinct().toList();
|
||||
Map<Long, String> namespaceSlugs = namespaceIds.isEmpty()
|
||||
? Map.of()
|
||||
: namespaceRepository.findByIdIn(namespaceIds).stream().collect(Collectors.toMap(Namespace::getId, Namespace::getSlug));
|
||||
|
||||
List<AdminSkillReportSummaryResponse> 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<Long, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillReport> findById(Long id);
|
||||
boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status);
|
||||
Page<SkillReport> findByStatus(SkillReportStatus status, Pageable pageable);
|
||||
List<SkillReport> findBySkillIdIn(Collection<Long> skillIds);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
public enum SkillReportStatus {
|
||||
PENDING,
|
||||
RESOLVED,
|
||||
DISMISSED
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillReport, Long>, SkillReportRepository {
|
||||
boolean existsBySkillIdAndReporterIdAndStatus(Long skillId, String reporterId, SkillReportStatus status);
|
||||
Page<SkillReport> findByStatusOrderByCreatedAtDesc(SkillReportStatus status, Pageable pageable);
|
||||
List<SkillReport> findBySkillIdIn(Collection<Long> skillIds);
|
||||
|
||||
@Override
|
||||
default Page<SkillReport> findByStatus(SkillReportStatus status, Pageable pageable) {
|
||||
return findByStatusOrderByCreatedAtDesc(status, pageable);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue