feat(report): add skill report backend workflow

This commit is contained in:
yun-zhi-ztl 2026-03-15 16:41:31 +08:00
parent 0bdca1ab81
commit cc8ce22c85
18 changed files with 878 additions and 0 deletions

View file

@ -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()));
}
}

View file

@ -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));
}
}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
public record AdminSkillReportActionRequest(
String comment
) {}

View file

@ -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
) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record SkillReportMutationResponse(
Long reportId,
String status
) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record SkillReportSubmitRequest(
String reason,
String details
) {}

View file

@ -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);
}
}
}

View file

@ -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);

View file

@ -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}

View file

@ -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}

View file

@ -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"))
);
}
}

View file

@ -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"));
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.report;
public enum SkillReportStatus {
PENDING,
RESOLVED,
DISMISSED
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}