From 2868c10467b6445033947ccdfd334a7dc260703b Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 20 Mar 2026 10:06:32 +0800 Subject: [PATCH] refactor(app): slim portal controllers and sync backend findings --- docs/02-domain-model.md | 12 + docs/03-authentication-design.md | 51 +- docs/05-business-flows.md | 47 +- docs/06-api-design.md | 39 +- docs/17-backend-annotation-findings.md | 46 +- .../compat/ClawHubCompatAppService.java | 378 ++++++++++++++ .../compat/ClawHubCompatController.java | 465 +++--------------- .../compat/ClawHubRegistryFacade.java | 21 +- .../compat/CompatSkillLookupService.java | 80 +++ .../portal/PromotionController.java | 173 +------ .../controller/portal/ReviewController.java | 237 ++------- .../exception/GlobalExceptionHandler.java | 45 +- .../skillhub/exception/LocalizedError.java | 7 +- .../service/PromotionPortalAppService.java | 211 ++++++++ .../service/ReviewPortalAppService.java | 278 +++++++++++ .../compat/ClawHubRegistryFacadeTest.java | 9 +- .../auth/exception/AuthFlowException.java | 13 +- .../exception/DomainBadRequestException.java | 5 + .../exception/DomainForbiddenException.java | 5 + .../exception/DomainNotFoundException.java | 5 + .../exception/LocalizedDomainException.java | 4 +- .../shared/exception/LocalizedMessage.java | 11 + 22 files changed, 1290 insertions(+), 852 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedMessage.java diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 20e31621..65e92c29 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -77,6 +77,12 @@ - 唯一约束:`(namespace_id, slug)` - `status` 表示 skill 容器生命周期,不再承载“隐藏”语义。隐藏是独立的治理覆盖层,由 `hidden` / `hidden_at` / `hidden_by` 表达 +- 当前代码下的实际可见性判定以 `VisibilityChecker` 为准,规则如下: + - 若 `hidden=true`:仅 skill owner 或该 namespace 的 `ADMIN` / `OWNER` 可读 + - 若 `latest_version_id is null`:仅 skill owner 可读;即使 `visibility=PUBLIC` 也不会对外公开 + - `PUBLIC`:任意人可读 skill 容器与已发布版本 + - `NAMESPACE_ONLY`:该 namespace 任意成员可读(`MEMBER` / `ADMIN` / `OWNER`) + - `PRIVATE`:仅 skill owner 或该 namespace 的 `ADMIN` / `OWNER` 可读,普通 `MEMBER` 不可读 - `owner_id` 语义为"主要维护人",可转让。权限主轴是 namespace role,不是 owner: - namespace ADMIN 对空间内所有 skill 有完整管理权(归档、版本管理、提升到全局),不受 owner 限制 - owner 作为 MEMBER 时可管理自己创建的 skill(提交审核、编辑草稿) @@ -113,6 +119,12 @@ - 已发布撤回:`PUBLISHED → YANKED` - 唯一约束:`(skill_id, version)` 防止重复发布 - `YANKED` 状态:已发布后撤回 +- 当前代码下的实际读权限补充: + - 普通详情 / 下载 / resolve / tag / 文件读取,只接受 `PUBLISHED` + - owner 可通过常规版本详情预览自己的 `PENDING_REVIEW` 版本 + - owner / namespace `ADMIN` / `OWNER` 在版本列表中可看到全部五种状态:`PUBLISHED / PENDING_REVIEW / DRAFT / REJECTED / YANKED` + - 但常规版本详情接口并不会放行 `DRAFT / REJECTED / YANKED` + - 审核详情页走独立 review 读路径,可查看待审版本及完整版本快照 版本号不可变性规则: diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2f491704..a97a69fe 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -396,14 +396,14 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 | 操作 | 所需权限 | 判定逻辑 | |------|---------|---------| -| 提交发布审核 | `skill:publish` | 用户是该 namespace 的 MEMBER 以上,且 namespace 非 FROZEN | +| 发布技能包 | `skill:publish` | 普通用户要求是目标 namespace 成员;`SUPER_ADMIN` 可绕过成员校验并直发 | +| 提交已有版本进入审核 | `review:submit` | owner 本人,或 namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` | | 管理技能(归档/版本管理) | `skill:manage` | namespace ADMIN 以上,或 owner 本人 | | 提升到全局 | `skill:promote` | namespace ADMIN 以上,或 owner 本人 | -| 审核团队空间技能 | `review:approve` | 该 namespace 的 ADMIN 或 OWNER | -| 审核全局空间技能 | `review:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN | +| 审核技能发布 | `review:approve` | namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN`;提交人本人仅 `SUPER_ADMIN` 可审核自己的 review task | | 审核提升申请 | `promotion:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN | -| 隐藏/恢复技能 | `skill:manage` | 持有 SKILL_ADMIN / SUPER_ADMIN | -| 撤回已发布版本(YANK) | `skill:manage` | 持有 SKILL_ADMIN / SUPER_ADMIN | +| 隐藏/恢复技能 | `skill:manage` | 仅 `SUPER_ADMIN` | +| 撤回已发布版本(YANK) | `skill:manage` | `SKILL_ADMIN` / `SUPER_ADMIN` | | 管理用户角色 | `user:manage` | 持有 USER_ADMIN / SUPER_ADMIN | | 审批用户准入 | `user:approve` | 持有 USER_ADMIN / SUPER_ADMIN | | 查看审计日志 | `audit:read` | 持有 AUDITOR / SUPER_ADMIN | @@ -417,14 +417,13 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 | API 路径 | 适用范围 | 权限要求 | |----------|---------|---------| -| `POST /api/v1/admin/reviews/{id}/approve` | 全局空间审核 | SKILL_ADMIN / SUPER_ADMIN | -| `POST /api/v1/admin/promotions/{id}/approve` | 提升到全局审核 | SKILL_ADMIN / SUPER_ADMIN | -| `POST /api/v1/namespaces/{slug}/reviews/{id}/approve` | 团队空间内发布审核 | 该空间 ADMIN / OWNER | +| `POST /api/v1/reviews/{id}/approve` | 技能发布审核 | namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` | +| `POST /api/v1/promotions/{id}/approve` | 提升到全局审核 | `SKILL_ADMIN` / `SUPER_ADMIN` | | `GET /api/v1/admin/audit-logs` | 审计日志查询 | AUDITOR / SUPER_ADMIN | | `PUT /api/v1/admin/users/{id}/roles` | 用户角色管理 | USER_ADMIN / SUPER_ADMIN | | `POST /api/v1/admin/users/{id}/approve` | 用户准入审批 | USER_ADMIN / SUPER_ADMIN | -SUPER_ADMIN 和持有对应角色的用户均可通过 Admin API 操作;团队空间审核限定通过 Namespace API 完成,平台管理员不越权进入团队空间审核流程。 +当前实现中,审核与提升都走统一 portal API;是否允许操作由服务层根据 namespace role 与 platform role 联合判定,而不是靠分叉路由表达。 ## 7. Session 设计 @@ -587,11 +586,11 @@ window.location.href = '/oauth2/authorization/github' | 接口 | 匿名 | 已登录 | 判定逻辑 | |------|------|--------|---------| -| `GET /api/v1/skills`(搜索) | PUBLIC 技能 | PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin) | `SearchVisibilityScope` 投影 | -| `GET /api/v1/skills/{ns}/{slug}` | PUBLIC 技能 | 同上 | visibility + namespace 成员关系 | -| `GET /api/v1/skills/{ns}/{slug}/versions` | PUBLIC 技能 | 同上 | 同上 | -| `GET /api/v1/skills/{ns}/{slug}/download` | PUBLIC 技能 | 同上 | 同上 | -| `GET /api/v1/skills/{ns}/{slug}/resolve` | PUBLIC 技能 | 同上 | 同上 | +| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin)` | `SearchVisibilityScope` + 搜索索引状态 | +| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` | +| `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 | +| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须是 `PUBLISHED` | visibility + namespace type + version status | +| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status | | `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 | ### 10.2 Authenticated API @@ -600,27 +599,27 @@ window.location.href = '/oauth2/authorization/github' |------|---------|---------| | `POST /api/v1/skills/{ns}/{slug}/star` | 已登录 | Session/Token | | `POST /api/v1/skills/{ns}/{slug}/rating` | 已登录 | Session/Token | -| `POST .../versions/{ver}/submit-review` | namespace MEMBER 以上 | `namespace_member.role` | +| `POST /api/v1/reviews` | owner 本人,或 namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` | `skill.owner_id` / `namespace_member.role` / platform roles | | `POST .../versions/{ver}/withdraw-review` | 提交人本人 | `review_task.submitted_by` | | `PUT /api/v1/skills/{ns}/{slug}/tags/{tag}` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | | `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | -| `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 DRAFT/REJECTED) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | +| `POST .../versions/{ver}/rerelease` | namespace ADMIN 以上 或 owner;源版本必须 `PUBLISHED` | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | +| `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 `DRAFT` / `REJECTED`) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | ### 10.3 CLI API | 接口 | 所需凭证 | 额外判定 | |------|---------|---------| | `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | -| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 | +| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 | ### 10.4 Admin API | 接口 | 所需平台角色 | 判定来源 | |------|------------|---------| -| `POST /api/v1/admin/reviews/{id}/approve` | SKILL_ADMIN / SUPER_ADMIN | `user_role_binding` → `role_permission` | -| `POST /api/v1/admin/reviews/{id}/reject` | SKILL_ADMIN / SUPER_ADMIN | 同上 | -| `POST /api/v1/admin/promotions/{id}/approve` | SKILL_ADMIN / SUPER_ADMIN | 同上 | -| `POST /api/v1/admin/promotions/{id}/reject` | SKILL_ADMIN / SUPER_ADMIN | 同上 | +| `POST /api/v1/admin/skills/{id}/hide` | SUPER_ADMIN | `user_role_binding` → `role_permission` | +| `POST /api/v1/admin/skills/{id}/unhide` | SUPER_ADMIN | 同上 | +| `POST /api/v1/admin/skills/versions/{versionId}/yank` | SKILL_ADMIN / SUPER_ADMIN | 同上 | | `PUT /api/v1/admin/users/{id}/roles` | USER_ADMIN / SUPER_ADMIN | 同上,且 USER_ADMIN 不可分配 SUPER_ADMIN | | `POST /api/v1/admin/users/{id}/approve` | USER_ADMIN / SUPER_ADMIN | 同上 | | `POST /api/v1/admin/users/{id}/ban` | USER_ADMIN / SUPER_ADMIN | 同上 | @@ -630,11 +629,9 @@ window.location.href = '/oauth2/authorization/github' | 接口 | 所需 namespace 角色 | 判定来源 | |------|-------------------|---------| -| `POST /api/v1/namespaces/{slug}/reviews/{id}/approve` | 该空间 ADMIN / OWNER | `namespace_member.role` | -| `POST /api/v1/namespaces/{slug}/reviews/{id}/reject` | 该空间 ADMIN / OWNER | `namespace_member.role` | | `POST /api/v1/namespaces/{slug}/members` | 该空间 ADMIN 以上 | `namespace_member.role` | | `DELETE /api/v1/namespaces/{slug}/members/{userId}` | 该空间 ADMIN 以上 | `namespace_member.role` | -| `POST .../skills/{skillId}/promote` | 该空间 ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | +| `POST /api/v1/promotions` | 该空间 ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | ### 10.6 Compatibility API(Bearer Token 认证) @@ -642,6 +639,6 @@ window.location.href = '/oauth2/authorization/github' |------|---------|---------| | `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | | `GET /api/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` | -| `GET /api/v1/resolve` | 可选(匿名限 PUBLIC) | visibility | -| `GET /api/v1/download/{slug}/{version}` | 可选(匿名限 PUBLIC) | visibility | -| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上(namespace 由 canonical slug 解析) | +| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status | +| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status | +| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过(namespace 由 canonical slug 解析) | diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index c5f53d03..e0719939 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -59,7 +59,7 @@ - `headlineVersion`:当前详情页/我的技能列表主展示版本 - `publishedVersion`:当前最新可公开分发的已发布版本 -- `ownerPreviewVersion`:owner 或 namespace 管理者可见的待审核版本 +- `ownerPreviewVersion`:详情 projection 中仅暴露给 owner / namespace 管理者的 `PENDING_REVIEW` 版本 - `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE` 业务规则: @@ -69,6 +69,51 @@ - 推广到全局、安装命令、公开下载都只能绑定到 `publishedVersion` - `hidden` 是独立治理覆盖层,不属于 skill 生命周期状态机 +### 1.3 Skill 可见性与角色访问矩阵 + +以下矩阵以当前后端实现为准,综合了 `VisibilityChecker`、`SkillQueryService`、`SkillDownloadService`、`ReviewPermissionChecker` 的实际行为。 + +#### 1.3.1 Skill 容器读取 + +| 角色 | PUBLIC | NAMESPACE_ONLY | PRIVATE | hidden 任意 visibility | 无 `publishedVersion`(`latest_version_id=null`) | +|------|--------|----------------|---------|------------------------|-----------------------------------------------| +| 匿名用户 | 可读 | 不可读 | 不可读 | 不可读 | 不可读 | +| 登录非成员 | 可读 | 不可读 | 不可读 | 不可读 | 不可读 | +| namespace MEMBER | 可读 | 可读 | 不可读 | 不可读 | 仅自己是 owner 时可读 | +| skill owner | 可读 | 可读 | 可读 | 可读 | 可读 | +| namespace ADMIN / OWNER | 可读 | 可读 | 可读 | 可读 | 不可读,除非本人也是 skill owner | +| SKILL_ADMIN / SUPER_ADMIN(仅平台角色) | 与普通登录用户一致;普通读路径不会因为平台角色自动穿透 private / hidden / unpublished | + +补充: +- `hidden=true` 时,可读权限会收敛为“skill owner 或 namespace `ADMIN` / `OWNER`” +- `visibility=PUBLIC` 也不意味着未发布 skill 可见;当 `latest_version_id` 为空时,只有 owner 能读 + +#### 1.3.2 Version 状态读取 + +| 场景 / 角色 | DRAFT | PENDING_REVIEW | PUBLISHED | REJECTED | YANKED | +|------------|-------|----------------|-----------|----------|--------| +| 普通 skill 详情页主版本投影 | 不展示 | owner / namespace 管理者可作为 `ownerPreviewVersion` 展示 | 展示 | 不展示 | 不展示 | +| 普通 `listVersions` 访客 | 不可见 | 不可见 | 可见 | 不可见 | 不可见 | +| `listVersions` 的 owner / namespace ADMIN / OWNER | 可见 | 可见 | 可见 | 可见 | 可见 | +| 常规 `getVersionDetail` | 不可读 | 仅 owner 可读 | 可读 | 不可读 | 不可读 | +| 下载 / resolve / tag / 文件读取 | 不可用 | 不可用 | 可用 | 不可用 | 不可用 | +| review 详情页 | 可见完整快照 | 可见完整快照 | 可见完整快照 | 可见完整快照 | 可见完整快照 | + +补充: +- `YANKED` 版本仍出现在管理视角的版本列表中,但不可下载 +- `yank` 当前最新已发布版本时,会重算 `latest_version_id` 指向下一个最新的 `PUBLISHED` 版本;若没有,则置空 + +#### 1.3.3 审核 / 推广 / 治理动作 + +| 角色 | 发布新版本 | 提交审核 | 审核团队空间 | 审核全局空间 | 提交推广 | 审核推广 | hide / unhide | yank 已发布版本 | +|------|------------|----------|--------------|--------------|----------|----------|---------------|----------------| +| 匿名用户 | 不可 | 不可 | 不可 | 不可 | 不可 | 不可 | 不可 | 不可 | +| namespace MEMBER | 可发布到所属 namespace;新版本进入 `PENDING_REVIEW` | 自己作为 owner 时可;不能代别人提审 | 不可 | 不可 | 自己作为 owner 时可 | 不可 | 不可 | 不可 | +| skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 | +| namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 | +| SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 | +| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;review 场景下还能审自己的提交 | 可 | 可 | + ### 对象存储写入策略 一期同步写入正式路径,不使用临时区: diff --git a/docs/06-api-design.md b/docs/06-api-design.md index ee3a49e2..673eb950 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -86,9 +86,11 @@ | GET | `/api/v1/namespaces/{slug}` | 命名空间详情 | Public API 的可见性规则: -- `PUBLIC` 技能:匿名和已登录用户均可访问 +- `PUBLIC` 技能:若存在已发布版本,则已登录用户可访问;匿名访问仍受下载/resolve 端点的 namespace 类型限制 - `NAMESPACE_ONLY` 技能:仅该命名空间成员可访问(需登录) - `PRIVATE` 技能:owner 本人 + 该 namespace 的 ADMIN 以上可访问(需登录) +- 若 `latest_version_id = null`,即使 `visibility=PUBLIC`,skill 也不会对外公开,只有 owner 可访问 +- `hidden=true` 时,普通访客不可访问;仅 owner 或该 namespace 的 `ADMIN` / `OWNER` 可访问 `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` 的 `data` 字段除版本基础信息外,还必须包含: @@ -206,9 +208,8 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 `DRAFT` 版本再次提交审核(当前主要用于撤回后重提) | | POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | 撤回提审(PENDING_REVIEW → DRAFT,同时删除关联的 PENDING review_task) | -| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/draft` | 查看草稿详情(owner 或 namespace ADMIN 以上) | +| POST | `/api/v1/reviews` | 提交指定 `skillVersionId` 进入审核队列 | ### 标签管理 @@ -225,12 +226,13 @@ Public API 的可见性规则: | POST | `/api/v1/skills/{namespace}/{slug}/archive` | 归档技能(namespace ADMIN 或 owner) | | POST | `/api/v1/skills/{namespace}/{slug}/unarchive` | 恢复归档(namespace ADMIN 或 owner) | | DELETE | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 删除 DRAFT/REJECTED 版本 | +| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/rerelease` | 从已发布版本重新发出一个新版本(namespace ADMIN 或 owner) | 当前代码中的 skill 生命周期读模型不再依赖 `latestVersionStatus` / `viewingVersionStatus` 一类拼装字段,而统一使用以下 projection: - `headlineVersion`:当前页面应展示的主版本 - `publishedVersion`:当前最新可分发的已发布版本 -- `ownerPreviewVersion`:owner / namespace 管理者可见的待审核预览版本 +- `ownerPreviewVersion`:owner / namespace 管理者可见的 `PENDING_REVIEW` 预览版本 - `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE` 其中: @@ -239,6 +241,7 @@ Public API 的可见性规则: - owner 详情页在没有可展示发布版本时,才允许 `headlineVersion` 落到 `ownerPreviewVersion` - 推广到全局一律使用 `publishedVersion.id` - `hidden` 是独立治理覆盖层,不属于生命周期状态机 +- 常规版本详情接口只放行 `PUBLISHED`,以及 owner 对自己 `PENDING_REVIEW` 版本的预览;`DRAFT / REJECTED / YANKED` 不通过该接口暴露 发布成功响应中的 `data` 至少包含以下字段: @@ -298,21 +301,17 @@ Public API 的可见性规则: Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: -### 技能治理(需 SKILL_ADMIN / SUPER_ADMIN) +### 平台治理接口 | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/v1/admin/reviews` | 全局空间待审核列表 | -| GET | `/api/v1/admin/reviews/{id}` | 全局空间审核详情 | -| POST | `/api/v1/admin/reviews/{id}/approve` | 通过全局空间审核 | -| POST | `/api/v1/admin/reviews/{id}/reject` | 拒绝全局空间审核 | -| GET | `/api/v1/admin/promotions` | 待审核提升申请列表 | -| GET | `/api/v1/admin/promotions/{id}` | 提升申请详情 | -| POST | `/api/v1/admin/promotions/{id}/approve` | 通过提升申请 | -| POST | `/api/v1/admin/promotions/{id}/reject` | 拒绝提升申请 | -| POST | `/api/v1/admin/skills/{id}/hide` | 隐藏技能 | -| POST | `/api/v1/admin/skills/{id}/unhide` | 恢复技能 | -| POST | `/api/v1/admin/skills/{id}/yank/{versionId}` | 撤回已发布版本 | +| GET | `/api/v1/promotions` | 待审核提升申请列表(需 `SKILL_ADMIN` / `SUPER_ADMIN`;路由不在 `/admin/*` 下) | +| GET | `/api/v1/promotions/{id}` | 提升申请详情(提交人本人或 `SKILL_ADMIN` / `SUPER_ADMIN` 可读) | +| POST | `/api/v1/promotions/{id}/approve` | 通过提升申请(需 `SKILL_ADMIN` / `SUPER_ADMIN`) | +| POST | `/api/v1/promotions/{id}/reject` | 拒绝提升申请(需 `SKILL_ADMIN` / `SUPER_ADMIN`) | +| POST | `/api/v1/admin/skills/{id}/hide` | 隐藏技能(仅 `SUPER_ADMIN`) | +| POST | `/api/v1/admin/skills/{id}/unhide` | 恢复技能(仅 `SUPER_ADMIN`) | +| POST | `/api/v1/admin/skills/versions/{versionId}/yank` | 撤回已发布版本(`SKILL_ADMIN` / `SUPER_ADMIN`) | ### 用户治理(需 USER_ADMIN / SUPER_ADMIN) @@ -341,10 +340,10 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | POST | `/api/v1/namespaces/{slug}/members` | 添加成员 | | PUT | `/api/v1/namespaces/{slug}/members/{userId}/role` | 修改成员角色 | | DELETE | `/api/v1/namespaces/{slug}/members/{userId}` | 移除成员 | -| GET | `/api/v1/namespaces/{slug}/reviews` | 该空间待审核列表 | -| POST | `/api/v1/namespaces/{slug}/reviews/{id}/approve` | 空间管理员审核通过 | -| POST | `/api/v1/namespaces/{slug}/reviews/{id}/reject` | 空间管理员审核拒绝 | -| POST | `/api/v1/namespaces/{slug}/skills/{skillId}/promote` | 申请提升到全局 | +| GET | `/api/v1/reviews?namespaceId={id}` | 该空间待审核列表 | +| POST | `/api/v1/reviews/{id}/approve` | 空间管理员审核通过 | +| POST | `/api/v1/reviews/{id}/reject` | 空间管理员审核拒绝 | +| POST | `/api/v1/promotions` | 申请提升到全局 | ## 7.8 `latest` 语义说明 diff --git a/docs/17-backend-annotation-findings.md b/docs/17-backend-annotation-findings.md index 30986a43..7799e835 100644 --- a/docs/17-backend-annotation-findings.md +++ b/docs/17-backend-annotation-findings.md @@ -2,13 +2,16 @@ This document records architecture and structure issues that became consistently visible while enriching backend comments. The goal is to preserve concrete observations discovered during code reading, not to propose a full redesign. -## Status Update (2026-03-19) +## Status Update (2026-03-20) -This document was re-checked after the refactor branch work for findings 1, 2, and 4. +This document was re-checked after the refactor branch work for findings 1, 2, 3, 4, 8, and 9. - Finding 1 is now handled in code. -- Finding 2 is partially handled in code. +- Finding 2 is substantially improved but still partially handled in code. +- Finding 3 is improved but still present in code. - Finding 4 is now handled in code. +- Finding 8 is improved but still present in code. +- Finding 9 is improved but still present in code. Validation completed on the standard regression path: @@ -19,9 +22,11 @@ Validation completed on the standard regression path: Double-check notes: - The admin-user refactor removed an overlapping, unused application service rather than changing the controller-facing workflow owner. -- The namespace and skill-lifecycle refactors moved orchestration out of controllers, but preserved the same downstream domain-service calls, request parameters, audit fields, response message keys, and mutation response shapes. +- The namespace, skill-lifecycle, review, promotion, and compatibility refactors moved orchestration out of controllers, but preserved the same downstream domain-service calls, request parameters, audit fields, response message keys, and mutation response shapes. - The security refactor centralized route metadata into one registry, but preserved the same route authorization rules, API-token scope behavior, and CSRF-ignore behavior. - `AuthContextFilter` is now scoped to API paths when projecting request attributes. This narrows unnecessary work on non-API requests, but it does not change existing business behavior because `userId` and `userNsRoles` consumers are API-side controllers and interceptors. +- The localized-exception refactor introduced a shared localized-message contract and moved domain HTTP status ownership into the domain exception types. This reduced handler branching without changing API error codes or HTTP status behavior. +- The compatibility refactor introduced `ClawHubCompatAppService` and `CompatSkillLookupService` so that repository and visibility-aware lookup logic are no longer duplicated across compatibility controllers and facades. ## 1. Admin user management is split across overlapping application services @@ -50,7 +55,7 @@ Current state: ## 2. Several controllers still perform orchestration that belongs in application services -Status: partially handled on branch `docs/backend-annotation-findings-discussion` +Status: substantially improved but still partially handled on branch `docs/backend-annotation-findings-discussion` Observed files: @@ -74,11 +79,15 @@ Current state: - `NamespaceController` has been slimmed down by moving orchestration into `NamespacePortalQueryAppService` and `NamespacePortalCommandAppService`. - `SkillLifecycleController` has been slimmed down by moving orchestration into `SkillLifecycleAppService`. +- `ReviewController` and `PromotionController` have now been slimmed down by moving orchestration into `ReviewPortalAppService` and `PromotionPortalAppService`. +- `ClawHubCompatController` has now been slimmed down by moving orchestration into `ClawHubCompatAppService`. - This branch preserved the original domain-service calls and response contracts for the refactored endpoints. -- `ReviewController`, `PromotionController`, and `ClawHubCompatController` still exhibit the same structural issue and remain future work. +- Some controller-side request translation still remains, and other controllers may still mix transport and workflow concerns, so the finding is not fully closed. ## 3. Compatibility endpoints are tightly coupled to canonical domain and repository internals +Status: improved but still present on branch `docs/backend-annotation-findings-discussion` + Observed files: - `server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java` @@ -94,6 +103,13 @@ Suggested direction: - Treat compatibility support as a dedicated adapter layer with narrower upstream contracts and fewer direct repository dependencies. +Current state: + +- `ClawHubCompatController` no longer coordinates repositories, publish flows, audit logging, and DTO assembly directly. That orchestration now sits in `ClawHubCompatAppService`. +- A new `CompatSkillLookupService` now centralizes legacy-slug lookup, visibility-aware skill resolution, and latest-version lookup for compatibility use cases. +- `ClawHubRegistryFacade` now reuses the same compatibility lookup helper instead of duplicating canonical repository access. +- The compatibility layer still depends on canonical query services, publish services, repository ports, and canonical lifecycle projections. The coupling is narrower and easier to follow, but there is still no fully isolated anti-corruption boundary. + ## 4. Security route policy is spread across configuration and implementation classes Status: handled on branch `docs/backend-annotation-findings-discussion` @@ -172,6 +188,8 @@ Suggested direction: ## 8. Exception modeling is duplicated across application, domain, and auth layers +Status: improved but still present on branch `docs/backend-annotation-findings-discussion` + Observed files: - `server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedException.java` @@ -189,8 +207,17 @@ Suggested direction: - Keep layer-specific exception types only where they represent a real boundary, and consider converging on a smaller shared contract for localized API-facing errors. +Current state: + +- The code now has a shared `LocalizedMessage` contract used across app, domain, and auth exceptions. +- `GlobalExceptionHandler` now renders localized app, auth, and domain exceptions through one shared rendering path instead of handling each domain subtype separately. +- Domain localized exceptions now own their HTTP status code, so the handler no longer has to use `instanceof` checks to map domain exceptions to API status codes. +- Separate exception base types still exist in the app, auth, and domain modules. The duplication is reduced, but the model has not fully converged into one cross-module abstraction. + ## 9. Repository and read-model access patterns are mixed across layers +Status: improved but still present on branch `docs/backend-annotation-findings-discussion` + Observed files: - `server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminUserSearchRepository.java` @@ -208,6 +235,13 @@ Suggested direction: - Define explicit rules for when a use case should depend on domain repository ports, dedicated query repositories, or direct persistence adapters. +Current state: + +- This branch reduced some of the most visible mixing at the entrypoint layer by removing direct repository orchestration from `ReviewController`, `PromotionController`, and `ClawHubCompatController`. +- Compatibility-specific skill lookup and version lookup now live behind `CompatSkillLookupService` instead of being duplicated across compatibility entry points. +- The broader architectural pattern is still mixed: some flows still use domain repository ports, some use dedicated query repositories, and some app-layer read paths still use direct persistence access. +- This finding should stay open until the codebase documents or enforces a clearer rule for choosing among those access patterns. + ## 10. OAuth login behavior is decomposed into many small classes without one visible flow owner Observed files: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java new file mode 100644 index 00000000..539b5221 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -0,0 +1,378 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.compat.dto.ClawHubDeleteResponse; +import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; +import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse; +import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; +import com.iflytek.skillhub.compat.dto.ClawHubStarResponse; +import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse; +import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; +import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; +import com.iflytek.skillhub.controller.support.ZipPackageExtractor; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.MDC; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +/** + * Compatibility-focused application service that keeps ClawHub transport logic + * out of the controller while preserving the existing wire contract. + */ +@Service +public class ClawHubCompatAppService { + + private final CanonicalSlugMapper mapper; + private final SkillSearchAppService skillSearchAppService; + private final SkillQueryService skillQueryService; + private final SkillPublishService skillPublishService; + private final ZipPackageExtractor zipPackageExtractor; + private final MultipartPackageExtractor multipartPackageExtractor; + private final AuditLogService auditLogService; + private final CompatSkillLookupService compatSkillLookupService; + private final SkillStarService skillStarService; + + public ClawHubCompatAppService(CanonicalSlugMapper mapper, + SkillSearchAppService skillSearchAppService, + SkillQueryService skillQueryService, + SkillPublishService skillPublishService, + ZipPackageExtractor zipPackageExtractor, + MultipartPackageExtractor multipartPackageExtractor, + AuditLogService auditLogService, + CompatSkillLookupService compatSkillLookupService, + SkillStarService skillStarService) { + this.mapper = mapper; + this.skillSearchAppService = skillSearchAppService; + this.skillQueryService = skillQueryService; + this.skillPublishService = skillPublishService; + this.zipPackageExtractor = zipPackageExtractor; + this.multipartPackageExtractor = multipartPackageExtractor; + this.auditLogService = auditLogService; + this.compatSkillLookupService = compatSkillLookupService; + this.skillStarService = skillStarService; + } + + public ClawHubSearchResponse search(String q, + int page, + int limit, + String userId, + Map userNsRoles) { + SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + q, + null, + q == null || q.isBlank() ? "newest" : "relevance", + page, + limit, + userId, + userNsRoles + ); + + List results = response.items().stream() + .map(this::toSearchResult) + .toList(); + + return new ClawHubSearchResponse(results); + } + + public ClawHubResolveResponse resolveByQuery(String slug, + String version, + String hash, + String userId, + Map userNsRoles) { + CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug); + + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + context.namespace().getSlug(), + context.skill().getSlug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + hash, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + return toResolveResponse(resolved); + } + + public ClawHubResolveResponse resolve(String canonicalSlug, + String version, + String userId, + Map userNsRoles) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + coord.namespace(), + coord.slug(), + "latest".equals(version) ? null : version, + "latest".equals(version) ? "latest" : null, + null, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + return toResolveResponse(resolved); + } + + public String downloadLocationByPath(String canonicalSlug, String version) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + return "latest".equals(version) + ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" + : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + } + + public String downloadLocationByQuery(String slug, String version) { + CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug); + return "latest".equals(version) + ? "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/download" + : "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/versions/" + version + "/download"; + } + + public ClawHubSkillListResponse listSkills(int page, + int limit, + String sort, + String userId, + Map userNsRoles) { + String sortBy = sort != null ? sort : "newest"; + SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + "", + null, + sortBy, + page, + limit, + userId, + userNsRoles + ); + + List items = response.items().stream() + .map(this::toSkillListItem) + .toList(); + + String nextCursor = null; + long totalResults = response.total(); + long currentOffset = (long) page * limit; + if (currentOffset + items.size() < totalResults) { + nextCursor = String.valueOf(page + 1); + } + + return new ClawHubSkillListResponse(items, nextCursor); + } + + public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( + coord.namespace(), + coord.slug(), + userId + ); + SkillVersion latestVersionEntity = context.latestVersion().orElse(null); + + ClawHubSkillResponse.SkillInfo skillInfo = null; + ClawHubSkillResponse.VersionInfo versionInfo = null; + + if (context.skill().getId() != null) { + long createdAt = context.skill().getCreatedAt() != null ? context.skill().getCreatedAt().toEpochMilli() : 0; + long updatedAt = context.skill().getUpdatedAt() != null ? context.skill().getUpdatedAt().toEpochMilli() : 0; + skillInfo = new ClawHubSkillResponse.SkillInfo( + mapper.toCanonical(coord.namespace(), coord.slug()), + context.skill().getDisplayName(), + context.skill().getSummary(), + Map.of(), + Map.of(), + createdAt, + updatedAt + ); + + if (latestVersionEntity != null) { + long versionCreatedAt = latestVersionEntity.getPublishedAt() != null + ? latestVersionEntity.getPublishedAt().toEpochMilli() + : 0; + versionInfo = new ClawHubSkillResponse.VersionInfo( + latestVersionEntity.getVersion(), + versionCreatedAt, + latestVersionEntity.getChangelog() == null ? "" : latestVersionEntity.getChangelog(), + null + ); + } + } + + return new ClawHubSkillResponse( + skillInfo, + versionInfo, + null, + new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null) + ); + } + + public ClawHubDeleteResponse deleteSkill() { + return new ClawHubDeleteResponse(); + } + + public ClawHubDeleteResponse undeleteSkill() { + return new ClawHubDeleteResponse(); + } + + public ClawHubStarResponse starSkill(String canonicalSlug, PlatformPrincipal principal) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( + coord.namespace(), + coord.slug(), + principal.userId() + ); + + boolean alreadyStarred = skillStarService.isStarred(context.skill().getId(), principal.userId()); + skillStarService.star(context.skill().getId(), principal.userId()); + return new ClawHubStarResponse(true, alreadyStarred); + } + + public ClawHubUnstarResponse unstarSkill(String canonicalSlug, PlatformPrincipal principal) { + SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); + CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( + coord.namespace(), + coord.slug(), + principal.userId() + ); + + boolean alreadyUnstarred = !skillStarService.isStarred(context.skill().getId(), principal.userId()); + skillStarService.unstar(context.skill().getId(), principal.userId()); + return new ClawHubUnstarResponse(true, alreadyUnstarred); + } + + public ClawHubPublishResponse publishSkill(String payloadJson, + MultipartFile[] files, + PlatformPrincipal principal, + String clientIp, + String userAgent) throws IOException { + MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson); + String namespace = determineNamespace(principal, extracted.payload()); + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + extracted.entries(), + principal.userId(), + SkillVisibility.PUBLIC, + principal.platformRoles() + ); + recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, + "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}"); + return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString()); + } + + public ClawHubPublishResponse publish(MultipartFile file, + String namespace, + PlatformPrincipal principal, + String clientIp, + String userAgent) throws IOException { + SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + namespace, + zipPackageExtractor.extract(file), + principal.userId(), + SkillVisibility.PUBLIC, + principal.platformRoles() + ); + recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent, + "{\"namespace\":\"" + namespace + "\"}"); + return new ClawHubPublishResponse(result.skillId().toString(), result.version().getId().toString()); + } + + public ClawHubWhoamiResponse whoami(PlatformPrincipal principal) { + return new ClawHubWhoamiResponse( + principal.userId(), + principal.displayName(), + principal.avatarUrl() + ); + } + + private ClawHubSearchResponse.ClawHubSearchResult toSearchResult(SkillSummaryResponse item) { + Long updatedAtEpoch = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : null; + return new ClawHubSearchResponse.ClawHubSearchResult( + mapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + item.publishedVersion() != null ? item.publishedVersion().version() : null, + calculateScore(item), + updatedAtEpoch + ); + } + + private double calculateScore(SkillSummaryResponse item) { + int starScore = item.starCount() != null ? item.starCount() * 10 : 0; + long downloadScore = item.downloadCount() != null ? item.downloadCount() : 0; + return (starScore + downloadScore) / 100.0; + } + + private ClawHubResolveResponse toResolveResponse(SkillQueryService.ResolvedVersionDTO resolved) { + ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null + ? new ClawHubResolveResponse.VersionInfo(resolved.version()) + : null; + return new ClawHubResolveResponse(matchVersion, latestVersion); + } + + private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) { + long createdAt = 0; + long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0; + + ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null; + if (item.publishedVersion() != null) { + latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion( + item.publishedVersion().version(), + updatedAt, + "", + null + ); + } + + Map stats = new HashMap<>(); + if (item.downloadCount() != null) { + stats.put("downloads", item.downloadCount()); + } + if (item.starCount() != null) { + stats.put("stars", item.starCount()); + } + + return new ClawHubSkillListResponse.SkillListItem( + mapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + Map.of(), + stats, + createdAt, + updatedAt, + latestVersion + ); + } + + private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) { + return "global"; + } + + private void recordCompatPublishAudit(String userId, + Long versionId, + String clientIp, + String userAgent, + String detailJson) { + auditLogService.record( + userId, + "COMPAT_PUBLISH", + "SKILL_VERSION", + versionId, + MDC.get("requestId"), + clientIp, + userAgent, + detailJson + ); + } + +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index e689c0c8..a66a7f0d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -10,407 +10,125 @@ import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse; import com.iflytek.skillhub.compat.dto.ClawHubStarResponse; import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse; import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse; -import com.iflytek.skillhub.controller.support.MultipartPackageExtractor; -import com.iflytek.skillhub.controller.support.ZipPackageExtractor; -import com.iflytek.skillhub.domain.audit.AuditLogService; -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.DomainNotFoundException; -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.SkillVisibility; -import com.iflytek.skillhub.domain.skill.service.SkillPublishService; -import com.iflytek.skillhub.domain.skill.service.SkillQueryService; -import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; -import com.iflytek.skillhub.domain.social.SkillStarService; -import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.ratelimit.RateLimit; -import com.iflytek.skillhub.service.SkillSearchAppService; import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.MDC; +import java.io.IOException; +import java.util.Map; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Map; - /** - * Compatibility controller that exposes SkillHub content using ClawHub-style routes and payload - * shapes expected by legacy clients. + * Compatibility controller that exposes SkillHub content using ClawHub-style + * routes and payload shapes expected by legacy clients. */ @RestController @RequestMapping("/api/v1") public class ClawHubCompatController { - private final CanonicalSlugMapper mapper; - private final SkillSearchAppService skillSearchAppService; - private final SkillQueryService skillQueryService; - private final SkillPublishService skillPublishService; - private final ZipPackageExtractor zipPackageExtractor; - private final MultipartPackageExtractor multipartPackageExtractor; - private final AuditLogService auditLogService; - private final SkillRepository skillRepository; - private final NamespaceRepository namespaceRepository; - private final SkillVersionRepository skillVersionRepository; - private final SkillStarService skillStarService; - private final SkillSlugResolutionService skillSlugResolutionService; + private final ClawHubCompatAppService clawHubCompatAppService; - public ClawHubCompatController(CanonicalSlugMapper mapper, - SkillSearchAppService skillSearchAppService, - SkillQueryService skillQueryService, - SkillPublishService skillPublishService, - ZipPackageExtractor zipPackageExtractor, - MultipartPackageExtractor multipartPackageExtractor, - AuditLogService auditLogService, - SkillRepository skillRepository, - NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository, - SkillStarService skillStarService, - SkillSlugResolutionService skillSlugResolutionService) { - this.mapper = mapper; - this.skillSearchAppService = skillSearchAppService; - this.skillQueryService = skillQueryService; - this.skillPublishService = skillPublishService; - this.zipPackageExtractor = zipPackageExtractor; - this.multipartPackageExtractor = multipartPackageExtractor; - this.auditLogService = auditLogService; - this.skillRepository = skillRepository; - this.namespaceRepository = namespaceRepository; - this.skillVersionRepository = skillVersionRepository; - this.skillStarService = skillStarService; - this.skillSlugResolutionService = skillSlugResolutionService; + public ClawHubCompatController(ClawHubCompatAppService clawHubCompatAppService) { + this.clawHubCompatAppService = clawHubCompatAppService; } @RateLimit(category = "search", authenticated = 60, anonymous = 20) @GetMapping("/search") - public ClawHubSearchResponse search( - @RequestParam String q, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int limit, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - SkillSearchAppService.SearchResponse response = skillSearchAppService.search( - q, - null, - q == null || q.isBlank() ? "newest" : "relevance", - page, - limit, - userId, - userNsRoles - ); - - List results = response.items().stream() - .map(this::toSearchResult) - .toList(); - - return new ClawHubSearchResponse(results); - } - - private ClawHubSearchResponse.ClawHubSearchResult toSearchResult(SkillSummaryResponse item) { - Long updatedAtEpoch = item.updatedAt() != null - ? item.updatedAt().toEpochMilli() - : null; - return new ClawHubSearchResponse.ClawHubSearchResult( - mapper.toCanonical(item.namespace(), item.slug()), - item.displayName(), - item.summary(), - item.publishedVersion() != null ? item.publishedVersion().version() : null, - calculateScore(item), - updatedAtEpoch - ); - } - - private double calculateScore(SkillSummaryResponse item) { - // Simple score calculation based on stars and downloads - int starScore = item.starCount() != null ? item.starCount() * 10 : 0; - long downloadScore = item.downloadCount() != null ? item.downloadCount() : 0; - return (starScore + downloadScore) / 100.0; + public ClawHubSearchResponse search(@RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int limit, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return clawHubCompatAppService.search(q, page, limit, userId, userNsRoles); } @RateLimit(category = "resolve", authenticated = 60, anonymous = 20) @GetMapping("/resolve") - public ClawHubResolveResponse resolveByQuery( - @RequestParam String slug, - @RequestParam(required = false) String version, - @RequestParam(required = false) String hash, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - // For resolve endpoint with query params, slug is just the skill slug without namespace - // We need to find the skill by slug (this is a simplification - in real world you'd need more context) - Skill skill = skillRepository.findBySlug(slug).stream().findFirst() - .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); - - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); - - SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( - ns.getSlug(), - skill.getSlug(), - "latest".equals(version) ? null : version, - "latest".equals(version) ? "latest" : null, - hash, - userId, - userNsRoles != null ? userNsRoles : Map.of() - ); - - ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null - ? new ClawHubResolveResponse.VersionInfo(resolved.version()) - : null; - ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null - ? new ClawHubResolveResponse.VersionInfo(resolved.version()) - : null; - - return new ClawHubResolveResponse(matchVersion, latestVersion); + public ClawHubResolveResponse resolveByQuery(@RequestParam String slug, + @RequestParam(required = false) String version, + @RequestParam(required = false) String hash, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return clawHubCompatAppService.resolveByQuery(slug, version, hash, userId, userNsRoles); } @RateLimit(category = "resolve", authenticated = 60, anonymous = 20) @GetMapping("/resolve/{canonicalSlug}") - public ClawHubResolveResponse resolve( - @PathVariable String canonicalSlug, - @RequestParam(defaultValue = "latest") String version, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( - coord.namespace(), - coord.slug(), - "latest".equals(version) ? null : version, - "latest".equals(version) ? "latest" : null, - null, - userId, - userNsRoles != null ? userNsRoles : Map.of() - ); - - ClawHubResolveResponse.VersionInfo matchVersion = resolved.version() != null - ? new ClawHubResolveResponse.VersionInfo(resolved.version()) - : null; - ClawHubResolveResponse.VersionInfo latestVersion = resolved.version() != null - ? new ClawHubResolveResponse.VersionInfo(resolved.version()) - : null; - - return new ClawHubResolveResponse(matchVersion, latestVersion); + public ClawHubResolveResponse resolve(@PathVariable String canonicalSlug, + @RequestParam(defaultValue = "latest") String version, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return clawHubCompatAppService.resolve(canonicalSlug, version, userId, userNsRoles); } @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download/{canonicalSlug}") public ResponseEntity downloadByPath(@PathVariable String canonicalSlug, @RequestParam(defaultValue = "latest") String version) { - SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - String location = "latest".equals(version) - ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" - : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; - return ResponseEntity.status(HttpStatus.FOUND) - .header(HttpHeaders.LOCATION, location) - .build(); + return redirect(clawHubCompatAppService.downloadLocationByPath(canonicalSlug, version)); } @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download") public ResponseEntity downloadByQuery(@RequestParam String slug, @RequestParam(defaultValue = "latest") String version) { - // For query param version, slug is just the skill slug without namespace - Skill skill = skillRepository.findBySlug(slug).stream().findFirst() - .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); - Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); - String location = "latest".equals(version) - ? "/api/v1/skills/" + ns.getSlug() + "/" + skill.getSlug() + "/download" - : "/api/v1/skills/" + ns.getSlug() + "/" + skill.getSlug() + "/versions/" + version + "/download"; - return ResponseEntity.status(HttpStatus.FOUND) - .header(HttpHeaders.LOCATION, location) - .build(); + return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version)); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @GetMapping("/skills") - public ClawHubSkillListResponse listSkills( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "25") int limit, - @RequestParam(required = false) String sort, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - // Use search with empty query to list skills - String sortBy = sort != null ? sort : "newest"; - SkillSearchAppService.SearchResponse response = skillSearchAppService.search( - "", - null, - sortBy, - page, - limit, - userId, - userNsRoles - ); - - List items = response.items().stream() - .map(this::toSkillListItem) - .toList(); - - // Calculate nextCursor: if there are more results, return next page number as cursor - String nextCursor = null; - long totalResults = response.total(); - long currentOffset = (long) page * limit; - if (currentOffset + items.size() < totalResults) { - nextCursor = String.valueOf(page + 1); - } - - return new ClawHubSkillListResponse(items, nextCursor); - } - - private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) { - long createdAt = 0; - long updatedAt = item.updatedAt() != null - ? item.updatedAt().toEpochMilli() - : 0; - - ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null; - if (item.publishedVersion() != null) { - latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion( - item.publishedVersion().version(), - updatedAt, // Use skill's updatedAt as version createdAt - "", // changelog not available in summary - null // license not available in summary - ); - } - - // Build stats map with non-null values - Map stats = new java.util.HashMap<>(); - if (item.downloadCount() != null) { - stats.put("downloads", item.downloadCount()); - } - if (item.starCount() != null) { - stats.put("stars", item.starCount()); - } - - return new ClawHubSkillListResponse.SkillListItem( - mapper.toCanonical(item.namespace(), item.slug()), - item.displayName(), - item.summary(), - Map.of(), // tags - stats, - createdAt, - updatedAt, - latestVersion - ); + public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int limit, + @RequestParam(required = false) String sort, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @GetMapping("/skills/{canonicalSlug}") - public ClawHubSkillResponse getSkill( - @PathVariable String canonicalSlug, - @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - - Namespace ns = namespaceRepository.findBySlug(coord.namespace()) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); - Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), userId); - - SkillVersion latestVersionEntity = null; - if (skill.getLatestVersionId() != null) { - latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); - } - - ClawHubSkillResponse.SkillInfo skillInfo = null; - ClawHubSkillResponse.VersionInfo versionInfo = null; - - if (skill.getId() != null) { - long createdAt = skill.getCreatedAt() != null - ? skill.getCreatedAt().toEpochMilli() - : 0; - long updatedAt = skill.getUpdatedAt() != null - ? skill.getUpdatedAt().toEpochMilli() - : 0; - skillInfo = new ClawHubSkillResponse.SkillInfo( - mapper.toCanonical(coord.namespace(), coord.slug()), - skill.getDisplayName(), - skill.getSummary(), - Map.of(), // tags - Map.of(), // stats - createdAt, - updatedAt - ); - - if (latestVersionEntity != null) { - long versionCreatedAt = latestVersionEntity.getPublishedAt() != null - ? latestVersionEntity.getPublishedAt().toEpochMilli() - : 0; - versionInfo = new ClawHubSkillResponse.VersionInfo( - latestVersionEntity.getVersion(), - versionCreatedAt, - latestVersionEntity.getChangelog() == null ? "" : latestVersionEntity.getChangelog(), - null // license - ); - } - } - - // Owner info - we don't have this readily available, return null - ClawHubSkillResponse.OwnerInfo ownerInfo = null; - - // Moderation info - not implemented yet - ClawHubSkillResponse.ModerationInfo moderationInfo = new ClawHubSkillResponse.ModerationInfo( - false, false, "clean", new String[0], null, null, null - ); - - return new ClawHubSkillResponse(skillInfo, versionInfo, ownerInfo, moderationInfo); + public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug, + @RequestAttribute(value = "userId", required = false) String userId) { + return clawHubCompatAppService.getSkill(canonicalSlug, userId); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @DeleteMapping("/skills/{canonicalSlug}") - public ClawHubDeleteResponse deleteSkill( - @PathVariable String canonicalSlug, - @AuthenticationPrincipal PlatformPrincipal principal) { - // Note: Full delete not implemented yet, just return ok for compatibility - return new ClawHubDeleteResponse(); + public ClawHubDeleteResponse deleteSkill(@PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return clawHubCompatAppService.deleteSkill(); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @PostMapping("/skills/{canonicalSlug}/undelete") - public ClawHubDeleteResponse undeleteSkill( - @PathVariable String canonicalSlug, - @AuthenticationPrincipal PlatformPrincipal principal) { - // Note: Undelete not implemented yet, just return ok for compatibility - return new ClawHubDeleteResponse(); + public ClawHubDeleteResponse undeleteSkill(@PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return clawHubCompatAppService.undeleteSkill(); } @RateLimit(category = "stars", authenticated = 60, anonymous = 20) @PostMapping("/stars/{canonicalSlug}") - public ClawHubStarResponse starSkill( - @PathVariable String canonicalSlug, - @AuthenticationPrincipal PlatformPrincipal principal) { - SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - Namespace ns = namespaceRepository.findBySlug(coord.namespace()) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); - Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId()); - - boolean alreadyStarred = skillStarService.isStarred(skill.getId(), principal.userId()); - skillStarService.star(skill.getId(), principal.userId()); - - return new ClawHubStarResponse(true, alreadyStarred); + public ClawHubStarResponse starSkill(@PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return clawHubCompatAppService.starSkill(canonicalSlug, principal); } @RateLimit(category = "stars", authenticated = 60, anonymous = 20) @DeleteMapping("/stars/{canonicalSlug}") - public ClawHubUnstarResponse unstarSkill( - @PathVariable String canonicalSlug, - @AuthenticationPrincipal PlatformPrincipal principal) { - SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - Namespace ns = namespaceRepository.findBySlug(coord.namespace()) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", coord.namespace())); - Skill skill = resolveVisibleSkill(ns.getId(), coord.slug(), principal.userId()); - - boolean alreadyUnstarred = !skillStarService.isStarred(skill.getId(), principal.userId()); - skillStarService.unstar(skill.getId(), principal.userId()); - - return new ClawHubUnstarResponse(true, alreadyUnstarred); + public ClawHubUnstarResponse unstarSkill(@PathVariable String canonicalSlug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return clawHubCompatAppService.unstarSkill(canonicalSlug, principal); } @RateLimit(category = "skills", authenticated = 60, anonymous = 20) @@ -419,28 +137,12 @@ public class ClawHubCompatController { @RequestParam("files") MultipartFile[] files, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { - MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson); - String namespace = determineNamespace(principal, extracted.payload()); - SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( - namespace, - extracted.entries(), - principal.userId(), - SkillVisibility.PUBLIC, - principal.platformRoles() - ); - auditLogService.record( - principal.userId(), - "COMPAT_PUBLISH", - "SKILL_VERSION", - result.version().getId(), - MDC.get("requestId"), + return clawHubCompatAppService.publishSkill( + payloadJson, + files, + principal, request.getRemoteAddr(), - request.getHeader("User-Agent"), - "{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}" - ); - return new ClawHubPublishResponse( - result.skillId().toString(), - result.version().getId().toString() + request.getHeader("User-Agent") ); } @@ -450,53 +152,24 @@ public class ClawHubCompatController { @RequestParam("namespace") String namespace, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { - SkillPublishService.PublishResult result = skillPublishService.publishFromEntries( + return clawHubCompatAppService.publish( + file, namespace, - zipPackageExtractor.extract(file), - principal.userId(), - SkillVisibility.PUBLIC, - principal.platformRoles() - ); - auditLogService.record( - principal.userId(), - "COMPAT_PUBLISH", - "SKILL_VERSION", - result.version().getId(), - MDC.get("requestId"), + principal, request.getRemoteAddr(), - request.getHeader("User-Agent"), - "{\"namespace\":\"" + namespace + "\"}" + request.getHeader("User-Agent") ); - return new ClawHubPublishResponse( - result.skillId().toString(), - result.version().getId().toString() - ); - } - - private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) { - // Use "global" namespace by default for compatibility - return "global"; } @RateLimit(category = "whoami", authenticated = 60, anonymous = 20) @GetMapping("/whoami") public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { - return new ClawHubWhoamiResponse( - principal.userId(), - principal.displayName(), - principal.avatarUrl() - ); + return clawHubCompatAppService.whoami(principal); } - private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { - try { - return skillSlugResolutionService.resolve( - namespaceId, - slug, - currentUserId, - SkillSlugResolutionService.Preference.PUBLISHED); - } catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException ex) { - throw new DomainNotFoundException("error.skill.notFound", slug); - } + private ResponseEntity redirect(String location) { + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index b7fac835..68dd32fc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -9,9 +9,7 @@ import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse; import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillVersion; 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.service.SkillQueryService; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; @@ -35,22 +33,19 @@ public class ClawHubRegistryFacade { private final CanonicalSlugMapper canonicalSlugMapper; private final SkillSearchAppService skillSearchAppService; private final SkillQueryService skillQueryService; - private final SkillRepository skillRepository; - private final SkillVersionRepository skillVersionRepository; + private final CompatSkillLookupService compatSkillLookupService; private final UserAccountRepository userAccountRepository; public ClawHubRegistryFacade( CanonicalSlugMapper canonicalSlugMapper, SkillSearchAppService skillSearchAppService, SkillQueryService skillQueryService, - SkillRepository skillRepository, - SkillVersionRepository skillVersionRepository, + CompatSkillLookupService compatSkillLookupService, UserAccountRepository userAccountRepository) { this.canonicalSlugMapper = canonicalSlugMapper; this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; - this.skillRepository = skillRepository; - this.skillVersionRepository = skillVersionRepository; + this.compatSkillLookupService = compatSkillLookupService; this.userAccountRepository = userAccountRepository; } @@ -85,8 +80,12 @@ public class ClawHubRegistryFacade { userId, normalizeRoles(userNsRoles)); - Skill skill = skillRepository.findById(detail.id()) - .orElseThrow(() -> new IllegalStateException("Skill unexpectedly missing: " + canonicalSlug)); + CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( + coordinate.namespace(), + coordinate.slug(), + userId + ); + Skill skill = context.skill(); ClawHubRegistrySkill payload = new ClawHubRegistrySkill( canonicalSlugMapper.toCanonical(coordinate.namespace(), detail.slug()), @@ -152,7 +151,7 @@ public class ClawHubRegistryFacade { return null; } - Optional latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), projection.version()); + Optional latestVersion = compatSkillLookupService.findVersion(skill.getId(), projection.version()); if (latestVersion.isEmpty()) { return new ClawHubRegistrySkillVersion(projection.version(), 0L, "", null); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java new file mode 100644 index 00000000..a151ed8f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +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.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import java.util.Optional; +import org.springframework.stereotype.Service; + +/** + * Compatibility-side lookup helper that centralizes legacy slug resolution and + * visibility-aware canonical skill loading. + */ +@Service +public class CompatSkillLookupService { + + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final SkillVersionRepository skillVersionRepository; + private final SkillSlugResolutionService skillSlugResolutionService; + + public CompatSkillLookupService(SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + SkillVersionRepository skillVersionRepository, + SkillSlugResolutionService skillSlugResolutionService) { + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.skillVersionRepository = skillVersionRepository; + this.skillSlugResolutionService = skillSlugResolutionService; + } + + public CompatSkillContext findByLegacySlug(String slug) { + Skill skill = skillRepository.findBySlug(slug).stream().findFirst() + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", slug)); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", skill.getNamespaceId())); + return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); + } + + public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) { + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug)); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + return new CompatSkillContext(namespace, skill, findLatestVersion(skill)); + } + + public Optional findVersion(Long skillId, String version) { + if (skillId == null || version == null || version.isBlank()) { + return Optional.empty(); + } + return skillVersionRepository.findBySkillIdAndVersion(skillId, version); + } + + public Optional findLatestVersion(Skill skill) { + if (skill == null || skill.getLatestVersionId() == null) { + return Optional.empty(); + } + return skillVersionRepository.findById(skill.getLatestVersionId()); + } + + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { + try { + return skillSlugResolutionService.resolve( + namespaceId, + slug, + currentUserId, + SkillSlugResolutionService.Preference.PUBLISHED + ); + } catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException ex) { + throw new DomainNotFoundException("error.skill.notFound", slug); + } + } + + public record CompatSkillContext(Namespace namespace, Skill skill, Optional latestVersion) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index e86f7dbf..5c4b5d4e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -1,33 +1,17 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.domain.audit.AuditLogService; -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.review.PromotionRequest; -import com.iflytek.skillhub.domain.review.PromotionRequestRepository; -import com.iflytek.skillhub.domain.review.PromotionService; -import com.iflytek.skillhub.domain.review.ReviewTaskStatus; -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.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionActionRequest; import com.iflytek.skillhub.dto.PromotionRequestDto; import com.iflytek.skillhub.dto.PromotionResponseDto; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.PromotionPortalAppService; import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.MDC; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -37,9 +21,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; -import java.util.Set; - /** * Promotion workflow endpoints that expose submission, review, and query * operations for cross-namespace promotion requests. @@ -48,33 +29,12 @@ import java.util.Set; @RequestMapping({"/api/v1/promotions", "/api/web/promotions"}) public class PromotionController extends BaseApiController { - private final PromotionService promotionService; - private final PromotionRequestRepository promotionRequestRepository; - private final SkillRepository skillRepository; - private final SkillVersionRepository skillVersionRepository; - private final NamespaceRepository namespaceRepository; - private final UserAccountRepository userAccountRepository; - private final RbacService rbacService; - private final AuditLogService auditLogService; + private final PromotionPortalAppService promotionPortalAppService; - public PromotionController(PromotionService promotionService, - PromotionRequestRepository promotionRequestRepository, - SkillRepository skillRepository, - SkillVersionRepository skillVersionRepository, - NamespaceRepository namespaceRepository, - UserAccountRepository userAccountRepository, - RbacService rbacService, - AuditLogService auditLogService, + public PromotionController(PromotionPortalAppService promotionPortalAppService, ApiResponseFactory responseFactory) { super(responseFactory); - this.promotionService = promotionService; - this.promotionRequestRepository = promotionRequestRepository; - this.skillRepository = skillRepository; - this.skillVersionRepository = skillVersionRepository; - this.namespaceRepository = namespaceRepository; - this.userAccountRepository = userAccountRepository; - this.rbacService = rbacService; - this.auditLogService = auditLogService; + this.promotionPortalAppService = promotionPortalAppService; } @PostMapping @@ -82,22 +42,16 @@ public class PromotionController extends BaseApiController { @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { - PromotionRequest promotion = promotionService.submitPromotion( - request.sourceSkillId(), - request.sourceVersionId(), - request.targetNamespaceId(), - userId, - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId) + return ok( + "response.success.created", + promotionPortalAppService.submitPromotion( + request.sourceSkillId(), + request.sourceVersionId(), + request.targetNamespaceId(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest)) ); - recordAudit( - "PROMOTION_SUBMIT", - userId, - promotion.getId(), - httpRequest, - "{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}" - ); - return ok("response.success.created", toResponse(promotion)); } @PostMapping("/{id}/approve") @@ -106,9 +60,10 @@ public class PromotionController extends BaseApiController { @RequestAttribute("userId") String userId, HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, rbacService.getUserRoleCodes(userId)); - recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); - return ok("response.success.updated", toResponse(promotion)); + return ok( + "response.success.updated", + promotionPortalAppService.approvePromotion(id, comment, userId, AuditRequestContext.from(httpRequest)) + ); } @PostMapping("/{id}/reject") @@ -117,9 +72,10 @@ public class PromotionController extends BaseApiController { @RequestAttribute("userId") String userId, HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, rbacService.getUserRoleCodes(userId)); - recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment)); - return ok("response.success.updated", toResponse(promotion)); + return ok( + "response.success.updated", + promotionPortalAppService.rejectPromotion(id, comment, userId, AuditRequestContext.from(httpRequest)) + ); } @GetMapping @@ -127,96 +83,19 @@ public class PromotionController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { - throw new DomainForbiddenException("promotion.no_permission"); - } - ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); - return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); + return ok("response.success.read", promotionPortalAppService.listPromotions(status, page, size, userId)); } @GetMapping("/pending") public ApiResponse> listPendingPromotions(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { - Set platformRoles = rbacService.getUserRoleCodes(userId); - if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { - throw new DomainForbiddenException("promotion.no_permission"); - } - Page requests = promotionRequestRepository.findByStatus( - ReviewTaskStatus.PENDING, PageRequest.of(page, size)); - return ok("response.success.read", PageResponse.from(requests.map(this::toResponse))); + return ok("response.success.read", promotionPortalAppService.listPendingPromotions(page, size, userId)); } @GetMapping("/{id}") public ApiResponse getPromotionDetail(@PathVariable Long id, @RequestAttribute("userId") String userId) { - PromotionRequest promotion = promotionRequestRepository.findById(id) - .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", id)); - if (!promotionService.canViewPromotion(promotion, userId, rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("promotion.no_permission"); - } - return ok("response.success.read", toResponse(promotion)); - } - - private PromotionResponseDto toResponse(PromotionRequest request) { - Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); - SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId())); - Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); - Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId())); - - String submittedByName = userAccountRepository.findById(request.getSubmittedBy()) - .map(UserAccount::getDisplayName) - .orElse(null); - String reviewedByName = request.getReviewedBy() != null - ? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) - : null; - - return new PromotionResponseDto( - request.getId(), - request.getSourceSkillId(), - sourceNamespace.getSlug(), - sourceSkill.getSlug(), - sourceVersion.getVersion(), - targetNamespace.getSlug(), - request.getTargetSkillId(), - request.getStatus().name(), - request.getSubmittedBy(), - submittedByName, - request.getReviewedBy(), - reviewedByName, - request.getReviewComment(), - request.getSubmittedAt(), - request.getReviewedAt() - ); - } - - private void recordAudit(String action, - String userId, - Long targetId, - HttpServletRequest httpRequest, - String detailJson) { - auditLogService.record( - userId, - action, - "PROMOTION_REQUEST", - targetId, - MDC.get("requestId"), - httpRequest.getRemoteAddr(), - httpRequest.getHeader("User-Agent"), - detailJson - ); - } - - private String detailWithComment(String comment) { - if (comment == null || comment.isBlank()) { - return null; - } - return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + return ok("response.success.read", promotionPortalAppService.getPromotionDetail(id, userId)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index bd4f6c51..081cd4c1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -1,24 +1,8 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.controller.BaseApiController; -import com.iflytek.skillhub.domain.audit.AuditLogService; -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.review.ReviewService; -import com.iflytek.skillhub.domain.review.ReviewTask; -import com.iflytek.skillhub.domain.review.ReviewTaskRepository; -import com.iflytek.skillhub.domain.review.ReviewTaskStatus; -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.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersion; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; @@ -26,13 +10,12 @@ import com.iflytek.skillhub.dto.ReviewActionRequest; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; import com.iflytek.skillhub.dto.ReviewTaskRequest; import com.iflytek.skillhub.dto.ReviewTaskResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.ReviewPortalAppService; import com.iflytek.skillhub.service.ReviewSkillDetailAppService; import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.MDC; +import java.util.Map; import org.springframework.core.io.InputStreamResource; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -46,9 +29,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; -import java.util.Set; - /** * Endpoints for submitting, browsing, approving, rejecting, and withdrawing * review tasks. @@ -57,35 +37,14 @@ import java.util.Set; @RequestMapping({"/api/v1/reviews", "/api/web/reviews"}) public class ReviewController extends BaseApiController { - private final ReviewService reviewService; - private final ReviewTaskRepository reviewTaskRepository; - private final SkillRepository skillRepository; - private final SkillVersionRepository skillVersionRepository; - private final NamespaceRepository namespaceRepository; - private final UserAccountRepository userAccountRepository; - private final RbacService rbacService; - private final AuditLogService auditLogService; + private final ReviewPortalAppService reviewPortalAppService; private final ReviewSkillDetailAppService reviewSkillDetailAppService; - public ReviewController(ReviewService reviewService, - ReviewTaskRepository reviewTaskRepository, - SkillRepository skillRepository, - SkillVersionRepository skillVersionRepository, - NamespaceRepository namespaceRepository, - UserAccountRepository userAccountRepository, - RbacService rbacService, - AuditLogService auditLogService, + public ReviewController(ReviewPortalAppService reviewPortalAppService, ReviewSkillDetailAppService reviewSkillDetailAppService, ApiResponseFactory responseFactory) { super(responseFactory); - this.reviewService = reviewService; - this.reviewTaskRepository = reviewTaskRepository; - this.skillRepository = skillRepository; - this.skillVersionRepository = skillVersionRepository; - this.namespaceRepository = namespaceRepository; - this.userAccountRepository = userAccountRepository; - this.rbacService = rbacService; - this.auditLogService = auditLogService; + this.reviewPortalAppService = reviewPortalAppService; this.reviewSkillDetailAppService = reviewSkillDetailAppService; } @@ -94,14 +53,14 @@ public class ReviewController extends BaseApiController { @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { - ReviewTask task = reviewService.submitReview( - request.skillVersionId(), - userId, - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId) + return ok( + "response.success.created", + reviewPortalAppService.submitReview( + request.skillVersionId(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest)) ); - recordAudit("REVIEW_SUBMIT", userId, task.getId(), httpRequest, "{\"skillVersionId\":" + request.skillVersionId() + "}"); - return ok("response.success.created", toResponse(task)); } @PostMapping("/{id}/approve") @@ -111,15 +70,15 @@ public class ReviewController extends BaseApiController { @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - ReviewTask task = reviewService.approveReview( - id, - userId, - comment, - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId) + return ok( + "response.success.updated", + reviewPortalAppService.approveReview( + id, + comment, + userId, + userNsRoles, + AuditRequestContext.from(httpRequest)) ); - recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); - return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/reject") @@ -129,25 +88,22 @@ public class ReviewController extends BaseApiController { @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, HttpServletRequest httpRequest) { String comment = request != null ? request.comment() : null; - ReviewTask task = reviewService.rejectReview( - id, - userId, - comment, - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId) + return ok( + "response.success.updated", + reviewPortalAppService.rejectReview( + id, + comment, + userId, + userNsRoles, + AuditRequestContext.from(httpRequest)) ); - recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment)); - return ok("response.success.updated", toResponse(task)); } @PostMapping("/{id}/withdraw") public ApiResponse withdrawReview(@PathVariable Long id, @RequestAttribute("userId") String userId, HttpServletRequest httpRequest) { - ReviewTask task = reviewTaskRepository.findById(id) - .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); - reviewService.withdrawReview(task.getSkillVersionId(), userId); - recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}"); + reviewPortalAppService.withdrawReview(id, userId, AuditRequestContext.from(httpRequest)); return ok("response.success.updated", null); } @@ -158,35 +114,9 @@ public class ReviewController extends BaseApiController { @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); - - Page tasks; - if (namespaceId != null) { - Namespace namespace = namespaceRepository.findById(namespaceId) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); - ReviewTask probe = new ReviewTask(0L, namespaceId, userId); - if (!reviewService.canReviewNamespace( - probe, - userId, - namespace.getType(), - namespaceRoles, - rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("review.no_permission"); - } - tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); - } else { - tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); - } - - java.util.List visibleItems = tasks.getContent().stream() - .filter(task -> canViewReview(task, userId, namespaceRoles)) - .map(this::toResponse) - .toList(); - return ok( "response.success.read", - PageResponse.from(new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size())) + reviewPortalAppService.listReviews(status, namespaceId, page, size, userId, userNsRoles) ); } @@ -196,49 +126,24 @@ public class ReviewController extends BaseApiController { @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - Namespace namespace = namespaceRepository.findById(namespaceId) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); - ReviewTask probe = new ReviewTask(0L, namespaceId, userId); - if (!reviewService.canReviewNamespace( - probe, - userId, - namespace.getType(), - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("review.no_permission"); - } - - Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( - namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); - return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); + return ok( + "response.success.read", + reviewPortalAppService.listPendingReviews(namespaceId, page, size, userId, userNsRoles) + ); } @GetMapping("/my-submissions") public ApiResponse> listMySubmissions(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { - Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( - userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); - return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); + return ok("response.success.read", reviewPortalAppService.listMySubmissions(page, size, userId)); } @GetMapping("/{id}") public ApiResponse getReviewDetail(@PathVariable Long id, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - ReviewTask task = reviewTaskRepository.findById(id) - .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); - Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); - if (!reviewService.canViewReview( - task, - userId, - namespace.getType(), - userNsRoles != null ? userNsRoles : Map.of(), - rbacService.getUserRoleCodes(userId))) { - throw new DomainForbiddenException("review.no_permission"); - } - return ok("response.success.read", toResponse(task)); + return ok("response.success.read", reviewPortalAppService.getReviewDetail(id, userId, userNsRoles)); } @GetMapping("/{id}/skill-detail") @@ -264,74 +169,6 @@ public class ReviewController extends BaseApiController { return buildDownloadResponse(request, result); } - private ReviewTaskResponse toResponse(ReviewTask task) { - SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); - Skill skill = skillRepository.findById(skillVersion.getSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); - Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); - - String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) - .map(UserAccount::getDisplayName) - .orElse(null); - String reviewedByName = task.getReviewedBy() != null - ? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) - : null; - - return new ReviewTaskResponse( - task.getId(), - task.getSkillVersionId(), - namespace.getSlug(), - skill.getSlug(), - skillVersion.getVersion(), - task.getStatus().name(), - task.getSubmittedBy(), - submittedByName, - task.getReviewedBy(), - reviewedByName, - task.getReviewComment(), - task.getSubmittedAt(), - task.getReviewedAt() - ); - } - - private boolean canViewReview(ReviewTask task, String userId, Map namespaceRoles) { - Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); - return reviewService.canViewReview( - task, - userId, - namespace.getType(), - namespaceRoles, - rbacService.getUserRoleCodes(userId) - ); - } - - private void recordAudit(String action, - String userId, - Long targetId, - HttpServletRequest httpRequest, - String detailJson) { - auditLogService.record( - userId, - action, - "REVIEW_TASK", - targetId, - MDC.get("requestId"), - httpRequest.getRemoteAddr(), - httpRequest.getHeader("User-Agent"), - detailJson - ); - } - - private String detailWithComment(String comment) { - if (comment == null || comment.isBlank()) { - return null; - } - return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; - } - private ResponseEntity buildDownloadResponse(HttpServletRequest request, SkillDownloadService.DownloadResult result) { if (shouldRedirectToPresignedUrl(request, result.presignedUrl())) { return ResponseEntity.status(HttpStatus.FOUND) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 9e40abfd..44c9a004 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -4,9 +4,8 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; -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.shared.exception.LocalizedDomainException; +import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.security.SensitiveLogSanitizer; import com.iflytek.skillhub.storage.StorageAccessException; @@ -45,39 +44,17 @@ public class GlobalExceptionHandler { @ExceptionHandler(LocalizedException.class) public ResponseEntity> handleLocalizedError(LocalizedException ex, HttpServletRequest request) { - HttpStatus status = ex.status(); - logHandledException(status, ex.messageCode(), request); - return ResponseEntity.status(status).body( - apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); + return renderLocalizedError(ex, ex.status(), request); } @ExceptionHandler(AuthFlowException.class) public ResponseEntity> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) { - HttpStatus status = ex.getStatus(); - logHandledException(status, ex.getMessageCode(), request); - return ResponseEntity.status(status).body( - apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs())); + return renderLocalizedError(ex, ex.getStatus(), request); } - @ExceptionHandler(DomainBadRequestException.class) - public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex, HttpServletRequest request) { - logHandledException(HttpStatus.BAD_REQUEST, ex.messageCode(), request); - return ResponseEntity.badRequest().body( - apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs())); - } - - @ExceptionHandler(DomainForbiddenException.class) - public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex, HttpServletRequest request) { - logHandledException(HttpStatus.FORBIDDEN, ex.messageCode(), request); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body( - apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); - } - - @ExceptionHandler(DomainNotFoundException.class) - public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex, HttpServletRequest request) { - logHandledException(HttpStatus.NOT_FOUND, ex.messageCode(), request); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body( - apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); + @ExceptionHandler(LocalizedDomainException.class) + public ResponseEntity> handleLocalizedDomainException(LocalizedDomainException ex, HttpServletRequest request) { + return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -160,6 +137,14 @@ public class GlobalExceptionHandler { ); } + private ResponseEntity> renderLocalizedError(LocalizedMessage error, + HttpStatus status, + HttpServletRequest request) { + logHandledException(status, error.messageCode(), request); + return ResponseEntity.status(status).body( + apiResponseFactory.error(status.value(), error.messageCode(), error.messageArgs())); + } + private String resolveUserId(HttpServletRequest request) { if (!(request.getUserPrincipal() instanceof Authentication authentication)) { return "anonymous"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java index da494d0b..2ed8fec7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/LocalizedError.java @@ -1,14 +1,11 @@ package com.iflytek.skillhub.exception; +import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage; import org.springframework.http.HttpStatus; /** * Common contract for errors that can be rendered as localized API responses. */ -public interface LocalizedError { - String messageCode(); - - Object[] messageArgs(); - +public interface LocalizedError extends LocalizedMessage { HttpStatus status(); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java new file mode 100644 index 00000000..48c80229 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -0,0 +1,211 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +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.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.PromotionService; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +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.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.PromotionResponseDto; +import java.util.Map; +import java.util.Set; +import org.slf4j.MDC; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class PromotionPortalAppService { + + private final PromotionService promotionService; + private final PromotionRequestRepository promotionRequestRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final UserAccountRepository userAccountRepository; + private final RbacService rbacService; + private final AuditLogService auditLogService; + + public PromotionPortalAppService(PromotionService promotionService, + PromotionRequestRepository promotionRequestRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + UserAccountRepository userAccountRepository, + RbacService rbacService, + AuditLogService auditLogService) { + this.promotionService = promotionService; + this.promotionRequestRepository = promotionRequestRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.userAccountRepository = userAccountRepository; + this.rbacService = rbacService; + this.auditLogService = auditLogService; + } + + public PromotionResponseDto submitPromotion(Long sourceSkillId, + Long sourceVersionId, + Long targetNamespaceId, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + PromotionRequest promotion = promotionService.submitPromotion( + sourceSkillId, + sourceVersionId, + targetNamespaceId, + userId, + normalizeRoles(userNsRoles), + platformRoles(userId) + ); + recordAudit( + "PROMOTION_SUBMIT", + userId, + promotion.getId(), + auditContext, + "{\"sourceSkillId\":" + sourceSkillId + ",\"sourceVersionId\":" + sourceVersionId + "}" + ); + return toResponse(promotion); + } + + public PromotionResponseDto approvePromotion(Long promotionId, + String comment, + String userId, + AuditRequestContext auditContext) { + PromotionRequest promotion = promotionService.approvePromotion( + promotionId, + userId, + comment, + platformRoles(userId) + ); + recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, detailWithComment(comment)); + return toResponse(promotion); + } + + public PromotionResponseDto rejectPromotion(Long promotionId, + String comment, + String userId, + AuditRequestContext auditContext) { + PromotionRequest promotion = promotionService.rejectPromotion( + promotionId, + userId, + comment, + platformRoles(userId) + ); + recordAudit("PROMOTION_REJECT", userId, promotion.getId(), auditContext, detailWithComment(comment)); + return toResponse(promotion); + } + + public PageResponse listPromotions(String status, + int page, + int size, + String userId) { + requirePromotionAdmin(userId); + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + return PageResponse.from(requests.map(this::toResponse)); + } + + public PageResponse listPendingPromotions(int page, int size, String userId) { + requirePromotionAdmin(userId); + Page requests = promotionRequestRepository.findByStatus( + ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + return PageResponse.from(requests.map(this::toResponse)); + } + + public PromotionResponseDto getPromotionDetail(Long promotionId, String userId) { + PromotionRequest promotion = promotionRequestRepository.findById(promotionId) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); + if (!promotionService.canViewPromotion(promotion, userId, platformRoles(userId))) { + throw new DomainForbiddenException("promotion.no_permission"); + } + return toResponse(promotion); + } + + private PromotionResponseDto toResponse(PromotionRequest request) { + Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); + SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId())); + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId())); + + String submittedByName = userAccountRepository.findById(request.getSubmittedBy()) + .map(UserAccount::getDisplayName) + .orElse(null); + String reviewedByName = request.getReviewedBy() != null + ? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) + : null; + + return new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + sourceNamespace.getSlug(), + sourceSkill.getSlug(), + sourceVersion.getVersion(), + targetNamespace.getSlug(), + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + submittedByName, + request.getReviewedBy(), + reviewedByName, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + ); + } + + private void requirePromotionAdmin(String userId) { + Set platformRoles = platformRoles(userId); + if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { + throw new DomainForbiddenException("promotion.no_permission"); + } + } + + private Set platformRoles(String userId) { + return rbacService.getUserRoleCodes(userId); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); + } + + private void recordAudit(String action, + String userId, + Long targetId, + AuditRequestContext auditContext, + String detailJson) { + auditLogService.record( + userId, + action, + "PROMOTION_REQUEST", + targetId, + MDC.get("requestId"), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java new file mode 100644 index 00000000..b2e80ba8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -0,0 +1,278 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +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.review.ReviewService; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +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.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewTaskResponse; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.slf4j.MDC; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class ReviewPortalAppService { + + private final ReviewService reviewService; + private final ReviewTaskRepository reviewTaskRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final UserAccountRepository userAccountRepository; + private final RbacService rbacService; + private final AuditLogService auditLogService; + + public ReviewPortalAppService(ReviewService reviewService, + ReviewTaskRepository reviewTaskRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + UserAccountRepository userAccountRepository, + RbacService rbacService, + AuditLogService auditLogService) { + this.reviewService = reviewService; + this.reviewTaskRepository = reviewTaskRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.userAccountRepository = userAccountRepository; + this.rbacService = rbacService; + this.auditLogService = auditLogService; + } + + public ReviewTaskResponse submitReview(Long skillVersionId, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + ReviewTask task = reviewService.submitReview( + skillVersionId, + userId, + normalizeRoles(userNsRoles), + platformRoles(userId) + ); + recordAudit("REVIEW_SUBMIT", userId, task.getId(), auditContext, "{\"skillVersionId\":" + skillVersionId + "}"); + return toResponse(task); + } + + public ReviewTaskResponse approveReview(Long reviewTaskId, + String comment, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + ReviewTask task = reviewService.approveReview( + reviewTaskId, + userId, + comment, + normalizeRoles(userNsRoles), + platformRoles(userId) + ); + recordAudit("REVIEW_APPROVE", userId, task.getId(), auditContext, detailWithComment(comment)); + return toResponse(task); + } + + public ReviewTaskResponse rejectReview(Long reviewTaskId, + String comment, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + ReviewTask task = reviewService.rejectReview( + reviewTaskId, + userId, + comment, + normalizeRoles(userNsRoles), + platformRoles(userId) + ); + recordAudit("REVIEW_REJECT", userId, task.getId(), auditContext, detailWithComment(comment)); + return toResponse(task); + } + + public void withdrawReview(Long reviewTaskId, + String userId, + AuditRequestContext auditContext) { + ReviewTask task = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + reviewService.withdrawReview(task.getSkillVersionId(), userId); + recordAudit( + "REVIEW_WITHDRAW", + userId, + reviewTaskId, + auditContext, + "{\"skillVersionId\":" + task.getSkillVersionId() + "}" + ); + } + + public PageResponse listReviews(String status, + Long namespaceId, + int page, + int size, + String userId, + Map userNsRoles) { + ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); + Map namespaceRoles = normalizeRoles(userNsRoles); + + Page tasks; + if (namespaceId != null) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace( + probe, + userId, + namespace.getType(), + namespaceRoles, + platformRoles(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); + } else { + tasks = reviewTaskRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + } + + List visibleItems = tasks.getContent().stream() + .filter(task -> canViewReview(task, userId, namespaceRoles)) + .map(this::toResponse) + .toList(); + + return PageResponse.from(new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size())); + } + + public PageResponse listPendingReviews(Long namespaceId, + int page, + int size, + String userId, + Map userNsRoles) { + Namespace namespace = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); + ReviewTask probe = new ReviewTask(0L, namespaceId, userId); + if (!reviewService.canReviewNamespace( + probe, + userId, + namespace.getType(), + normalizeRoles(userNsRoles), + platformRoles(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + + Page tasks = reviewTaskRepository.findByNamespaceIdAndStatus( + namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + return PageResponse.from(tasks.map(this::toResponse)); + } + + public PageResponse listMySubmissions(int page, int size, String userId) { + Page tasks = reviewTaskRepository.findBySubmittedByAndStatus( + userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + return PageResponse.from(tasks.map(this::toResponse)); + } + + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, + String userId, + Map userNsRoles) { + ReviewTask task = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + if (!reviewService.canViewReview( + task, + userId, + namespace.getType(), + normalizeRoles(userNsRoles), + platformRoles(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + return toResponse(task); + } + + private ReviewTaskResponse toResponse(ReviewTask task) { + SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + + String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) + .map(UserAccount::getDisplayName) + .orElse(null); + String reviewedByName = task.getReviewedBy() != null + ? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null) + : null; + + return new ReviewTaskResponse( + task.getId(), + task.getSkillVersionId(), + namespace.getSlug(), + skill.getSlug(), + skillVersion.getVersion(), + task.getStatus().name(), + task.getSubmittedBy(), + submittedByName, + task.getReviewedBy(), + reviewedByName, + task.getReviewComment(), + task.getSubmittedAt(), + task.getReviewedAt() + ); + } + + private boolean canViewReview(ReviewTask task, String userId, Map namespaceRoles) { + Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + return reviewService.canViewReview( + task, + userId, + namespace.getType(), + namespaceRoles, + platformRoles(userId) + ); + } + + private Set platformRoles(String userId) { + return rbacService.getUserRoleCodes(userId); + } + + private Map normalizeRoles(Map userNsRoles) { + return userNsRoles != null ? userNsRoles : Map.of(); + } + + private void recordAudit(String action, + String userId, + Long targetId, + AuditRequestContext auditContext, + String detailJson) { + auditLogService.record( + userId, + action, + "REVIEW_TASK", + targetId, + MDC.get("requestId"), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + detailJson + ); + } + + private String detailWithComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + return "{\"comment\":\"" + comment.replace("\"", "\\\"") + "\"}"; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java index d3342b77..a381a39d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -1,9 +1,6 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; @@ -27,16 +24,14 @@ class ClawHubRegistryFacadeTest { CanonicalSlugMapper canonicalSlugMapper = new CanonicalSlugMapper(); SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class); SkillQueryService skillQueryService = mock(SkillQueryService.class); - SkillRepository skillRepository = mock(SkillRepository.class); - SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class); UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); ClawHubRegistryFacade facade = new ClawHubRegistryFacade( canonicalSlugMapper, skillSearchAppService, skillQueryService, - skillRepository, - skillVersionRepository, + compatSkillLookupService, userAccountRepository ); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java index 4190ae7e..aeca636a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java @@ -1,12 +1,13 @@ package com.iflytek.skillhub.auth.exception; +import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage; import org.springframework.http.HttpStatus; /** * Auth-layer exception that carries both an HTTP status and a localized message code for API * rendering. */ -public class AuthFlowException extends RuntimeException { +public class AuthFlowException extends RuntimeException implements LocalizedMessage { private final HttpStatus status; private final String messageCode; @@ -23,6 +24,16 @@ public class AuthFlowException extends RuntimeException { return status; } + @Override + public String messageCode() { + return messageCode; + } + + @Override + public Object[] messageArgs() { + return messageArgs; + } + public String getMessageCode() { return messageCode; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java index aab80d58..255739c8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainBadRequestException.java @@ -8,4 +8,9 @@ public class DomainBadRequestException extends LocalizedDomainException { public DomainBadRequestException(String messageCode, Object... messageArgs) { super(messageCode, messageArgs); } + + @Override + public int statusCode() { + return 400; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java index 14807d15..3624205f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainForbiddenException.java @@ -8,4 +8,9 @@ public class DomainForbiddenException extends LocalizedDomainException { public DomainForbiddenException(String messageCode, Object... messageArgs) { super(messageCode, messageArgs); } + + @Override + public int statusCode() { + return 403; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java index df387c69..82ab93da 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainNotFoundException.java @@ -8,4 +8,9 @@ public class DomainNotFoundException extends LocalizedDomainException { public DomainNotFoundException(String messageCode, Object... messageArgs) { super(messageCode, messageArgs); } + + @Override + public int statusCode() { + return 404; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java index 35e55cd7..03208292 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedDomainException.java @@ -3,7 +3,7 @@ package com.iflytek.skillhub.domain.shared.exception; /** * Base class for domain-layer exceptions that carry a localized message code and arguments. */ -public abstract class LocalizedDomainException extends RuntimeException { +public abstract class LocalizedDomainException extends RuntimeException implements LocalizedMessage { private final String messageCode; private final Object[] messageArgs; @@ -21,4 +21,6 @@ public abstract class LocalizedDomainException extends RuntimeException { public Object[] messageArgs() { return messageArgs.clone(); } + + public abstract int statusCode(); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedMessage.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedMessage.java new file mode 100644 index 00000000..fc5dcafe --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/LocalizedMessage.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.shared.exception; + +/** + * Shared contract for exceptions that expose a localized message code and + * interpolation arguments regardless of module boundary. + */ +public interface LocalizedMessage { + String messageCode(); + + Object[] messageArgs(); +}