From 14328868aecd234ec92a725ca22715c3114ab28a Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 16:16:33 +0800 Subject: [PATCH 01/13] docs: align roadmap with current review flow --- docs/00-product-direction.md | 10 +++++----- docs/05-business-flows.md | 26 ++++++++++---------------- docs/06-api-design.md | 3 ++- docs/10-delivery-roadmap.md | 9 +++++---- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index ce6d4f44..fd9f4fda 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -95,7 +95,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* ## 4. 一期 MVP 功能 核心能力: -- 技能发布(Phase 2 先直达 `PUBLISHED` 跑通主链路,Phase 3 切回“提交 → 审核 → 上线”) +- 技能发布(当前版本采用“提交 → 审核 → 上线”;`SUPER_ADMIN` 保留直发能力) - 技能版本管理(semver + 标签) - 技能浏览、详情、下载(公共技能匿名可访问) - 标签管理(`latest` 系统保留只读 + 自定义标签人工维护) @@ -109,12 +109,12 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* - 创建技能时选择归属空间 审核流程: -- Phase 2:跳过审核,发布链路直达 `PUBLISHED` -- Phase 3 起恢复每版本审核策略 +- 当前版本:普通用户发布后进入审核,审核通过后上线 +- `SUPER_ADMIN` 发布可直达 `PUBLISHED` - 分级审核:团队空间由团队管理员审核,全局空间由平台管理员审核 - 团队技能提升到全局需平台管理员二次审核 - 平台管理员只负责全局空间审核与提升审核,不介入团队空间审核 -- 一期纯人工审核,架构预留自动预检扩展点(`PrePublishValidator`) +- 当前不引入自动审核;`PrePublishValidator` 仅作为未来扩展点保留,默认实现为 `NoOp` 认证与权限: - OAuth2 标准登录(一期 GitHub OAuth) @@ -136,7 +136,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]* - 评论 → Phase 5 上线,含举报机制 - 自动安全扫描 → Phase 5 上线,接入 `PrePublishValidator` 扩展点 - 举报/标记机制 → Phase 5 上线,配合评论和治理闭环 -- 向量搜索 → Phase 3(搜索演进路线) +- 向量搜索 → 当前进入第一阶段规划,仅做搜索增强,不引入推荐系统 - 在线编辑器 → 暂不规划 - Webhook/事件通知 → Phase 5(预留扩展点) - 技能依赖/兼容性声明 → 暂不规划(预留 `parsed_metadata_json` 字段) diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index e01549c4..73c8e794 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -6,7 +6,7 @@ > **设计决策**:一期暂不考虑异步发布(uploadId、publishId、状态轮询、异步转正等)。一期技能包为文本资源包,体积有限(上限 10MB),同步处理足以满足需求。如后续引入大文件或复杂校验流程,再考虑异步模型。 -### 1.1 Phase 2 发布流程基线 +### 1.1 当前发布流程基线 ``` 用户提交发布 @@ -29,11 +29,11 @@ ▼ ④ 持久化数据 - 创建或关联 skill 记录(首次发布时创建 skill) - - 创建 skill_version(status=PUBLISHED) + - 创建 skill_version(普通用户进入 `PENDING_REVIEW`,`SUPER_ADMIN` 直达 `PUBLISHED`) - 创建 skill_file 记录 - 解析 SKILL.md frontmatter → parsed_metadata_json - 生成 manifest_json - - 更新 skill.latest_version_id + - 直发场景更新 skill.latest_version_id │ ▼ ⑤ 同步写入审计日志 @@ -42,16 +42,11 @@ ⑥ 异步触发搜索索引写入 ``` -Phase 2 的目标是先跑通上传、存储、发布、查询、下载完整链路,因此不经过审核,发布结果直接进入 `PUBLISHED`。 +当前版本采用审核流,不再区分“Phase 2 直发”与“Phase 3 恢复审核”两套现实实现: -### 1.2 Phase 3 迁移后的发布流程 - -Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DRAFT → 提交审核 → 人工审核 → 发布”: - -- 发布请求先创建 `skill_version(status=DRAFT)` -- 提交审核后转为 `PENDING_REVIEW` -- 创建 `review_task(status=PENDING)` -- 审核通过后才转为 `PUBLISHED` +- 普通用户发布请求创建 `skill_version(status=PENDING_REVIEW)` +- 同步创建 `review_task(status=PENDING)` +- 审核通过后转为 `PUBLISHED` - 审核拒绝后转为 `REJECTED` - 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员 - 上述例外必须对 Web、`/api/v1/publish`、`/api/compat/v1/publish` 保持一致 @@ -76,10 +71,9 @@ Parts: 一期同步响应:服务端同步完成上传、校验、存储、持久化,返回 `200 OK` + skill_version 信息。 -Phase 2 CLI 默认行为:上传 → 直接发布为 `PUBLISHED`。 -Phase 3 CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核。 +当前 CLI 默认行为:上传 → 进入审核。 如果调用方持有 `SUPER_ADMIN`,则直接发布为 `PUBLISHED`。 -Web 端可保留“发布后再提交审核”的两段式体验,但这属于 Phase 3 能力。 +Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确的审核提示。 `/api/v1/publish` 响应: @@ -199,7 +193,7 @@ Web 端可保留“发布后再提交审核”的两段式体验,但这属于 | `SkillDownloadedEvent` | 下载完成 | 下载计数 | | `SkillStarredEvent` | 收藏/取消 | 收藏计数 | | `SkillRatedEvent` | 评分提交 | 评分重算 | -| `ReviewCompletedEvent` | 审核完成 | 通知提交者(一期可选) | +| `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) | | `SkillPromotedEvent` | 提升到全局 | 搜索索引写入(新 skill) | 一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 6b6b5efc..976ed7e2 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -240,6 +240,7 @@ Public API 的可见性规则: - 普通用户发布成功后,`status` 为 `PENDING_REVIEW` - 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/publish`、`/api/compat/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员 +- 当前版本保持该审核策略,不再提供“全员直发”的运行模式 ## 7.4 Token API(需登录) @@ -254,7 +255,7 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/whoami` | 当前 Bearer Token 对应的用户信息 | -| POST | `/api/v1/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流;`SUPER_ADMIN` 始终直发) | +| POST | `/api/v1/publish` | 发布技能包(普通用户进入审核;`SUPER_ADMIN` 始终直发) | | GET | `/api/v1/resolve/{namespace}/{slug}` | 解析版本 | | GET | `/api/v1/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 | diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index 553f2949..5ef9eb3c 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -49,7 +49,7 @@ - 命名空间 CRUD + 成员管理 - 对象存储集成(LocalFile + S3 双实现) -- 技能发布(上传 → 校验 → 存储 → `PUBLISHED`,一期同步处理) +- 技能发布(上传 → 校验 → 存储 → 审核 / 上线,一期同步处理) - 技能查询(详情、版本、文件)、下载(打包 + 可见性检查,PUBLIC 匿名可下载) - 标签管理、搜索(PostgreSQL Full-Text,匿名搜索限 PUBLIC) - 异步事件基础设施 @@ -64,7 +64,7 @@ ### 验收 -完整发布 → 存储 → 查询 → 下载链路,搜索可用,命名空间隔离生效,匿名用户可浏览/下载公共技能,Phase 2 不经过审核即可完成发布 +完整发布 → 存储 → 审核 → 查询 → 下载链路可用,搜索可用,命名空间隔离生效,匿名用户可浏览/下载公共技能 ## Phase 3:审核流程 + 评分收藏 + CLI API / ClawHub 兼容层 @@ -92,7 +92,7 @@ ### 验收 -发布恢复为必须经审核,团队空间自治审核与全局空间平台审核生效,skillhub CLI Device Flow 可用,ClawHub CLI 通过兼容层可完成核心 registry 操作,评分收藏可用 +团队空间自治审核与全局空间平台审核生效,skillhub CLI Device Flow 可用,ClawHub CLI 通过兼容层可完成核心 registry 操作,评分收藏可用 ## Phase 4:运维增强 + 打磨 + 开源就绪 @@ -131,9 +131,10 @@ - 评论功能 - 举报/标记机制(用户举报 → 管理员处理 → 隐藏/撤回) -- 自动安全预检(`PrePublishValidator` 实现:敏感信息扫描、恶意脚本检测) +- 自动安全预检(`PrePublishValidator` 从当前 `NoOp` 扩展为真实校验链) - Webhook/事件通知(发布通知、审核结果通知) - 后续 OAuth Provider 扩展(GitLab、Google 等) +- 向量搜索第二阶段增强(当前第一阶段仅做搜索增强,不做推荐) ## 主要风险与应对 From 0bdca1ab814148e541d05125a5e7038ddbb835f4 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 16:30:10 +0800 Subject: [PATCH 02/13] feat(skill): add lifecycle management APIs --- .../portal/SkillLifecycleController.java | 118 +++++++++++++ .../dto/SkillLifecycleMutationResponse.java | 8 + .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_zh.properties | 3 + .../portal/SkillLifecycleControllerTest.java | 158 ++++++++++++++++++ .../domain/skill/SkillVersionRepository.java | 2 + .../skill/service/SkillGovernanceService.java | 104 +++++++++++- .../skill/service/SkillPublishService.java | 4 + .../skill/service/SkillQueryService.java | 17 +- .../service/SkillGovernanceServiceTest.java | 115 ++++++++++++- .../service/SkillPublishServiceTest.java | 32 ++++ .../infra/jpa/SkillVersionJpaRepository.java | 1 + 12 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java new file mode 100644 index 00000000..604119ff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -0,0 +1,118 @@ +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.namespace.NamespaceRole; +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.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import com.iflytek.skillhub.dto.AdminSkillActionRequest; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import org.springframework.web.bind.annotation.DeleteMapping; +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 SkillLifecycleController extends BaseApiController { + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillGovernanceService skillGovernanceService; + + public SkillLifecycleController(NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SkillGovernanceService skillGovernanceService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillGovernanceService = skillGovernanceService; + } + + @PostMapping("/{namespace}/{slug}/archive") + public ApiResponse archiveSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody(required = false) AdminSkillActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug); + Skill archived = skillGovernanceService.archiveSkill( + skill.getId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(archived.getId(), null, "ARCHIVE", archived.getStatus().name())); + } + + @PostMapping("/{namespace}/{slug}/unarchive") + public ApiResponse unarchiveSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug); + Skill restored = skillGovernanceService.unarchiveSkill( + skill.getId(), + userId, + userNsRoles != null ? userNsRoles : Map.of(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + + return ok("response.success.updated", + new SkillLifecycleMutationResponse(restored.getId(), null, "UNARCHIVE", restored.getStatus().name())); + } + + @DeleteMapping("/{namespace}/{slug}/versions/{version}") + public ApiResponse deleteVersion(@PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + Skill skill = findSkill(namespace, slug); + SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + skillGovernanceService.deleteVersion( + skill, + skillVersion, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + + return ok("response.success.deleted", + new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version)); + } + + 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/SkillLifecycleMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java new file mode 100644 index 00000000..5cdea644 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record SkillLifecycleMutationResponse( + Long skillId, + Long versionId, + String action, + String status +) {} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index c870d587..37da08e6 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -77,13 +77,16 @@ error.skill.publish.publisher.notMember=Publisher is not a member of namespace: error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.skill.publish.archived=Archived skill must be restored before publishing: {0} error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters error.skill.notFound=Skill not found: {0} error.skill.access.denied=Access denied to skill: {0} error.skill.status.notActive=Skill is not active +error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can manage this skill 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.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 70fe706e..939d6bdb 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -77,13 +77,16 @@ error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0} error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.failed=预发布校验失败:{0} +error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符 error.skill.notFound=未找到技能:{0} error.skill.access.denied=没有权限访问技能:{0} error.skill.status.notActive=技能未处于 ACTIVE 状态 +error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员可以管理该技能 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.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/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java new file mode 100644 index 00000000..3f196de4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -0,0 +1,158 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.anyMap; +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.delete; +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.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +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.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillLifecycleControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private SkillRepository skillRepository; + + @MockBean + private SkillVersionRepository skillVersionRepository; + + @MockBean + private SkillGovernanceService skillGovernanceService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void archiveSkill_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/archive") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}") + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.action").value("ARCHIVE")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + @Test + void unarchiveSkill_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class))) + .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE)); + + mockMvc.perform(post("/api/web/skills/global/demo-skill/unarchive") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.action").value("UNARCHIVE")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + @Test + void deleteVersion_returnsUnifiedEnvelope() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner"); + setNamespaceId(namespace, 1L); + Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC); + setSkillId(skill, 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setSkillVersionId(version, 2L); + version.setStatus(SkillVersionStatus.DRAFT); + + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill)); + given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); + + mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0") + .requestAttr("userId", "usr_1") + .requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN)) + .with(user("usr_1")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(1)) + .andExpect(jsonPath("$.data.versionId").value(2)) + .andExpect(jsonPath("$.data.action").value("DELETE_VERSION")) + .andExpect(jsonPath("$.data.status").value("1.0.0")); + } + + private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) { + skill.setStatus(status); + return skill; + } + + private void setNamespaceId(Namespace namespace, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", id); + } + + private void setSkillId(Skill skill, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(skill, "id", id); + } + + private void setSkillVersionId(SkillVersion version, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(version, "id", id); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index db7f457c..6a5739c7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -7,7 +7,9 @@ public interface SkillVersionRepository { Optional findById(Long id); List findByIdIn(List ids); List findBySkillIdIn(List skillIds); + List findBySkillId(Long skillId); Optional findBySkillIdAndVersion(Long skillId, String version); List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); + void delete(SkillVersion version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 6f09cb32..3a17f5ef 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -1,13 +1,24 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.storage.ObjectStorageService; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,14 +27,23 @@ public class SkillGovernanceService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; + private final SkillFileRepository skillFileRepository; + private final ObjectStorageService objectStorageService; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; public SkillGovernanceService(SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, - AuditLogService auditLogService) { + SkillFileRepository skillFileRepository, + ObjectStorageService objectStorageService, + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; + this.skillFileRepository = skillFileRepository; + this.objectStorageService = objectStorageService; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; } @Transactional @@ -39,6 +59,26 @@ public class SkillGovernanceService { return saved; } + @Transactional + public Skill archiveSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + SkillStatus previousStatus = skill.getStatus(); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ARCHIVED)); + return saved; + } + @Transactional public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) { Skill skill = skillRepository.findById(skillId) @@ -52,6 +92,56 @@ public class SkillGovernanceService { return saved; } + @Transactional + public Skill unarchiveSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + SkillStatus previousStatus = skill.getStatus(); + skill.setStatus(SkillStatus.ACTIVE); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "UNARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE)); + return saved; + } + + @Transactional + public void deleteVersion(Skill skill, + SkillVersion version, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent) { + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); + } + + List files = skillFileRepository.findByVersionId(version.getId()); + if (!files.isEmpty()) { + objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList()); + } + objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId())); + skillFileRepository.deleteByVersionId(version.getId()); + skillVersionRepository.delete(version); + auditLogService.record( + actorUserId, + "DELETE_SKILL_VERSION", + "SKILL_VERSION", + version.getId(), + null, + clientIp, + userAgent, + "{\"version\":\"" + version.getVersion().replace("\"", "\\\"") + "\"}" + ); + } + @Transactional public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) { SkillVersion version = skillVersionRepository.findById(versionId) @@ -65,6 +155,18 @@ public class SkillGovernanceService { return saved; } + private void assertCanManageLifecycle(Skill skill, + String actorUserId, + Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } + private String jsonReason(String reason) { if (reason == null || reason.isBlank()) { return null; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index a6cd6396..ca67907c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -142,6 +142,10 @@ public class SkillPublishService { return skillRepository.save(newSkill); }); + if (skill.getStatus() == SkillStatus.ARCHIVED) { + throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); + } + // 7. Check version doesn't already exist if (skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()).isPresent()) { throw new DomainBadRequestException("error.skill.version.exists", metadata.version()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index e4953ebf..f496dd61 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -383,14 +383,27 @@ public class SkillQueryService { } private void assertPublishedAccessible(Skill skill, String currentUserId, Map userNsRoles) { - if (skill.getStatus() != SkillStatus.ACTIVE) { - throw new DomainBadRequestException("error.skill.status.notActive"); + if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } } + private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles) { + if (currentUserId == null) { + return false; + } + NamespaceRole role = userNsRoles.get(skill.getNamespaceId()); + return skill.getOwnerId().equals(currentUserId) + || role == NamespaceRole.ADMIN + || role == NamespaceRole.OWNER; + } + private void assertPublishedVersion(SkillVersion version, String versionStr) { if (version.getStatus() != SkillVersionStatus.PUBLISHED) { throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 9df6b643..469bd2fc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -1,17 +1,30 @@ package com.iflytek.skillhub.domain.skill.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.BDDMockito.given; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.storage.ObjectStorageService; import java.util.Optional; +import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,13 +39,26 @@ class SkillGovernanceServiceTest { @Mock private SkillVersionRepository skillVersionRepository; @Mock + private SkillFileRepository skillFileRepository; + @Mock + private ObjectStorageService objectStorageService; + @Mock private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; private SkillGovernanceService service; @BeforeEach void setUp() { - service = new SkillGovernanceService(skillRepository, skillVersionRepository, auditLogService); + service = new SkillGovernanceService( + skillRepository, + skillVersionRepository, + skillFileRepository, + objectStorageService, + auditLogService, + eventPublisher + ); } @Test @@ -48,6 +74,45 @@ class SkillGovernanceServiceTest { verify(auditLogService).record("admin", "HIDE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"policy\"}"); } + @Test + void archiveSkill_marksSkillArchived() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.archiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit", "cleanup"); + + assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED); + verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}"); + verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class)); + } + + @Test + void unarchiveSkill_restoresActiveStatus() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setStatus(SkillStatus.ARCHIVED); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.unarchiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit"); + + assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE); + verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null); + verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class)); + } + + @Test + void archiveSkill_requiresOwnerOrNamespaceAdmin() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + + assertThrows(DomainForbiddenException.class, + () -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), "127.0.0.1", "JUnit", null)); + } + @Test void yankVersion_setsYankedStatus() { SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); @@ -61,4 +126,52 @@ class SkillGovernanceServiceTest { assertThat(result.getYankedBy()).isEqualTo("admin"); verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); } + + @Test + void deleteVersion_removesDraftFilesAndBundle() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.DRAFT); + SkillFile readme = new SkillFile(version.getId(), "README.md", 10L, "text/markdown", "sha1", "skills/demo/readme"); + SkillFile icon = new SkillFile(version.getId(), "icon.png", 20L, "image/png", "sha2", "skills/demo/icon"); + given(skillFileRepository.findByVersionId(version.getId())).willReturn(java.util.List.of(readme, icon)); + + service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit"); + + verify(objectStorageService).deleteObjects(argThat(keys -> + keys.size() == 2 + && keys.contains("skills/demo/readme") + && keys.contains("skills/demo/icon"))); + verify(objectStorageService).deleteObject("packages/1/2/bundle.zip"); + verify(skillFileRepository).deleteByVersionId(2L); + verify(skillVersionRepository).delete(version); + verify(auditLogService).record("owner", "DELETE_SKILL_VERSION", "SKILL_VERSION", 2L, null, "127.0.0.1", "JUnit", "{\"version\":\"1.0.0\"}"); + } + + @Test + void deleteVersion_rejectsPublishedVersion() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + assertThrows(DomainBadRequestException.class, + () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit")); + + verify(skillVersionRepository, never()).delete(any()); + verify(objectStorageService, never()).deleteObject(any()); + } + + 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-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index a184de8e..02cc64e2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -229,6 +229,38 @@ class SkillPublishServiceTest { verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); } + @Test + void testPublishFromEntries_ShouldRejectArchivedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + Skill archivedSkill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + archivedSkill.setStatus(SkillStatus.ARCHIVED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(Optional.of(archivedSkill)); + + assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + } + @Test void testPublishFromEntries_ShouldAutoGenerateVersionWhenMissing() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index d5c95394..e6095270 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -14,6 +14,7 @@ import java.util.Optional; @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { List findByIdIn(List ids); + List findBySkillId(Long skillId); List findBySkillIdIn(List skillIds); Optional findBySkillIdAndVersion(Long skillId, String version); 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 03/13] 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); + } +} From 411e592362696579f5965395962510e6e8ae1bd6 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 16:47:53 +0800 Subject: [PATCH 04/13] feat(report): add skill report UI and admin page --- web/src/api/client.ts | 44 +++++ web/src/api/types.ts | 16 ++ web/src/app/router.tsx | 15 ++ web/src/features/report/use-skill-reports.ts | 38 +++++ web/src/i18n/locales/en.json | 39 ++++- web/src/i18n/locales/zh.json | 39 ++++- web/src/pages/dashboard.tsx | 13 +- web/src/pages/dashboard/reports.tsx | 161 +++++++++++++++++++ web/src/pages/skill-detail.tsx | 72 +++++++++ web/src/shared/components/user-menu.tsx | 7 + 10 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 web/src/features/report/use-skill-reports.ts create mode 100644 web/src/pages/dashboard/reports.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index af43b166..9507434a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,6 +15,7 @@ import type { PromotionTask, AuditLogItem, SkillSummary, + SkillReport, AuthMethod, OAuthProvider, User, @@ -562,6 +563,49 @@ export const promotionApi = { }, } +export const reportApi = { + async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/reports`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async listSkillReports(params: { status?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('status', params.status ?? 'PENDING') + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: SkillReport[]; total: number; page: number; size: number }>( + `/api/v1/admin/skill-reports?${searchParams.toString()}`, + ) + }, + + async resolveSkillReport(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/admin/skill-reports/${id}/resolve`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, + + async dismissSkillReport(id: number, comment?: string): Promise { + await fetchJson(`/api/v1/admin/skill-reports/${id}/dismiss`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ comment }), + }) + }, +} + export const meApi = { async getStars(): Promise { return fetchJson(`${WEB_API_PREFIX}/me/stars`) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5b5f35a3..67a218a2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -220,6 +220,22 @@ export interface PromotionTask { reviewedAt?: string } +export interface SkillReport { + id: number + skillId: number + namespace?: string + skillSlug?: string + skillDisplayName?: string + reporterId: string + reason: string + details?: string + status: 'PENDING' | 'RESOLVED' | 'DISMISSED' | string + handledBy?: string + handleComment?: string + createdAt: string + handledAt?: string +} + export interface AdminUser { userId: string username: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d0167041..0d9af6f3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -51,6 +51,7 @@ const NamespaceReviewsPage = createLazyRouteComponent( 'NamespaceReviewsPage', ) const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') +const ReportsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reports'), 'ReportsPage') const ReviewDetailPage = createLazyRouteComponent( () => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage', @@ -212,6 +213,19 @@ const dashboardReviewsRoute = createRoute({ component: ReviewsPage, }) +const dashboardReportsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/reports', + beforeLoad: async (ctx) => { + const { user } = await requireAuth(ctx) + if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) { + throw redirect({ to: '/dashboard' }) + } + return { user } + }, + component: ReportsPage, +}) + const dashboardReviewDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews/$id', @@ -308,6 +322,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, dashboardReviewsRoute, + dashboardReportsRoute, dashboardReviewDetailRoute, dashboardPromotionsRoute, dashboardStarsRoute, diff --git a/web/src/features/report/use-skill-reports.ts b/web/src/features/report/use-skill-reports.ts new file mode 100644 index 00000000..284cce4e --- /dev/null +++ b/web/src/features/report/use-skill-reports.ts @@ -0,0 +1,38 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { reportApi } from '@/api/client' + +export function useSkillReports(status: string) { + return useQuery({ + queryKey: ['skill-reports', status], + queryFn: async () => { + const page = await reportApi.listSkillReports({ status }) + return page.items + }, + }) +} + +export function useSubmitSkillReport(namespace: string, slug: string) { + return useMutation({ + mutationFn: (request: { reason: string; details?: string }) => reportApi.submitSkillReport(namespace, slug, request), + }) +} + +export function useResolveSkillReport() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.resolveSkillReport(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skill-reports'] }) + }, + }) +} + +export function useDismissSkillReport() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.dismissSkillReport(id, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skill-reports'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 41f34664..4dceae8d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -182,7 +182,9 @@ "credentials": "Credentials", "openTokens": "Open Token Page", "governanceTitle": "Review & Governance", - "viewPromotions": "View Promotions" + "viewPromotions": "View Promotions", + "reportsTitle": "Report Management", + "viewReports": "View skill reports" }, "mySkills": { "title": "My Skills", @@ -374,7 +376,39 @@ "processing": "Processing...", "hideSkill": "Hide Skill", "unhideSkill": "Unhide Skill", - "yankVersion": "Yank Current Version" + "yankVersion": "Yank Current Version", + "reportSkill": "Report Skill", + "reportDialogTitle": "Report skill", + "reportDialogDescription": "Provide a reason so administrators can review and act on it quickly.", + "reportReasonPlaceholder": "Reason, for example policy violation, misleading content, or infringement", + "reportDetailsPlaceholder": "Additional details (optional)", + "submitReport": "Submit Report", + "reportReasonRequired": "Please provide a report reason", + "reportSuccessTitle": "Report submitted", + "reportSuccessDescription": "Administrators will review this report soon.", + "reportErrorTitle": "Report failed" + }, + "reports": { + "title": "Skill Reports", + "subtitle": "Handle user-submitted skill reports", + "tabPending": "Pending", + "tabResolved": "Resolved", + "tabDismissed": "Dismissed", + "empty": "No reports", + "reporter": "Reporter", + "handledBy": "Handled by", + "resolve": "Resolve", + "dismiss": "Dismiss", + "resolveConfirmTitle": "Resolve report", + "resolveConfirmDescription": "Mark the report for \"{{skill}}\" as handled?", + "dismissConfirmTitle": "Dismiss report", + "dismissConfirmDescription": "Dismiss the report for \"{{skill}}\"?", + "resolveSuccessTitle": "Report resolved", + "resolveSuccessDescription": "The report for \"{{skill}}\" has been marked as handled.", + "dismissSuccessTitle": "Report dismissed", + "dismissSuccessDescription": "The report for \"{{skill}}\" has been dismissed.", + "resolveErrorTitle": "Failed to resolve report", + "dismissErrorTitle": "Failed to dismiss report" }, "members": { "title": "Member Management", @@ -471,6 +505,7 @@ "stars": "Starred", "reviews": "Review Management", "promotions": "Promotion Management", + "reports": "Report Management", "users": "User Management", "auditLog": "Audit Log", "security": "Security Settings", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3e319ff5..4024fb8a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -182,7 +182,9 @@ "credentials": "访问凭证", "openTokens": "打开 Token 页面", "governanceTitle": "审核与治理", - "viewPromotions": "查看提升审核" + "viewPromotions": "查看提升审核", + "reportsTitle": "举报管理", + "viewReports": "查看技能举报" }, "mySkills": { "title": "我的技能", @@ -374,7 +376,39 @@ "processing": "处理中...", "hideSkill": "隐藏技能", "unhideSkill": "恢复技能", - "yankVersion": "撤回当前版本" + "yankVersion": "撤回当前版本", + "reportSkill": "举报技能", + "reportDialogTitle": "举报技能", + "reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。", + "reportReasonPlaceholder": "举报原因,例如包含违规内容、恶意误导、侵权等", + "reportDetailsPlaceholder": "补充说明(可选)", + "submitReport": "提交举报", + "reportReasonRequired": "请先填写举报原因", + "reportSuccessTitle": "举报已提交", + "reportSuccessDescription": "管理员将尽快处理这条举报。", + "reportErrorTitle": "举报失败" + }, + "reports": { + "title": "技能举报", + "subtitle": "处理用户提交的技能举报", + "tabPending": "待处理", + "tabResolved": "已处理", + "tabDismissed": "已驳回", + "empty": "暂无举报记录", + "reporter": "举报人", + "handledBy": "处理人", + "resolve": "标记处理", + "dismiss": "驳回举报", + "resolveConfirmTitle": "确认处理举报", + "resolveConfirmDescription": "确认将“{{skill}}”的举报标记为已处理吗?", + "dismissConfirmTitle": "确认驳回举报", + "dismissConfirmDescription": "确认驳回“{{skill}}”的这条举报吗?", + "resolveSuccessTitle": "举报已处理", + "resolveSuccessDescription": "“{{skill}}”的举报已标记为已处理。", + "dismissSuccessTitle": "举报已驳回", + "dismissSuccessDescription": "“{{skill}}”的举报已驳回。", + "resolveErrorTitle": "处理举报失败", + "dismissErrorTitle": "驳回举报失败" }, "members": { "title": "成员管理", @@ -471,6 +505,7 @@ "stars": "我的收藏", "reviews": "审核管理", "promotions": "推广管理", + "reports": "举报管理", "users": "用户管理", "auditLog": "审计日志", "security": "安全设置", diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 7fc83e9b..12d5d314 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/sha export function DashboardPage() { const { t } = useTranslation() - const { user } = useAuth() + const { user, hasRole } = useAuth() + const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') return (
@@ -58,7 +59,7 @@ export function DashboardPage() { -
+
{t('dashboard.starsAndRatings')}
@@ -77,6 +78,14 @@ export function DashboardPage() { {t('dashboard.viewPromotions')}
+ {governanceVisible ? ( + +
{t('dashboard.reportsTitle')}
+ + {t('dashboard.viewReports')} + +
+ ) : null}
diff --git a/web/src/pages/dashboard/reports.tsx b/web/src/pages/dashboard/reports.tsx new file mode 100644 index 00000000..1b25e3e5 --- /dev/null +++ b/web/src/pages/dashboard/reports.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Card } from '@/shared/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { Button } from '@/shared/ui/button' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { useDismissSkillReport, useResolveSkillReport, useSkillReports } from '@/features/report/use-skill-reports' +import { toast } from '@/shared/lib/toast' + +export function ReportsPage() { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + const [pendingAction, setPendingAction] = useState<{ id: number; action: 'resolve' | 'dismiss'; skillLabel: string } | null>(null) + const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING') + const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED') + const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED') + const resolveMutation = useResolveSkillReport() + const dismissMutation = useDismissSkillReport() + + const formatDate = (dateString: string) => new Date(dateString).toLocaleString(i18n.language) + + const handleOpenSkill = (namespace?: string, skillSlug?: string) => { + if (!namespace || !skillSlug) { + return + } + navigate({ to: `/space/${namespace}/${skillSlug}` }) + } + + const handleConfirm = async () => { + if (!pendingAction) { + return + } + try { + if (pendingAction.action === 'resolve') { + await resolveMutation.mutateAsync({ id: pendingAction.id }) + toast.success(t('reports.resolveSuccessTitle'), t('reports.resolveSuccessDescription', { skill: pendingAction.skillLabel })) + } else { + await dismissMutation.mutateAsync({ id: pendingAction.id }) + toast.success(t('reports.dismissSuccessTitle'), t('reports.dismissSuccessDescription', { skill: pendingAction.skillLabel })) + } + setPendingAction(null) + } catch (error) { + toast.error( + pendingAction.action === 'resolve' ? t('reports.resolveErrorTitle') : t('reports.dismissErrorTitle'), + error instanceof Error ? error.message : '', + ) + } + } + + const renderList = (reports: typeof pendingReports, isLoading: boolean, status: 'PENDING' | 'RESOLVED' | 'DISMISSED') => { + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) + } + + if (!reports || reports.length === 0) { + return {t('reports.empty')} + } + + return ( +
+ {reports.map((report) => { + const skillLabel = report.skillDisplayName || report.skillSlug || `#${report.skillId}` + return ( + +
+
+ +
{skillLabel}
+
{report.reason}
+ {report.details ?
{report.details}
: null} +
+
+
{t('reports.reporter')}: {report.reporterId}
+
{formatDate(report.createdAt)}
+ {report.handledAt ?
{formatDate(report.handledAt)}
: null} +
+
+ + {status === 'PENDING' ? ( +
+ + +
+ ) : ( +
+ {t('reports.handledBy')}: {report.handledBy || '—'} +
+ )} +
+ ) + })} +
+ ) + } + + return ( +
+ + + + + {t('reports.tabPending')} + {t('reports.tabResolved')} + {t('reports.tabDismissed')} + + + + {renderList(pendingReports, isPendingLoading, 'PENDING')} + + + {renderList(resolvedReports, isResolvedLoading, 'RESOLVED')} + + + {renderList(dismissedReports, isDismissedLoading, 'DISMISSED')} + + + + { + if (!open) { + setPendingAction(null) + } + }} + title={pendingAction?.action === 'resolve' ? t('reports.resolveConfirmTitle') : t('reports.dismissConfirmTitle')} + description={pendingAction?.action === 'resolve' + ? t('reports.resolveConfirmDescription', { skill: pendingAction?.skillLabel ?? '' }) + : t('reports.dismissConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })} + confirmText={pendingAction?.action === 'resolve' ? t('reports.resolve') : t('reports.dismiss')} + onConfirm={handleConfirm} + /> +
+ ) +} diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index e0b70dc8..fa5ccd25 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -9,12 +10,17 @@ import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' import { adminApi, WEB_API_PREFIX } from '@/api/client' +import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { formatLocalDateTime } from '@/shared/lib/date-time' import { formatCompactCount } from '@/shared/lib/number-format' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Textarea } from '@/shared/ui/textarea' +import { toast } from '@/shared/lib/toast' import { useSkillDetail, useSkillVersions, @@ -27,6 +33,9 @@ export function SkillDetailPage() { const navigate = useNavigate() const location = useRouterState({ select: (s) => s.location }) const queryClient = useQueryClient() + const [reportDialogOpen, setReportDialogOpen] = useState(false) + const [reportReason, setReportReason] = useState('') + const [reportDetails, setReportDetails] = useState('') const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() @@ -57,6 +66,7 @@ export function SkillDetailPage() { mutationFn: () => adminApi.yankVersion(latestVersion!.id), onSuccess: refreshSkill, }) + const reportMutation = useSubmitSkillReport(namespace, slug) const handleDownload = () => { if (!user) { @@ -80,6 +90,34 @@ export function SkillDetailPage() { }) } + const handleOpenReport = () => { + if (!user) { + requireLogin() + return + } + setReportDialogOpen(true) + } + + const handleSubmitReport = async () => { + if (!reportReason.trim()) { + toast.error(t('skillDetail.reportReasonRequired')) + return + } + + try { + await reportMutation.mutateAsync({ + reason: reportReason.trim(), + details: reportDetails.trim() || undefined, + }) + setReportDialogOpen(false) + setReportReason('') + setReportDetails('') + toast.success(t('skillDetail.reportSuccessTitle'), t('skillDetail.reportSuccessDescription')) + } catch (error) { + toast.error(t('skillDetail.reportErrorTitle'), error instanceof Error ? error.message : '') + } + } + const handleBack = () => { if (window.history.length > 1) { window.history.back() @@ -253,6 +291,9 @@ export function SkillDetailPage() {
+ {!user && (

{t('skillDetail.loginToRate')}

)} @@ -305,6 +346,37 @@ export function SkillDetailPage() { )}
+ + + + + {t('skillDetail.reportDialogTitle')} + {t('skillDetail.reportDialogDescription')} + +
+ setReportReason(event.target.value)} + placeholder={t('skillDetail.reportReasonPlaceholder')} + maxLength={200} + /> +