refactor(app): slim portal controllers and sync backend findings

This commit is contained in:
vsxd 2026-03-20 10:06:32 +08:00 committed by Xudong Sun
parent a625c37906
commit 2868c10467
22 changed files with 1290 additions and 852 deletions

View file

@ -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 读路径,可查看待审版本及完整版本快照
版本号不可变性规则:

View file

@ -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成员空间+ PRIVATEowner/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成员空间+ PRIVATEowner/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 APIBearer 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 解析) |

View file

@ -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 场景下还能审自己的提交 | 可 | 可 |
### 对象存储写入策略
一期同步写入正式路径,不使用临时区:

View file

@ -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` 语义说明

View file

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

View file

@ -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<Long, NamespaceRole> userNsRoles) {
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
q,
null,
q == null || q.isBlank() ? "newest" : "relevance",
page,
limit,
userId,
userNsRoles
);
List<ClawHubSearchResponse.ClawHubSearchResult> results = response.items().stream()
.map(this::toSearchResult)
.toList();
return new ClawHubSearchResponse(results);
}
public ClawHubResolveResponse resolveByQuery(String slug,
String version,
String hash,
String userId,
Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
String sortBy = sort != null ? sort : "newest";
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
"",
null,
sortBy,
page,
limit,
userId,
userNsRoles
);
List<ClawHubSkillListResponse.SkillListItem> 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<String, Object> 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
);
}
}

View file

@ -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<Long, NamespaceRole> userNsRoles) {
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
q,
null,
q == null || q.isBlank() ? "newest" : "relevance",
page,
limit,
userId,
userNsRoles
);
List<ClawHubSearchResponse.ClawHubSearchResult> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
return clawHubCompatAppService.resolve(canonicalSlug, version, userId, userNsRoles);
}
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
@GetMapping("/download/{canonicalSlug}")
public ResponseEntity<Void> 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<Void> 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<Long, NamespaceRole> 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<ClawHubSkillListResponse.SkillListItem> 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<String, Object> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Void> redirect(String location) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, location)
.build();
}
}

View file

@ -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<SkillVersion> latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), projection.version());
Optional<SkillVersion> latestVersion = compatSkillLookupService.findVersion(skill.getId(), projection.version());
if (latestVersion.isEmpty()) {
return new ClawHubRegistrySkillVersion(projection.version(), 0L, "", null);
}

View file

@ -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<SkillVersion> findVersion(Long skillId, String version) {
if (skillId == null || version == null || version.isBlank()) {
return Optional.empty();
}
return skillVersionRepository.findBySkillIdAndVersion(skillId, version);
}
public Optional<SkillVersion> 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<SkillVersion> latestVersion) {
}
}

View file

@ -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<Long, NamespaceRole> 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<String> 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<PromotionRequest> 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<PageResponse<PromotionResponseDto>> listPendingPromotions(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
throw new DomainForbiddenException("promotion.no_permission");
}
Page<PromotionRequest> 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<PromotionResponseDto> 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));
}
}

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Void> 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<Long, NamespaceRole> userNsRoles) {
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
Page<ReviewTask> 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<ReviewTaskResponse> 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<Long, NamespaceRole> 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<ReviewTask> 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<PageResponse<ReviewTaskResponse>> listMySubmissions(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
Page<ReviewTask> 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<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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<InputStreamResource> buildDownloadResponse(HttpServletRequest request, SkillDownloadService.DownloadResult result) {
if (shouldRedirectToPresignedUrl(request, result.presignedUrl())) {
return ResponseEntity.status(HttpStatus.FOUND)

View file

@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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";

View file

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

View file

@ -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<Long, NamespaceRole> 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<PromotionResponseDto> listPromotions(String status,
int page,
int size,
String userId) {
requirePromotionAdmin(userId);
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Page<PromotionRequest> requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size));
return PageResponse.from(requests.map(this::toResponse));
}
public PageResponse<PromotionResponseDto> listPendingPromotions(int page, int size, String userId) {
requirePromotionAdmin(userId);
Page<PromotionRequest> 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<String> platformRoles = platformRoles(userId);
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
throw new DomainForbiddenException("promotion.no_permission");
}
}
private Set<String> platformRoles(String userId) {
return rbacService.getUserRoleCodes(userId);
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> 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("\"", "\\\"") + "\"}";
}
}

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<ReviewTaskResponse> listReviews(String status,
Long namespaceId,
int page,
int size,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Map<Long, NamespaceRole> namespaceRoles = normalizeRoles(userNsRoles);
Page<ReviewTask> 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<ReviewTaskResponse> 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<ReviewTaskResponse> listPendingReviews(Long namespaceId,
int page,
int size,
String userId,
Map<Long, NamespaceRole> 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<ReviewTask> tasks = reviewTaskRepository.findByNamespaceIdAndStatus(
namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
return PageResponse.from(tasks.map(this::toResponse));
}
public PageResponse<ReviewTaskResponse> listMySubmissions(int page, int size, String userId) {
Page<ReviewTask> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<String> platformRoles(String userId) {
return rbacService.getUserRoleCodes(userId);
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> 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("\"", "\\\"") + "\"}";
}
}

View file

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

View file

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

View file

@ -8,4 +8,9 @@ public class DomainBadRequestException extends LocalizedDomainException {
public DomainBadRequestException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
@Override
public int statusCode() {
return 400;
}
}

View file

@ -8,4 +8,9 @@ public class DomainForbiddenException extends LocalizedDomainException {
public DomainForbiddenException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
@Override
public int statusCode() {
return 403;
}
}

View file

@ -8,4 +8,9 @@ public class DomainNotFoundException extends LocalizedDomainException {
public DomainNotFoundException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
@Override
public int statusCode() {
return 404;
}
}

View file

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

View file

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