refactor: skill lifecycle projection and docs (#78)

* Refactor skill lifecycle projection and docs

* Improve owner lifecycle visibility on skill detail

* Track download counts per skill version
This commit is contained in:
Xudong Sun 2026-03-18 13:39:22 +08:00 committed by GitHub
parent c2284d8acf
commit 4aa3d29599
50 changed files with 1104 additions and 393 deletions

View file

@ -115,6 +115,9 @@ ClawHub CLI 使用单一 slug 模型slug 校验规则为 `[a-z0-9]([a-z0-9-]*
- 团队技能提升到全局需平台管理员二次审核
- 平台管理员只负责全局空间审核与提升审核,不介入团队空间审核
- 当前不引入自动审核;`PrePublishValidator` 仅作为未来扩展点保留,默认实现为 `NoOp`
- 撤回审核语义统一为 `PENDING_REVIEW → DRAFT`,不再走删除版本记录
- skill 生命周期管理读模型统一为 `headlineVersion / publishedVersion / ownerPreviewVersion / resolutionMode`
- `hidden` 是独立治理覆盖层,不属于 skill 容器状态机
认证与权限:
- OAuth2 标准登录(一期 GitHub OAuth

View file

@ -64,8 +64,8 @@
| owner_id | varchar(128) | 主要维护人(可转让) |
| source_skill_id | bigint | 派生来源(团队技能提升到全局时记录原 skill IDnullable |
| visibility | enum | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` |
| status | enum | `ACTIVE` / `HIDDEN` / `ARCHIVED` |
| latest_version_id | bigint | 最新已发布版本(自动跟随,每次发布自动更新) |
| status | enum | `ACTIVE` / `ARCHIVED` |
| latest_version_id | bigint | latest published pointer仅指向最新 `PUBLISHED` 版本;若不存在已发布版本则可为 `null` |
| download_count | bigint | |
| star_count | int | |
| rating_avg | decimal(3,2) | 平均评分 |
@ -76,6 +76,7 @@
| updated_at | datetime | |
- 唯一约束:`(namespace_id, slug)`
- `status` 表示 skill 容器生命周期,不再承载“隐藏”语义。隐藏是独立的治理覆盖层,由 `hidden` / `hidden_at` / `hidden_by` 表达
- `owner_id` 语义为"主要维护人",可转让。权限主轴是 namespace role不是 owner
- namespace ADMIN 对空间内所有 skill 有完整管理权(归档、版本管理、提升到全局),不受 owner 限制
- owner 作为 MEMBER 时可管理自己创建的 skill提交审核、编辑草稿
@ -102,8 +103,14 @@
| published_at | datetime | |
| created_at | datetime | |
- `status` 覆盖完整审核生命周期
- 状态机:`DRAFT → PENDING_REVIEW → PUBLISHED / REJECTED``PUBLISHED → YANKED`
- `status` 表示 version 发布生命周期,和 skill 容器状态、review task 状态分离
- 当前代码下的实际迁移约束:
- 普通用户首次上传/重传新版本后,版本直接进入 `PENDING_REVIEW`
- `SUPER_ADMIN` 直发时可直接进入 `PUBLISHED`
- 审核通过:`PENDING_REVIEW → PUBLISHED`
- 审核拒绝:`PENDING_REVIEW → REJECTED`
- 撤回审核:`PENDING_REVIEW → DRAFT`
- 已发布撤回:`PUBLISHED → YANKED`
- 唯一约束:`(skill_id, version)` 防止重复发布
- `YANKED` 状态:已发布后撤回
@ -112,7 +119,7 @@
| 版本状态 | 版本号处理 |
|---------|-----------|
| DRAFT | 可删除该版本记录,重新使用同版本号 |
| PENDING_REVIEW | 可撤回到 DRAFT,然后删除 |
| PENDING_REVIEW | 可撤回到 DRAFT |
| REJECTED | 可删除该版本记录,重新使用同版本号 |
| PUBLISHED | 版本号永久占用,不可复用 |
| YANKED | 版本号永久占用,不可复用,版本列表中显示但标记为不可下载 |
@ -144,7 +151,7 @@
| updated_by | varchar(128) | |
| updated_at | datetime | |
- `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`,不允许 API 手动移动
- `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`;其语义严格等价于“最新已发布版本”,不允许 API 手动移动
- 自定义标签(如 `beta``stable-2026q1`)允许人工创建和移动
- 唯一约束:`(skill_id, tag_name)`
- `target_version_id` 必须指向 `status = PUBLISHED` 的版本,应用层校验
@ -166,7 +173,7 @@
- 仅用于普通发布审核,"提升到全局"使用独立的 `promotion_request`
- `version` 字段用于乐观锁,防止多 Pod 并发审核
- 业务约束:同一 `skill_version_id``status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict。撤回PENDING → 删除 review_task + skill_version 回退到 DRAFT后才能再次提交
- 业务约束:同一 `skill_version_id``status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict。撤回时删除 `PENDING` review_task并将 `skill_version` 回退到 `DRAFT`
- PostgreSQL 并发约束落地:通过唯一索引 `(skill_version_id)` + 软删除标记实现。`review_task` 表增加 `deleted` 字段bigint, 默认 0唯一索引改为 `(skill_version_id, deleted)`。撤回时将 `deleted` 设为 `id`(非零值),新提交时 `deleted=0`,利用唯一索引防止并发重复提交。或者采用更简单的方案:撤回时物理删除 review_task 记录,依赖 `INSERT` 的唯一约束 `(skill_version_id)` 防并发。PostgreSQL 还支持 partial unique index 方案:`CREATE UNIQUE INDEX ON review_task (skill_version_id) WHERE status = 'PENDING'`,更优雅地实现"PENDING 状态唯一"约束
### promotion_request
@ -293,7 +300,7 @@
| 角色 code | 说明 | 典型权限 |
|-----------|------|---------|
| `SUPER_ADMIN` | 平台超管,拥有所有权限 | 全部 |
| `SKILL_ADMIN` | 技能治理:全局空间审核、提升审核、隐藏/撤回 | `review:approve`, `skill:manage`, `promotion:approve` |
| `SKILL_ADMIN` | 技能治理:全局空间审核、提升审核、隐藏/恢复、撤回已发布版本 | `review:approve`, `skill:manage`, `promotion:approve` |
| `USER_ADMIN` | 用户治理:准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN | `user:manage`, `user:approve` |
| `AUDITOR` | 审计只读:查看审计日志 | `audit:read` |
@ -341,7 +348,7 @@
### skill_search_document
一个 skill 对应一条搜索文档,内容取 `latest_version_id` 对应版本
一个 skill 对应一条搜索文档,内容取“最新已发布版本”。实现上可由 `latest_version_id` 作为缓存指针承载,但其语义只能是 latest published pointer
| 字段 | 类型 | 说明 |
|------|------|------|

View file

@ -377,7 +377,7 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
| 平台角色 | 职责 |
|---------|------|
| `SUPER_ADMIN` | 全部权限,硬判定短路 |
| `SKILL_ADMIN` | 全局空间审核、提升审核、隐藏/撤回技能 |
| `SKILL_ADMIN` | 全局空间审核、提升审核、隐藏/恢复技能、撤回已发布版本 |
| `USER_ADMIN` | 准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN |
| `AUDITOR` | 审计日志只读 |
@ -402,7 +402,8 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
| 审核团队空间技能 | `review:approve` | 该 namespace 的 ADMIN 或 OWNER |
| 审核全局空间技能 | `review:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN |
| 审核提升申请 | `promotion:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN |
| 隐藏/撤回技能 | `skill:manage` | 持有 SKILL_ADMIN / SUPER_ADMIN |
| 隐藏/恢复技能 | `skill:manage` | 持有 SKILL_ADMIN / 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 |
@ -600,7 +601,7 @@ 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 .../versions/{ver}/withdraw-review` | 提交人本人 或 namespace ADMIN | `review_task.submitted_by``namespace_member.role` |
| `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` |

View file

@ -57,7 +57,7 @@ WHERE (visibility = 'PUBLIC')
## 3 搜索文档表 skill_search_document
一个 skill 对应一条搜索文档,内容取 `latest_version_id` 对应版本。版本发布时自动更新该条文档
一个 skill 对应一条搜索文档,但文档内容的来源语义应严格收敛为“当前最新已发布版本”。实现上仍可由 `latest_version_id` 作为缓存指针承载,但它只允许指向 `PUBLISHED` 版本;搜索层不能再把它当作泛化的“当前版本”
| 字段 | 类型 | 说明 |
|------|------|------|
@ -80,14 +80,15 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列,
## 4 索引写入时机
以下场景触发搜索文档更新upsert by skill_id
- 审核通过(`PENDING_REVIEW → PUBLISHED``latest_version_id` 自动更新,用新版本内容更新搜索文档
- 审核通过(`PENDING_REVIEW → PUBLISHED`):重算“最新已发布版本”指针,并用该发布版本内容更新搜索文档
- 已发布版本被撤回(`PUBLISHED → YANKED`):重算“最新已发布版本”指针;若不存在任何已发布版本,则移除搜索文档
- 技能状态变更(隐藏/归档/恢复):更新搜索文档的 status 字段
## 5 搜索演进路线
### 5.1 一期数据建模约束
一期"每个 skill 一条搜索文档、内容永远取 latest_version_id"是有意的简化。这个模型在以下场景下会不够用:
一期“每个 skill 一条搜索文档、内容永远取最新已发布版本”是有意的简化。当前实现仍使用 `latest_version_id` 作为持久化指针,但这里的语义已经收敛为 latest published pointer。这个模型在以下场景下会不够用:
- 版本级检索(搜索某个旧版本的内容)
- 自定义标签/通道检索(搜索 `@beta` 标签指向的版本内容)
@ -96,7 +97,7 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列,
这些场景不是简单换 provider 能解决的,需要改表结构和索引写入逻辑。
**一期搜索能力边界(产品限制):**
- 搜索只基于 `latest_version_id` 对应版本的内容
- 搜索只基于“最新已发布版本”的内容
- 不支持按 version 或 tag 搜索内容
- 搜索结果不区分 channel`beta``stable` 等标签通道)
- 用户通过 tag 安装的技能内容可能与搜索结果展示的内容不一致(搜索展示 latest安装的是 tag 指向的版本)
@ -106,8 +107,8 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列,
| 阶段 | 实现 | 索引粒度 | 切换方式 |
|------|------|---------|---------|
| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条latest_version_id | 默认 |
| 一点五期 | PostgreSQL Full-Text + 语义向量重排 | 每 skill 一条latest_version_id | 配置 `skillhub.search.semantic.enabled=true` |
| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条latest published | 默认 |
| 一点五期 | PostgreSQL Full-Text + 语义向量重排 | 每 skill 一条latest published | 配置 `skillhub.search.semantic.enabled=true` |
| 二期 | ES / OpenSearch | 每 skill_version 一条 + skill 聚合文档 | 配置 `search.provider=elasticsearch` |
| 三期 | 向量检索 | 每 skill_version 多条chunk 级) | 配置 `search.provider=vector` |
| 四期 | 混合排序 | 关键词 + 向量混合 | 配置 `search.provider=hybrid` |

View file

@ -48,8 +48,26 @@
- 同步创建 `review_task(status=PENDING)`
- 审核通过后转为 `PUBLISHED`
- 审核拒绝后转为 `REJECTED`
- 撤回审核时删除 `PENDING review_task`,并将 `skill_version` 回退到 `DRAFT`
- 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员
- 上述例外必须对 Web、`/api/v1/publish``/api/v1/publish` 保持一致
- 若重传新版本时发现旧的 `PENDING_REVIEW` 版本,旧版本会被自动降回 `DRAFT`,再创建新的待审版本
### 1.2 生命周期读模型
当前代码中的 skill 生命周期展示与操作判断,不再依赖旧的 `latestVersionStatus``viewingVersionStatus` 一类拼装字段,而统一基于以下 projection
- `headlineVersion`:当前详情页/我的技能列表主展示版本
- `publishedVersion`:当前最新可公开分发的已发布版本
- `ownerPreviewVersion`owner 或 namespace 管理者可见的待审核版本
- `resolutionMode``PUBLISHED` / `OWNER_PREVIEW` / `NONE`
业务规则:
- 公开入口只认 `publishedVersion`
- owner 进入详情页时,如果没有可用 `publishedVersion`,才允许 `headlineVersion = ownerPreviewVersion`
- 推广到全局、安装命令、公开下载都只能绑定到 `publishedVersion`
- `hidden` 是独立治理覆盖层,不属于 skill 生命周期状态机
### 对象存储写入策略
@ -121,6 +139,11 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确
- 原团队 skill 可继续独立迭代
- 两者版本不自动同步,如需同步由 owner 手动操作
提升流程当前严格绑定已发布版本:
- promotion request 的 `source_version_id` 必须指向 `publishedVersion.id`
- 不允许直接提升 `ownerPreviewVersion`
## 3 下载流程
```

View file

@ -76,7 +76,7 @@
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 版本详情 |
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/files` | 文件清单 |
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/file?path=...` | 读取单个文件query param 避免路径中 / 的解析问题) |
| GET | `/api/v1/skills/{namespace}/{slug}/download` | 下载默认安装版本(latest_version_id 指向的版本) |
| GET | `/api/v1/skills/{namespace}/{slug}/download` | 下载默认安装版本(最新已发布版本) |
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本包 |
| GET | `/api/v1/skills/{namespace}/{slug}/resolve` | 解析技能版本(支持 query param: `version``tag``hash` |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) |
@ -206,7 +206,7 @@ Public API 的可见性规则:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 DRAFT 版本提交审核 |
| 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 以上) |
@ -226,6 +226,20 @@ Public API 的可见性规则:
| POST | `/api/v1/skills/{namespace}/{slug}/unarchive` | 恢复归档namespace ADMIN 或 owner |
| DELETE | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 删除 DRAFT/REJECTED 版本 |
当前代码中的 skill 生命周期读模型不再依赖 `latestVersionStatus` / `viewingVersionStatus` 一类拼装字段,而统一使用以下 projection
- `headlineVersion`:当前页面应展示的主版本
- `publishedVersion`:当前最新可分发的已发布版本
- `ownerPreviewVersion`owner / namespace 管理者可见的待审核预览版本
- `resolutionMode``PUBLISHED` / `OWNER_PREVIEW` / `NONE`
其中:
- 公开详情、公开安装、公开搜索一律只认 `publishedVersion`
- owner 详情页在没有可展示发布版本时,才允许 `headlineVersion` 落到 `ownerPreviewVersion`
- 推广到全局一律使用 `publishedVersion.id`
- `hidden` 是独立治理覆盖层,不属于生命周期状态机
发布成功响应中的 `data` 至少包含以下字段:
- `skillId`
@ -241,6 +255,7 @@ Public API 的可见性规则:
- 普通用户发布成功后,`status``PENDING_REVIEW`
- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/publish``/api/v1/publish` 发布时,`status``PUBLISHED`,且不要求其必须是目标 namespace 成员
- 当前版本保持该审核策略,不再提供“全员直发”的运行模式
- 撤回审核不会删除版本记录,而是 `PENDING_REVIEW → DRAFT`
## 7.4 Token API需登录
@ -336,14 +351,15 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
`latest` 自动跟随最新已发布版本,不可手动移动。
- `skill.latest_version_id`:每次审核通过自动更新,始终指向最新 PUBLISHED 版本
- `yank` 当前最新已发布版本时,需要同步重算 `latest_version_id` 指向下一个最新的 `PUBLISHED` 版本;若不存在则允许为 `null`
- `latest` 标签:系统保留,只读,自动与 `latest_version_id` 同步
- 自定义标签(如 `beta``stable-2026q1`):允许人工创建和移动,用于固定安装通道
| 场景 | 使用字段 | 说明 |
|------|---------|------|
| 搜索索引内容 | `latest_version_id` | 搜索文档取最新已发布版本内容 |
| `/download`(不带版本号) | `latest_version_id` | 下载最新已发布版本 |
| CLI `install @team/skill` | `latest_version_id` | 等同于 `@latest` |
| 搜索索引内容 | `publishedVersion` / `latest_version_id` | 外部协议仍叫 latest但内部语义必须等价于最新已发布版本 |
| `/download`(不带版本号) | `publishedVersion` / `latest_version_id` | 下载最新已发布版本 |
| CLI `install @team/skill` | `publishedVersion` / `latest_version_id` | 等同于 `@latest` |
| CLI `install @team/skill@beta` | `skill_tag` 查询 | 自定义标签指向的版本 |
## 7.9 Resolve 接口说明
@ -361,7 +377,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
2. 仅传 `version`:精确匹配版本号
3. 仅传 `tag`:查询 `skill_tag` 表获取 `target_version_id`
4. 仅传 `hash`:遍历已发布版本,比对 fingerprint
5. 均不传:返回 `latest_version_id` 指向的版本
5. 均不传:返回最新已发布版本;实现上可由 `latest_version_id` 或等价 published projection 解析
响应:

View file

@ -116,7 +116,7 @@ CLI 安装后在本地写入 `.astron/metadata.json`
skillhub 自有 CLI 支持完整 namespace 坐标:
```
install @team/my-skill → 最新已发布版本latest_version_id
install @team/my-skill → 最新已发布版本(实现上通常由 `latest_version_id` / published pointer 解析
install @team/my-skill@1.2.0 → 精确版本
install @team/my-skill@latest → 等同于不带版本号(系统保留标签,只读)
install @team/my-skill@beta → beta 标签(自定义标签)

View file

@ -38,7 +38,7 @@
| 页面 | 路径 | 说明 |
|------|------|------|
| 我的技能 | `/dashboard/skills` | 我发布的技能 + 审核状态 |
| 我的技能 | `/dashboard/skills` | 我发布的技能 + 统一生命周期状态 |
| 发布技能 | `/dashboard/publish` | zip 上传 + 预览 + 提交审核 |
| 我的收藏 | `/dashboard/stars` | 收藏列表 |
| Token 管理 | `/dashboard/tokens` | 创建/查看/吊销 |
@ -57,7 +57,7 @@
|------|------|---------|------|
| 审核中心 | `/admin/reviews` | SKILL_ADMIN | 全局待审核列表 |
| 提升审核 | `/admin/promotions` | SKILL_ADMIN | 提升到全局的申请列表 |
| 技能管理 | `/admin/skills` | SKILL_ADMIN | 隐藏/恢复/撤回 |
| 技能管理 | `/admin/skills` | SKILL_ADMIN | 隐藏/恢复技能、撤回已发布版本 |
| 用户管理 | `/admin/users` | USER_ADMIN | 用户列表、角色分配、准入审批、封禁/解封 |
| 审计日志 | `/admin/audit-logs` | AUDITOR | 操作日志查询 |
| 命名空间管理 | `/admin/namespaces` | SUPER_ADMIN | 创建/归档/冻结 |
@ -70,6 +70,21 @@ SUPER_ADMIN 可访问所有管理页面。路由守卫检查用户是否持有
- Dashboard / Admin顶部导航 + 左侧边栏,管理效率优先
- 响应式:移动端侧边栏收起为抽屉
## 3.1 生命周期展示模型
前端不再从 `status + hidden + latestVersionStatus + viewingVersionStatus` 拼装 skill 生命周期,而统一消费后端返回的 projection
- `headlineVersion`:当前页面主展示版本
- `publishedVersion`:当前最新已发布版本
- `ownerPreviewVersion`owner / namespace 管理者可见的待审核版本
- `resolutionMode``PUBLISHED` / `OWNER_PREVIEW` / `NONE`
约束:
- 详情页和“我的技能”列表统一以 `headlineVersion` 作为主展示版本
- 安装、下载、promotion 等公开分发相关操作只允许绑定 `publishedVersion`
- `hidden` 是独立治理覆盖层,不属于版本生命周期状态机
## 4 登录与鉴权
### 4.1 OAuth2 登录流程(前端视角)

View file

@ -100,7 +100,7 @@
- 本地认证体系(用户名密码注册/登录 + BCrypt + 密码策略 + 账号锁定)
- 多账号合并流程(发起 → 验证 → 确认 → 数据迁移)
- 技能治理(隐藏/恢复 + 版本撤回 YANKED
- 技能治理(隐藏/恢复 + 已发布版本撤回 YANKED
- 审计日志查询 API多条件筛选 + 分页)
- Prometheus 指标暴露Actuator + Micrometer 自定义业务指标)
- 性能优化(数据库索引 + S3 预签名 URL + 连接池调优)
@ -111,7 +111,7 @@
- 注册页、登录页扩展(用户名密码 + OAuth 双模式)
- 密码修改页、账号合并页
- 审计日志查询页
- 技能隐藏/撤回操作(管理员可见)
- 技能隐藏/恢复/已发布版本撤回操作(管理员可见)
- 前端代码分割TanStack Router lazy routes
- rehype-sanitize XSS 防护
- OpenAPI SDK 工程化:生成文件纳入 CI 校验,避免新增接口回退到手写调用
@ -125,12 +125,12 @@
### 验收
本地认证可用,多账号合并可用,技能隐藏/撤回可用审计日志可查询Prometheus 指标可拉取,`docker compose up` 一键启动K8s 清单可部署,开源基础设施齐全
本地认证可用,多账号合并可用,技能隐藏/恢复/已发布版本撤回可用审计日志可查询Prometheus 指标可拉取,`docker compose up` 一键启动K8s 清单可部署,开源基础设施齐全
## Phase 5治理闭环 + 社交
- 评论功能
- 举报/标记机制(用户举报 → 管理员处理 → 隐藏/撤回)
- 举报/标记机制(用户举报 → 管理员处理 → 隐藏/已发布版本撤回)
- 自动安全预检(`PrePublishValidator` 从当前 `NoOp` 扩展为真实校验链)
- Webhook/事件通知(发布通知、审核结果通知)
- 后续 OAuth Provider 扩展GitLab、Google 等)
@ -141,7 +141,7 @@
| 风险 | 应对 |
|------|------|
| GitHub OAuth 回调配置复杂 | 本地用 MockAuthFilter 解耦OAuth 联调可并行 |
| 审核流程需求变更 | skill_version.status 已预留审核状态 |
| 审核流程需求变更 | 生命周期状态机已收敛到 `DRAFT / PENDING_REVIEW / PUBLISHED / REJECTED / YANKED`,读模型通过统一 projection 暴露 |
| 搜索效果不佳 | SPI 架构允许随时切换实现 |
| 前后端接口频繁变更 | OpenAPI spec 先行,类型自动生成 |
| 新增 OAuth Provider | Spring Security OAuth2 原生多 Provider 支持,只需配置 + 属性映射 |

155
docs/14-skill-lifecycle.md Normal file
View file

@ -0,0 +1,155 @@
# Skill Lifecycle
Date: 2026-03-18
Status: current code-aligned reference
本文件是 skill 生命周期的单一规范入口。结论以当前代码实现为准并已经同步到领域模型、业务流程、API、前端、搜索和兼容层文档。
## 1. 设计原则
- skill 生命周期不再被建模为一个混杂状态机,而是拆分为容器状态、版本状态、审核工作流状态和可见性覆盖层
- 前端不再从 `status + hidden + latestVersionStatus + viewingVersionStatus` 拼装状态,而统一消费后端 lifecycle projection
- destructive action 和 reversible action 必须分离;`withdraw-review` 只表示撤回提审,不表示删除版本
- 对外仍可保留 `latest` 协议词汇,但内部语义必须严格等价于 latest published
## 2. 状态模型
### 2.1 Skill 容器状态
- `ACTIVE`
- `ARCHIVED`
说明:
- `hidden` 是独立治理覆盖层,不属于 `Skill.status`
- `SkillStatus.HIDDEN` 不再视为有效生命周期语义
### 2.2 SkillVersion 版本状态
- `DRAFT`
- `PENDING_REVIEW`
- `PUBLISHED`
- `REJECTED`
- `YANKED`
状态含义:
- `DRAFT`:可再次提交审核或删除的非公开版本
- `PENDING_REVIEW`:冻结待审版本
- `PUBLISHED`:当前可分发版本
- `REJECTED`:审核拒绝后保留的版本
- `YANKED`:曾发布、现已撤回分发的版本
### 2.3 ReviewTask 审核工作流状态
- `PENDING`
- `APPROVED`
- `REJECTED`
`ReviewTask` 仅表达审核流程,不再被前端当作展示态来源。
## 3. 核心语义
### 3.1 Latest
- `Skill.latestVersionId` 的唯一语义是 latest published pointer
- 它只能指向 `PUBLISHED` 版本
- 若 skill 没有任何已发布版本,则允许为 `null`
- `latest` 系统保留标签自动跟随该指针
### 3.2 Lifecycle Projection
详情页、我的技能、我的收藏、搜索等读模型统一基于以下 projection
- `headlineVersion`:当前页面主展示版本
- `publishedVersion`:最新已发布版本
- `ownerPreviewVersion`owner / namespace 管理者可见的待审核预览版本
- `resolutionMode``PUBLISHED` / `OWNER_PREVIEW` / `NONE`
约束:
- 公开浏览、安装、下载、搜索只认 `publishedVersion`
- owner 详情页只有在不存在 `publishedVersion` 时,才允许 `headlineVersion = ownerPreviewVersion`
- promotion、compat latest、默认下载等公开分发行为都只能绑定 `publishedVersion`
## 4. 代码实际链路
### 4.1 首次上传
- 普通用户上传后直接创建 `PENDING_REVIEW` 版本
- 同时创建 `PENDING` review task
- 不会创建初始 `DRAFT`
- 不会更新 `latestVersionId`
### 4.2 审核通过
- `PENDING_REVIEW -> PUBLISHED`
- review task 标记为 `APPROVED`
- `Skill.latestVersionId` 指向该版本
- skill 展示元数据从发布版本刷新
### 4.3 审核拒绝
- `PENDING_REVIEW -> REJECTED`
- review task 标记为 `REJECTED`
- 版本保留,可后续删除
### 4.4 撤回审核
- `withdraw-review` 的统一语义是 `PENDING_REVIEW -> DRAFT`
- 同时删除关联的 `PENDING review_task`
- 该操作是可逆、非破坏性的
- 当前代码只允许提交人本人撤回
### 4.5 重传新版本
- 若发现旧的 `PENDING_REVIEW` 版本,会先把旧版本自动降回 `DRAFT`
- 然后创建新的待审版本
- 自动撤回与手动撤回必须保持同一语义
### 4.6 已发布版本重发
- rerelease 当前本质上是从已发布版本复制并重新走发布流程
- 当前实现允许特权路径直接产出新 `PUBLISHED` 版本
- 该能力应被理解为发布路径特例,不是生命周期展示态
### 4.7 隐藏 / 恢复 / 归档 / 撤回已发布版本
- 隐藏:只改 `hidden=true`
- 恢复:只改 `hidden=false`
- 归档:`Skill.status = ARCHIVED`
- 取消归档:`Skill.status = ACTIVE`
- yank`PUBLISHED -> YANKED`
### 4.8 Yank 后指针修正
- yank 已发布版本时,若命中当前 `latestVersionId`,必须重算 latest published pointer
- 若仍有其他 `PUBLISHED` 版本,则指向最新一个
- 若已无任何 `PUBLISHED` 版本,则 `latestVersionId = null`
## 5. 对外协议约束
### 5.1 Public / Search / Compat
- 对外协议可以继续暴露 `latestVersion``latest`、默认下载等概念
- 但它们都必须严格表示“最新已发布版本”
- compat 层内部实现必须从统一 lifecycle projection 的 `publishedVersion` 映射,不允许自行推导“当前版本”
### 5.2 Frontend
- 页面状态展示统一消费 projection
- 不再新增旧兼容字段依赖
- `hidden` 仅作为治理标记展示,不参与版本状态拼装
## 6. 权限边界
- `withdraw-review`:仅提交人本人
- 删除版本owner 或 namespace 管理者,且仅限 `DRAFT` / `REJECTED`
- 归档 / 取消归档owner 或 namespace 管理者
- 隐藏 / 恢复技能、撤回已发布版本:平台技能治理权限
## 7. 当前最终约束
- 一个 skill 生命周期的唯一规范入口就是本文件
- 其它文档如 `02-domain-model``05-business-flows``06-api-design``08-frontend-architecture` 必须与本文件保持一致
- 若后续代码再次改变生命周期语义,应先修改代码,再同步更新本文件和相关子文档

View file

@ -56,7 +56,7 @@ npx clawhub info my-skill
### 4. Install Skills
```bash
# Install latest version
# Install latest published version
npx clawhub install my-skill
# Install specific version
@ -89,6 +89,10 @@ SkillHub compatibility layer provides the following endpoints:
| `/api/v1/skills/{slug}/unstar` | DELETE | Unstar a skill | Yes |
| `/api/v1/publish` | POST | Publish a skill | Yes |
Notes:
- The compatibility layer may still expose the term "latest" externally, but it must strictly mean "latest published version"
- Internally, compat responses should map from the unified lifecycle projection's `publishedVersion` rather than inferring an ad hoc "current version"
\* Download endpoint authentication requirements:
- **Global namespace (@global) PUBLIC skills**: No authentication required
- **All team namespace skills**: Authentication required

View file

@ -56,7 +56,7 @@ npx clawhub info my-skill
### 4. 安装技能
```bash
# 安装最新版本
# 安装最新已发布版本
npx clawhub install my-skill
# 安装指定版本
@ -89,6 +89,10 @@ SkillHub 兼容层提供以下端点:
| `/api/v1/skills/{slug}/unstar` | DELETE | 取消收藏 | 必需 |
| `/api/v1/publish` | POST | 发布技能 | 必需 |
说明:
- 兼容层对外继续使用 “latest” 语义,但这里严格指向“最新已发布版本”
- 兼容层内部实现应从统一 lifecycle projection 的 `publishedVersion` 映射,而不是自行推导“当前版本”
\* 下载端点认证要求:
- **全局命名空间(@global的 PUBLIC 技能**:无需认证
- **团队命名空间的所有技能**:需要认证

View file

@ -119,7 +119,7 @@ public class ClawHubCompatController {
mapper.toCanonical(item.namespace(), item.slug()),
item.displayName(),
item.summary(),
item.latestVersion(),
item.publishedVersion() != null ? item.publishedVersion().version() : null,
calculateScore(item),
updatedAtEpoch
);
@ -268,9 +268,9 @@ public class ClawHubCompatController {
: 0;
ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null;
if (item.latestVersion() != null) {
if (item.publishedVersion() != null) {
latestVersion = new ClawHubSkillListResponse.SkillListItem.LatestVersion(
item.latestVersion(),
item.publishedVersion().version(),
updatedAt, // Use skill's updatedAt as version createdAt
"", // changelog not available in summary
null // license not available in summary

View file

@ -95,7 +95,7 @@ public class ClawHubRegistryFacade {
toEpochMillis(skill.getUpdatedAt())
);
ClawHubRegistrySkillVersion latestVersion = buildLatestVersion(skill, detail.latestVersion());
ClawHubRegistrySkillVersion latestVersion = buildLatestVersion(skill, detail.publishedVersion());
ClawHubRegistryOwner owner = buildOwner(skill.getOwnerId());
return new ClawHubRegistrySkillResponse(
@ -136,20 +136,22 @@ public class ClawHubRegistryFacade {
canonicalSlug,
normalizeDisplayName(item.displayName(), canonicalSlug),
item.summary(),
item.latestVersion(),
item.publishedVersion() != null ? item.publishedVersion().version() : null,
scoreFor(index),
toEpochMillis(item.updatedAt())
);
}
private ClawHubRegistrySkillVersion buildLatestVersion(Skill skill, String version) {
if (version == null || version.isBlank()) {
private ClawHubRegistrySkillVersion buildLatestVersion(
Skill skill,
com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection projection) {
if (projection == null || projection.version() == null || projection.version().isBlank()) {
return null;
}
Optional<SkillVersion> latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version);
Optional<SkillVersion> latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), projection.version());
if (latestVersion.isEmpty()) {
return new ClawHubRegistrySkillVersion(version, 0L, "", null);
return new ClawHubRegistrySkillVersion(projection.version(), 0L, "", null);
}
SkillVersion entity = latestVersion.get();

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
@ -12,6 +13,7 @@ import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ResolveVersionResponse;
import com.iflytek.skillhub.dto.SkillDetailResponse;
import com.iflytek.skillhub.dto.SkillFileResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillVersionDetailResponse;
import com.iflytek.skillhub.dto.SkillVersionResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
@ -69,14 +71,15 @@ public class SkillController extends BaseApiController {
detail.ratingAvg(),
detail.ratingCount(),
detail.hidden(),
detail.latestVersion(),
detail.latestVersionId(),
namespace,
detail.canManageLifecycle(),
detail.canSubmitPromotion(),
detail.viewingVersionStatus(),
detail.canInteract(),
detail.canReport()
detail.canReport(),
toLifecycleVersion(detail.headlineVersion()),
toLifecycleVersion(detail.publishedVersion()),
toLifecycleVersion(detail.ownerPreviewVersion()),
detail.resolutionMode()
);
return ok("response.success.read", response);
@ -362,4 +365,11 @@ public class SkillController extends BaseApiController {
}
return request.isSecure();
}
private SkillLifecycleVersionResponse toLifecycleVersion(SkillLifecycleProjectionService.VersionProjection projection) {
if (projection == null) {
return null;
}
return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status());
}
}

View file

@ -131,7 +131,7 @@ public class SkillLifecycleController extends BaseApiController {
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
reviewService.withdrawReview(skillVersion.getId(), userId);
SkillVersion withdrawnVersion = reviewService.withdrawReview(skillVersion.getId(), userId);
auditLogService.record(
userId,
"REVIEW_WITHDRAW",
@ -144,7 +144,7 @@ public class SkillLifecycleController extends BaseApiController {
);
return ok("response.success.updated",
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", "DELETED"));
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", withdrawnVersion.getStatus().name()));
}
@PostMapping("/{namespace}/{slug}/versions/{version}/rerelease")

View file

@ -14,12 +14,13 @@ public record SkillDetailResponse(
BigDecimal ratingAvg,
Integer ratingCount,
boolean hidden,
String latestVersion,
Long latestVersionId,
String namespace,
boolean canManageLifecycle,
boolean canSubmitPromotion,
String viewingVersionStatus,
boolean canInteract,
boolean canReport
boolean canReport,
SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode
) {}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
public record SkillLifecycleVersionResponse(
Long id,
String version,
String status
) {}

View file

@ -13,10 +13,11 @@ public record SkillSummaryResponse(
Integer starCount,
BigDecimal ratingAvg,
Integer ratingCount,
String latestVersion,
Long latestVersionId,
String latestVersionStatus,
String namespace,
LocalDateTime updatedAt,
boolean canSubmitPromotion
boolean canSubmitPromotion,
SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode
) {}

View file

@ -10,8 +10,10 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@ -30,26 +32,27 @@ public class MySkillAppService {
private final SkillVersionRepository skillVersionRepository;
private final SkillStarRepository skillStarRepository;
private final PromotionRequestRepository promotionRequestRepository;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
public MySkillAppService(
SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
SkillStarRepository skillStarRepository,
PromotionRequestRepository promotionRequestRepository) {
PromotionRequestRepository promotionRequestRepository,
SkillLifecycleProjectionService skillLifecycleProjectionService) {
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillStarRepository = skillStarRepository;
this.promotionRequestRepository = promotionRequestRepository;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
}
public PageResponse<SkillSummaryResponse> listMySkills(String userId, int page, int size) {
Page<Skill> skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
List<Skill> skills = skillPage.getContent();
Map<Long, SkillVersion> versionsBySkillId = loadLatestRelevantVersions(skills);
List<Long> namespaceIds = skills.stream()
.map(Skill::getNamespaceId)
.distinct()
@ -60,7 +63,7 @@ public class MySkillAppService {
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));
List<SkillSummaryResponse> items = skills.stream()
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
.map(skill -> toSummaryResponse(skill, userId, namespacesById))
.toList();
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
@ -82,8 +85,6 @@ public class MySkillAppService {
: skillRepository.findByIdIn(skillIds).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
Map<Long, SkillVersion> versionsBySkillId = loadLatestRelevantVersions(skillsById.values());
List<Long> namespaceIds = skillsById.values().stream()
.map(Skill::getNamespaceId)
.distinct()
@ -96,7 +97,7 @@ public class MySkillAppService {
List<SkillSummaryResponse> items = stars.stream()
.map(star -> skillsById.get(star.getSkillId()))
.filter(java.util.Objects::nonNull)
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
.map(skill -> toSummaryResponse(skill, userId, namespacesById))
.toList();
return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize());
@ -104,10 +105,17 @@ public class MySkillAppService {
private SkillSummaryResponse toSummaryResponse(
Skill skill,
Map<Long, SkillVersion> versionsBySkillId,
String currentUserId,
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById) {
SkillVersion latestVersion = versionsBySkillId.get(skill.getId());
com.iflytek.skillhub.domain.namespace.Namespace namespace = namespacesById.get(skill.getNamespaceId());
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer(
skill,
currentUserId,
Map.of()
);
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion();
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion();
return new SkillSummaryResponse(
skill.getId(),
@ -119,18 +127,19 @@ public class MySkillAppService {
skill.getStarCount(),
skill.getRatingAvg(),
skill.getRatingCount(),
Optional.ofNullable(latestVersion).map(SkillVersion::getVersion).orElse(null),
Optional.ofNullable(latestVersion).map(SkillVersion::getId).orElse(null),
Optional.ofNullable(latestVersion).map(SkillVersion::getStatus).map(Enum::name).orElse(null),
namespace != null ? namespace.getSlug() : null,
skill.getUpdatedAt(),
canSubmitPromotion(skill, latestVersion, namespace)
canSubmitPromotion(skill, publishedVersion, namespace),
toLifecycleVersion(headlineVersion),
toLifecycleVersion(publishedVersion),
toLifecycleVersion(ownerPreviewVersion),
projection.resolutionMode().name()
);
}
private boolean canSubmitPromotion(
Skill skill,
SkillVersion latestVersion,
SkillLifecycleProjectionService.VersionProjection publishedVersion,
com.iflytek.skillhub.domain.namespace.Namespace namespace) {
if (namespace == null) {
return false;
@ -147,48 +156,13 @@ public class MySkillAppService {
if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) {
return false;
}
return latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED;
return publishedVersion != null && "PUBLISHED".equals(publishedVersion.status());
}
private Map<Long, SkillVersion> loadLatestRelevantVersions(java.util.Collection<Skill> skills) {
if (skills.isEmpty()) {
return Map.of();
private SkillLifecycleVersionResponse toLifecycleVersion(SkillLifecycleProjectionService.VersionProjection projection) {
if (projection == null) {
return null;
}
List<Long> explicitLatestVersionIds = skills.stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = explicitLatestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(explicitLatestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> skillIdsNeedingFallback = skills.stream()
.filter(skill -> skill.getLatestVersionId() == null || !versionsById.containsKey(skill.getLatestVersionId()))
.map(Skill::getId)
.distinct()
.toList();
Map<Long, SkillVersion> fallbackBySkillId = skillIdsNeedingFallback.isEmpty()
? Map.of()
: skillVersionRepository.findBySkillIdIn(skillIdsNeedingFallback).stream()
.filter(version -> version.getStatus() != SkillVersionStatus.YANKED)
.collect(Collectors.toMap(
SkillVersion::getSkillId,
Function.identity(),
(left, right) -> left.getCreatedAt().isAfter(right.getCreatedAt()) ? left : right
));
Map<Long, SkillVersion> resolvedVersions = new java.util.HashMap<>();
for (Skill skill : skills) {
SkillVersion resolvedVersion = skill.getLatestVersionId() != null
? versionsById.get(skill.getLatestVersionId())
: fallbackBySkillId.get(skill.getId());
if (resolvedVersion != null) {
resolvedVersions.put(skill.getId(), resolvedVersion);
}
}
return resolvedVersions;
return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status());
}
}

View file

@ -8,8 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.search.SearchQuery;
@ -30,23 +29,23 @@ public class SkillSearchAppService {
private final SearchQueryService searchQueryService;
private final SkillRepository skillRepository;
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
private final NamespaceService namespaceService;
private final VisibilityChecker visibilityChecker;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
public SkillSearchAppService(
SearchQueryService searchQueryService,
SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
NamespaceService namespaceService,
VisibilityChecker visibilityChecker) {
VisibilityChecker visibilityChecker,
SkillLifecycleProjectionService skillLifecycleProjectionService) {
this.searchQueryService = searchQueryService;
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceService = namespaceService;
this.visibilityChecker = visibilityChecker;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
}
public record SearchResponse(
@ -151,16 +150,6 @@ public class SkillSearchAppService {
Map<Long, Skill> skillsById = matchedSkills.stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> latestVersionIds = matchedSkills.stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> namespaceIds = matchedSkills.stream()
.map(Skill::getNamespaceId)
.distinct()
@ -177,19 +166,18 @@ public class SkillSearchAppService {
.filter(java.util.Objects::nonNull)
.filter(skill -> visibilityChecker.canAccess(skill, userId, userNsRoles != null ? userNsRoles : Map.of()))
.filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles))
.map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById))
.map(skill -> toSummaryResponse(skill, namespaceSlugsById))
.toList();
}
private SkillSummaryResponse toSummaryResponse(
Skill skill,
Map<Long, SkillVersion> versionsById,
Map<Long, String> namespaceSlugsById) {
String latestVersion = skill.getLatestVersionId() == null
? null
: java.util.Optional.ofNullable(versionsById.get(skill.getLatestVersionId()))
.map(SkillVersion::getVersion)
.orElse(null);
SkillLifecycleProjectionService.Projection projection = skillLifecycleProjectionService.projectForViewer(
skill,
null,
Map.of()
);
String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId());
return new SkillSummaryResponse(
@ -202,12 +190,25 @@ public class SkillSearchAppService {
skill.getStarCount(),
skill.getRatingAvg(),
skill.getRatingCount(),
latestVersion,
skill.getLatestVersionId(),
latestVersion == null ? null : "PUBLISHED",
namespaceSlug,
skill.getUpdatedAt(),
false
false,
toLifecycleVersion(projection.headlineVersion()),
toLifecycleVersion(projection.publishedVersion()),
toLifecycleVersion(projection.ownerPreviewVersion()),
projection.resolutionMode().name()
);
}
private com.iflytek.skillhub.dto.SkillLifecycleVersionResponse toLifecycleVersion(
SkillLifecycleProjectionService.VersionProjection projection) {
if (projection == null) {
return null;
}
return new com.iflytek.skillhub.dto.SkillLifecycleVersionResponse(
projection.id(),
projection.version(),
projection.status()
);
}

View file

@ -0,0 +1,8 @@
CREATE TABLE skill_version_stats (
skill_version_id BIGINT PRIMARY KEY REFERENCES skill_version(id) ON DELETE CASCADE,
skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
download_count BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_skill_version_stats_skill_id ON skill_version_stats(skill_id);

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillSearchAppService;
import org.junit.jupiter.api.Test;
@ -61,12 +62,13 @@ class ClawHubCompatControllerTest {
5,
BigDecimal.valueOf(4.5),
2,
"1.2.0",
11L,
"PUBLISHED",
"global",
LocalDateTime.of(2026, 3, 13, 9, 0),
false)),
false,
new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"),
new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"),
null,
"PUBLISHED")),
1,
0,
20

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.MySkillAppService;
import org.junit.jupiter.api.Test;
@ -67,12 +68,13 @@ class MeControllerTest {
3,
null,
0,
"1.0.0",
11L,
"PUBLISHED",
"team-ai",
LocalDateTime.of(2026, 3, 17, 12, 0),
false
false,
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"),
null,
"PUBLISHED"
)),
9,
1,

View file

@ -123,26 +123,26 @@ class SkillControllerTest {
null,
0,
false,
"1.1.0",
1L,
LocalDateTime.of(2026, 3, 15, 10, 0),
LocalDateTime.of(2026, 3, 15, 10, 0),
null,
11L,
true,
false,
"PENDING_REVIEW",
false,
false
false,
new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"),
null,
new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"),
"OWNER_PREVIEW"
));
mockMvc.perform(get("/api/web/skills/team/demo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.latestVersion").value("1.1.0"))
.andExpect(jsonPath("$.data.latestVersionId").value(11L))
.andExpect(jsonPath("$.data.canSubmitPromotion").value(false))
.andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW"))
.andExpect(jsonPath("$.data.headlineVersion.version").value("1.1.0"))
.andExpect(jsonPath("$.data.ownerPreviewVersion.id").value(11L))
.andExpect(jsonPath("$.data.resolutionMode").value("OWNER_PREVIEW"))
.andExpect(jsonPath("$.data.canInteract").value(false))
.andExpect(jsonPath("$.data.canReport").value(false));
}

View file

@ -172,6 +172,10 @@ class SkillLifecycleControllerTest {
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
.willReturn(skill);
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
SkillVersion withdrawn = new SkillVersion(1L, "1.0.0", "owner");
setSkillVersionId(withdrawn, 2L);
withdrawn.setStatus(SkillVersionStatus.DRAFT);
given(reviewService.withdrawReview(2L, "usr_1")).willReturn(withdrawn);
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review")
.requestAttr("userId", "usr_1")
@ -182,7 +186,7 @@ class SkillLifecycleControllerTest {
.andExpect(jsonPath("$.data.skillId").value(1))
.andExpect(jsonPath("$.data.versionId").value(2))
.andExpect(jsonPath("$.data.action").value("WITHDRAW_REVIEW"))
.andExpect(jsonPath("$.data.status").value("DELETED"));
.andExpect(jsonPath("$.data.status").value("DRAFT"));
}
@Test

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.social.SkillStar;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import org.junit.jupiter.api.BeforeEach;
@ -50,15 +51,18 @@ class MySkillAppServiceTest {
private PromotionRequestRepository promotionRequestRepository;
private MySkillAppService service;
private SkillLifecycleProjectionService skillLifecycleProjectionService;
@BeforeEach
void setUp() {
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
service = new MySkillAppService(
skillRepository,
namespaceRepository,
skillVersionRepository,
skillStarRepository,
promotionRequestRepository
promotionRequestRepository,
skillLifecycleProjectionService
);
}
@ -89,7 +93,7 @@ class MySkillAppServiceTest {
ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0));
given(skillRepository.findByIdIn(List.of(2L))).willReturn(List.of(secondSkill));
given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of());
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of());
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
var stars = service.listMyStars("user-1", 1, 1);
@ -115,15 +119,18 @@ class MySkillAppServiceTest {
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdIn(List.of(1L))).willReturn(List.of(pendingVersion));
given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of());
given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).willReturn(List.of(pendingVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
var skills = service.listMySkills("user-1", 0, 10);
assertThat(skills.items()).hasSize(1);
assertThat(skills.items().get(0).latestVersion()).isEqualTo("1.0.0");
assertThat(skills.items().get(0).latestVersionId()).isEqualTo(11L);
assertThat(skills.items().get(0).latestVersionStatus()).isEqualTo("PENDING_REVIEW");
assertThat(skills.items().get(0).headlineVersion()).isNotNull();
assertThat(skills.items().get(0).headlineVersion().version()).isEqualTo("1.0.0");
assertThat(skills.items().get(0).headlineVersion().status()).isEqualTo("PENDING_REVIEW");
assertThat(skills.items().get(0).ownerPreviewVersion()).isNotNull();
assertThat(skills.items().get(0).ownerPreviewVersion().id()).isEqualTo(11L);
assertThat(skills.items().get(0).canSubmitPromotion()).isFalse();
}
@ -145,7 +152,7 @@ class MySkillAppServiceTest {
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty());
given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty());
@ -153,8 +160,10 @@ class MySkillAppServiceTest {
var skills = service.listMySkills("user-1", 0, 10);
assertThat(skills.items()).hasSize(1);
assertThat(skills.items().get(0).latestVersionId()).isEqualTo(22L);
assertThat(skills.items().get(0).latestVersionStatus()).isEqualTo("PUBLISHED");
assertThat(skills.items().get(0).publishedVersion()).isNotNull();
assertThat(skills.items().get(0).publishedVersion().id()).isEqualTo(22L);
assertThat(skills.items().get(0).headlineVersion()).isNotNull();
assertThat(skills.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED");
assertThat(skills.items().get(0).canSubmitPromotion()).isTrue();
}
@ -174,7 +183,7 @@ class MySkillAppServiceTest {
given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10)))
.willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1));
given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING))
.willReturn(Optional.of(new PromotionRequest(2L, 22L, 999L, "user-1")));

View file

@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.search.SearchQueryService;
import com.iflytek.skillhub.search.SearchResult;
import org.junit.jupiter.api.BeforeEach;
@ -52,9 +53,9 @@ class SkillSearchAppServiceTest {
searchQueryService,
skillRepository,
namespaceRepository,
skillVersionRepository,
namespaceService,
new VisibilityChecker()
new VisibilityChecker(),
new SkillLifecycleProjectionService(skillVersionRepository)
);
}

View file

@ -241,7 +241,7 @@ public class ReviewService {
}
@Transactional
public void withdrawReview(Long skillVersionId, String userId) {
public SkillVersion withdrawReview(Long skillVersionId, String userId) {
ReviewTask task = reviewTaskRepository.findBySkillVersionIdAndStatus(
skillVersionId, ReviewTaskStatus.PENDING)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found_for_version", skillVersionId));
@ -259,7 +259,7 @@ public class ReviewService {
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId()));
assertNamespaceActive(namespace);
skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId);
return skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId);
}
public boolean canReviewNamespace(ReviewTask task,

View file

@ -0,0 +1,61 @@
package com.iflytek.skillhub.domain.skill;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "skill_version_stats")
public class SkillVersionStats {
@Id
@Column(name = "skill_version_id", nullable = false)
private Long skillVersionId;
@Column(name = "skill_id", nullable = false)
private Long skillId;
@Column(name = "download_count", nullable = false)
private Long downloadCount = 0L;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected SkillVersionStats() {
}
public SkillVersionStats(Long skillVersionId, Long skillId) {
this.skillVersionId = skillVersionId;
this.skillId = skillId;
}
@PrePersist
protected void onCreate() {
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getSkillVersionId() {
return skillVersionId;
}
public Long getSkillId() {
return skillId;
}
public Long getDownloadCount() {
return downloadCount;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.skill;
import java.util.Optional;
public interface SkillVersionStatsRepository {
Optional<SkillVersionStats> findBySkillVersionId(Long skillVersionId);
void incrementDownloadCount(Long skillVersionId, Long skillId);
}

View file

@ -29,6 +29,7 @@ public class SkillDownloadService {
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillVersionStatsRepository skillVersionStatsRepository;
private final SkillFileRepository skillFileRepository;
private final SkillTagRepository skillTagRepository;
private final ObjectStorageService objectStorageService;
@ -40,6 +41,7 @@ public class SkillDownloadService {
NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillVersionStatsRepository skillVersionStatsRepository,
SkillFileRepository skillFileRepository,
SkillTagRepository skillTagRepository,
ObjectStorageService objectStorageService,
@ -49,6 +51,7 @@ public class SkillDownloadService {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillVersionStatsRepository = skillVersionStatsRepository;
this.skillFileRepository = skillFileRepository;
this.skillTagRepository = skillTagRepository;
this.objectStorageService = objectStorageService;
@ -144,6 +147,7 @@ public class SkillDownloadService {
}
skillRepository.incrementDownloadCount(skill.getId());
skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId());
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
return result;
}

View file

@ -161,51 +161,52 @@ public class SkillGovernanceService {
}
@Transactional
public boolean withdrawPendingVersion(Skill skill,
SkillVersion version,
String actorUserId) {
public SkillVersion withdrawPendingVersion(Skill skill,
SkillVersion version,
String actorUserId) {
if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("review.withdraw.not_pending", version.getId());
}
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
if (!files.isEmpty()) {
objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList());
}
objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()));
skillFileRepository.deleteByVersionId(version.getId());
skillVersionRepository.delete(version);
List<SkillVersion> remainingVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(existing -> !existing.getId().equals(version.getId()))
.toList();
if (remainingVersions.isEmpty()) {
skillRepository.delete(skill);
return true;
}
if (version.getId().equals(skill.getLatestVersionId())) {
skill.setLatestVersionId(null);
}
version.setStatus(SkillVersionStatus.DRAFT);
SkillVersion savedVersion = skillVersionRepository.save(version);
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
return false;
return savedVersion;
}
@Transactional
public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) {
SkillVersion version = skillVersionRepository.findById(versionId)
.orElseThrow(() -> new DomainNotFoundException("error.skill.version.notFound", versionId));
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion());
}
version.setStatus(SkillVersionStatus.YANKED);
version.setYankedAt(LocalDateTime.now());
version.setYankedBy(actorUserId);
version.setYankReason(reason);
SkillVersion saved = skillVersionRepository.save(version);
skillRepository.findById(version.getSkillId()).ifPresent(skill -> {
if (versionId.equals(skill.getLatestVersionId())) {
skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId()));
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
}
});
auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason));
return saved;
}
private Long findLatestPublishedVersionId(Long skillId) {
return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream()
.max(java.util.Comparator
.comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())))
.map(SkillVersion::getId)
.orElse(null);
}
private void assertCanManageLifecycle(Skill skill,
String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles) {

View file

@ -0,0 +1,114 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class SkillLifecycleProjectionService {
public enum ResolutionMode {
PUBLISHED,
OWNER_PREVIEW,
NONE
}
public record VersionProjection(
Long id,
String version,
String status
) {}
public record Projection(
VersionProjection headlineVersion,
VersionProjection publishedVersion,
VersionProjection ownerPreviewVersion,
ResolutionMode resolutionMode
) {}
private final SkillVersionRepository skillVersionRepository;
public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) {
this.skillVersionRepository = skillVersionRepository;
}
public Projection projectForViewer(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill));
VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles));
VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion;
ResolutionMode resolutionMode = headlineVersion == null
? ResolutionMode.NONE
: publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW;
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
}
public Projection projectForOwnerSummary(Skill skill) {
VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill));
VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill));
VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion;
ResolutionMode resolutionMode = headlineVersion == null
? ResolutionMode.NONE
: publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW;
return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode);
}
private SkillVersion resolvePublishedVersion(Skill skill) {
if (skill.getLatestVersionId() != null) {
SkillVersion latest = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null);
if (latest != null && latest.getStatus() == SkillVersionStatus.PUBLISHED) {
return latest;
}
}
return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED).stream()
.max(versionComparator())
.orElse(null);
}
private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (!canManage(skill, currentUserId, userNsRoles)) {
return null;
}
return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW).stream()
.max(versionComparator())
.orElse(null);
}
private SkillVersion resolveNewestNonPublishedVersion(Skill skill) {
List<SkillVersion> versions = skillVersionRepository.findBySkillId(skill.getId());
return versions.stream()
.filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED
&& version.getStatus() != SkillVersionStatus.YANKED)
.max(versionComparator())
.orElse(null);
}
private boolean canManage(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (currentUserId == null) {
return false;
}
NamespaceRole role = userNsRoles.get(skill.getNamespaceId());
return skill.getOwnerId().equals(currentUserId)
|| role == NamespaceRole.ADMIN
|| role == NamespaceRole.OWNER;
}
private Comparator<SkillVersion> versionComparator() {
return Comparator
.comparing(SkillVersion::getPublishedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()));
}
private VersionProjection toProjection(SkillVersion version) {
if (version == null) {
return null;
}
return new VersionProjection(version.getId(), version.getVersion(), version.getStatus().name());
}
}

View file

@ -41,6 +41,7 @@ public class SkillQueryService {
private final VisibilityChecker visibilityChecker;
private final PromotionRequestRepository promotionRequestRepository;
private final SkillSlugResolutionService skillSlugResolutionService;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
public SkillQueryService(
NamespaceRepository namespaceRepository,
@ -51,7 +52,8 @@ public class SkillQueryService {
ObjectStorageService objectStorageService,
VisibilityChecker visibilityChecker,
PromotionRequestRepository promotionRequestRepository,
SkillSlugResolutionService skillSlugResolutionService) {
SkillSlugResolutionService skillSlugResolutionService,
SkillLifecycleProjectionService skillLifecycleProjectionService) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
@ -61,6 +63,7 @@ public class SkillQueryService {
this.visibilityChecker = visibilityChecker;
this.promotionRequestRepository = promotionRequestRepository;
this.skillSlugResolutionService = skillSlugResolutionService;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
}
public record SkillDetailDTO(
@ -75,17 +78,17 @@ public class SkillQueryService {
java.math.BigDecimal ratingAvg,
Integer ratingCount,
boolean hidden,
String latestVersion,
Long namespaceId,
java.time.LocalDateTime createdAt,
java.time.LocalDateTime updatedAt,
SkillVersion latestVersionEntity,
Long latestVersionId,
boolean canManageLifecycle,
boolean canSubmitPromotion,
String viewingVersionStatus,
boolean canInteract,
boolean canReport
boolean canReport,
SkillLifecycleProjectionService.VersionProjection headlineVersion,
SkillLifecycleProjectionService.VersionProjection publishedVersion,
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion,
String resolutionMode
) {}
public record SkillVersionDetailDTO(
@ -125,11 +128,11 @@ public class SkillQueryService {
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
String latestVersion = null;
SkillVersion latestVersionEntity = resolvePreviewVersion(skill, currentUserId);
if (latestVersionEntity != null) {
latestVersion = latestVersionEntity.getVersion();
}
SkillLifecycleProjectionService.Projection projection =
skillLifecycleProjectionService.projectForViewer(skill, currentUserId, userNsRoles);
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion();
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion();
return new SkillDetailDTO(
skill.getId(),
@ -143,17 +146,17 @@ public class SkillQueryService {
skill.getRatingAvg(),
skill.getRatingCount(),
skill.isHidden(),
latestVersion,
skill.getNamespaceId(),
skill.getCreatedAt(),
skill.getUpdatedAt(),
latestVersionEntity,
latestVersionEntity != null ? latestVersionEntity.getId() : null,
canManageRestrictedSkill(skill, currentUserId, userNsRoles),
canSubmitPromotion(namespace, skill, latestVersionEntity, currentUserId, userNsRoles),
latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null,
latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED,
currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId)
canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles),
headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()),
currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId),
headlineVersion,
publishedVersion,
ownerPreviewVersion,
projection.resolutionMode().name()
);
}
@ -454,28 +457,6 @@ public class SkillQueryService {
return latestVersion;
}
private SkillVersion resolvePreviewVersion(Skill skill, String currentUserId) {
SkillVersion publishedVersion = null;
if (skill.getLatestVersionId() != null) {
publishedVersion = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null);
}
if (publishedVersion != null) {
return publishedVersion;
}
return resolveOwnerPendingPreview(skill, currentUserId);
}
private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId) {
if (!isOwner(skill, currentUserId)) {
return null;
}
return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW).stream()
.max(Comparator
.comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())))
.orElse(null);
}
private String computeFingerprint(SkillVersion version) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@ -528,7 +509,7 @@ public class SkillQueryService {
private boolean canSubmitPromotion(
Namespace namespace,
Skill skill,
SkillVersion latestVersionEntity,
SkillLifecycleProjectionService.VersionProjection publishedVersion,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
if (namespace.getType() == NamespaceType.GLOBAL) {
@ -537,7 +518,7 @@ public class SkillQueryService {
if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE) {
return false;
}
if (latestVersionEntity == null || latestVersionEntity.getStatus() != SkillVersionStatus.PUBLISHED) {
if (publishedVersion == null || !"PUBLISHED".equals(publishedVersion.status())) {
return false;
}
if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) {

View file

@ -527,16 +527,18 @@ class ReviewServiceTest {
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
Namespace namespace = createTeamNamespace();
SkillVersion withdrawn = createDraftSkillVersion();
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false);
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(withdrawn);
reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
SkillVersion result = reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
assertEquals(SkillVersionStatus.DRAFT, result.getStatus());
verify(reviewTaskRepository).delete(task);
verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID);
}
@ -580,42 +582,46 @@ class ReviewServiceTest {
}
@Test
void shouldDeleteEntireSkillWhenOnlyPendingVersionExists() {
void shouldReturnDraftVersionWhenOnlyPendingVersionExists() {
ReviewTask task = createPendingReviewTask();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
Namespace namespace = createTeamNamespace();
SkillVersion withdrawn = createDraftSkillVersion();
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(true);
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(withdrawn);
reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
SkillVersion result = reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
assertEquals(SkillVersionStatus.DRAFT, result.getStatus());
verify(reviewTaskRepository).delete(task);
verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID);
}
@Test
void shouldDeletePendingVersionAndKeepSkillWhenPublishedHistoryExists() {
void shouldWithdrawPendingVersionAndKeepSkillWhenPublishedHistoryExists() {
ReviewTask task = createPendingReviewTask();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
Namespace namespace = createTeamNamespace();
setField(skill, "latestVersionId", 99L);
SkillVersion withdrawn = createDraftSkillVersion();
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false);
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(withdrawn);
reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
SkillVersion result = reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
assertEquals(SkillVersionStatus.DRAFT, result.getStatus());
verify(reviewTaskRepository).delete(task);
verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID);
}

View file

@ -41,6 +41,8 @@ class SkillDownloadServiceTest {
@Mock
private SkillVersionRepository skillVersionRepository;
@Mock
private SkillVersionStatsRepository skillVersionStatsRepository;
@Mock
private SkillFileRepository skillFileRepository;
@Mock
private SkillTagRepository skillTagRepository;
@ -61,6 +63,7 @@ class SkillDownloadServiceTest {
namespaceRepository,
skillRepository,
skillVersionRepository,
skillVersionStatsRepository,
skillFileRepository,
skillTagRepository,
objectStorageService,
@ -111,6 +114,7 @@ class SkillDownloadServiceTest {
assertEquals(1000L, result.contentLength());
assertNotNull(result.content());
verify(skillRepository).incrementDownloadCount(1L);
verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L);
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@ -155,6 +159,7 @@ class SkillDownloadServiceTest {
assertEquals("Test Skill-1.0.0.zip", result.filename());
assertNotNull(result.content());
verify(skillRepository).incrementDownloadCount(1L);
verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L);
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@ -193,6 +198,9 @@ class SkillDownloadServiceTest {
assertEquals("http://minio.local/presigned", result.presignedUrl());
assertNotNull(result.content());
verify(skillRepository).incrementDownloadCount(1L);
verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L);
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
@ -219,6 +227,9 @@ class SkillDownloadServiceTest {
assertThrows(DomainBadRequestException.class, () ->
service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles));
verify(skillRepository, never()).incrementDownloadCount(anyLong());
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
@ -266,6 +277,7 @@ class SkillDownloadServiceTest {
}
verify(skillRepository).incrementDownloadCount(1L);
verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L);
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@ -299,6 +311,9 @@ class SkillDownloadServiceTest {
assertNotNull(result);
assertEquals("Demo Skill-1.0.0.zip", result.filename());
verify(skillRepository).incrementDownloadCount(1L);
verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L);
verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class));
}
@Test
@ -319,6 +334,9 @@ class SkillDownloadServiceTest {
service.downloadVersion("team-ai", "demo-skill", "1.0.0", null, Map.of()));
verify(visibilityChecker, never()).canAccess(any(), any(), anyMap());
verify(skillRepository, never()).incrementDownloadCount(anyLong());
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
}
private void setId(Object entity, Long id) throws Exception {

View file

@ -119,6 +119,7 @@ class SkillGovernanceServiceTest {
version.setStatus(SkillVersionStatus.PUBLISHED);
given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version));
given(skillVersionRepository.save(version)).willReturn(version);
given(skillRepository.findById(2L)).willReturn(Optional.empty());
SkillVersion result = service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken");
@ -127,6 +128,52 @@ class SkillGovernanceServiceTest {
verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}");
}
@Test
void withdrawPendingVersion_demotesVersionToDraft() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 1L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
setField(version, "id", 2L);
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
given(skillVersionRepository.save(version)).willReturn(version);
given(skillRepository.save(skill)).willReturn(skill);
SkillVersion result = service.withdrawPendingVersion(skill, version, "owner");
assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT);
verify(skillVersionRepository).save(version);
verify(skillRepository).save(skill);
verify(objectStorageService, never()).deleteObject(any());
}
@Test
void yankVersion_recomputesLatestPublishedPointer() {
SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner");
setField(yanked, "id", 22L);
yanked.setStatus(SkillVersionStatus.PUBLISHED);
yanked.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 18, 10, 0));
SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner");
setField(fallback, "id", 11L);
fallback.setStatus(SkillVersionStatus.PUBLISHED);
fallback.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 17, 10, 0));
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 2L);
skill.setLatestVersionId(22L);
given(skillVersionRepository.findById(22L)).willReturn(Optional.of(yanked));
given(skillVersionRepository.save(yanked)).willReturn(yanked);
given(skillRepository.findById(2L)).willReturn(Optional.of(skill));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(java.util.List.of(fallback));
given(skillRepository.save(skill)).willReturn(skill);
service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken");
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
verify(skillRepository).save(skill);
}
@Test
void deleteVersion_removesDraftFilesAndBundle() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);

View file

@ -53,10 +53,12 @@ class SkillQueryServiceTest {
private SkillQueryService service;
private SkillSlugResolutionService skillSlugResolutionService;
private SkillLifecycleProjectionService skillLifecycleProjectionService;
@BeforeEach
void setUp() {
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
service = new SkillQueryService(
namespaceRepository,
skillRepository,
@ -66,7 +68,8 @@ class SkillQueryServiceTest {
objectStorageService,
visibilityChecker,
promotionRequestRepository,
skillSlugResolutionService
skillSlugResolutionService,
skillLifecycleProjectionService
);
}
@ -88,6 +91,7 @@ class SkillQueryServiceTest {
SkillVersion version = new SkillVersion(1L, "1.0.0", userId);
setId(version, 10L);
version.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
@ -101,7 +105,8 @@ class SkillQueryServiceTest {
assertNotNull(result);
assertEquals(skillSlug, result.slug());
assertEquals("Test Skill", result.displayName());
assertEquals("1.0.0", result.latestVersion());
assertNotNull(result.headlineVersion());
assertEquals("1.0.0", result.headlineVersion().version());
assertFalse(result.canReport());
}
@ -127,6 +132,7 @@ class SkillQueryServiceTest {
SkillVersion ownVersion = new SkillVersion(2L, "2.0.0", userId);
setId(ownVersion, 22L);
ownVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
@ -137,7 +143,8 @@ class SkillQueryServiceTest {
assertEquals(2L, result.id());
assertEquals("Own Skill", result.displayName());
assertEquals("2.0.0", result.latestVersion());
assertNotNull(result.headlineVersion());
assertEquals("2.0.0", result.headlineVersion().version());
assertFalse(result.canReport());
}
@ -670,7 +677,8 @@ class SkillQueryServiceTest {
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
assertEquals(11L, result.latestVersionId());
assertNotNull(result.publishedVersion());
assertEquals(11L, result.publishedVersion().id());
assertTrue(result.canSubmitPromotion());
}
@ -790,13 +798,17 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW))
.thenReturn(List.of(pending));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
assertEquals("1.1.0", result.latestVersion());
assertEquals("PENDING_REVIEW", result.viewingVersionStatus());
assertNotNull(result.headlineVersion());
assertEquals("1.1.0", result.headlineVersion().version());
assertEquals("PENDING_REVIEW", result.headlineVersion().status());
assertEquals("OWNER_PREVIEW", result.resolutionMode());
assertFalse(result.canInteract());
}
@ -829,8 +841,10 @@ class SkillQueryServiceTest {
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
assertEquals("1.0.0", result.latestVersion());
assertEquals("PUBLISHED", result.viewingVersionStatus());
assertNotNull(result.headlineVersion());
assertEquals("1.0.0", result.headlineVersion().version());
assertEquals("PUBLISHED", result.headlineVersion().status());
assertEquals("PUBLISHED", result.resolutionMode());
assertTrue(result.canInteract());
}

View file

@ -0,0 +1,35 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.skill.SkillVersionStats;
import com.iflytek.skillhub.domain.skill.SkillVersionStatsRepository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface SkillVersionStatsJpaRepository extends JpaRepository<SkillVersionStats, Long>, SkillVersionStatsRepository {
@Override
default Optional<SkillVersionStats> findBySkillVersionId(Long skillVersionId) {
return findById(skillVersionId);
}
@Override
@Modifying
@Transactional
@Query(
value = """
INSERT INTO skill_version_stats (skill_version_id, skill_id, download_count, updated_at)
VALUES (:skillVersionId, :skillId, 1, CURRENT_TIMESTAMP)
ON CONFLICT (skill_version_id)
DO UPDATE SET download_count = skill_version_stats.download_count + 1,
updated_at = CURRENT_TIMESTAMP
""",
nativeQuery = true
)
void incrementDownloadCount(@Param("skillVersionId") Long skillVersionId, @Param("skillId") Long skillId);
}

View file

@ -1557,6 +1557,7 @@ export interface components {
slug?: string;
displayName?: string;
summary?: string;
status?: string;
/** Format: int64 */
downloadCount?: number;
/** Format: int32 */
@ -1564,10 +1565,14 @@ export interface components {
ratingAvg?: number;
/** Format: int32 */
ratingCount?: number;
latestVersion?: string;
namespace?: string;
/** Format: date-time */
updatedAt?: string;
canSubmitPromotion?: boolean;
headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
resolutionMode?: string;
};
ApiResponseBoolean: {
/** Format: int32 */
@ -1617,8 +1622,21 @@ export interface components {
/** Format: int32 */
ratingCount?: number;
hidden?: boolean;
latestVersion?: string;
namespace?: string;
canManageLifecycle?: boolean;
canSubmitPromotion?: boolean;
canInteract?: boolean;
canReport?: boolean;
headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
resolutionMode?: string;
};
SkillLifecycleVersionResponse: {
/** Format: int64 */
id?: number;
version?: string;
status?: string;
};
ApiResponsePageResponseSkillVersionResponse: {
/** Format: int32 */

View file

@ -138,12 +138,19 @@ export interface SkillSummary {
starCount: number
ratingAvg?: number
ratingCount: number
latestVersion?: string
latestVersionId?: number
latestVersionStatus?: string
namespace: string
updatedAt: string
canSubmitPromotion: boolean
headlineVersion?: SkillLifecycleVersion
publishedVersion?: SkillLifecycleVersion
ownerPreviewVersion?: SkillLifecycleVersion
resolutionMode?: string
}
export interface SkillLifecycleVersion {
id: number
version: string
status: string
}
export interface SkillDetail {
@ -158,14 +165,15 @@ export interface SkillDetail {
ratingAvg?: number
ratingCount: number
hidden: boolean
latestVersion?: string
latestVersionId?: number
namespace: string
canManageLifecycle: boolean
canSubmitPromotion: boolean
viewingVersionStatus?: string
canInteract: boolean
canReport: boolean
headlineVersion?: SkillLifecycleVersion
publishedVersion?: SkillLifecycleVersion
ownerPreviewVersion?: SkillLifecycleVersion
resolutionMode?: string
}
export interface SubmitPromotionRequest {

View file

@ -6,6 +6,7 @@ import { useStar, useToggleStar } from '@/features/social/use-star'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { Card } from '@/shared/ui/card'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
import { formatCompactCount } from '@/shared/lib/number-format'
import { Bookmark } from 'lucide-react'
@ -22,6 +23,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated)
const toggleStarMutation = useToggleStar(skill.id)
const showStarredBadge = highlightStarred && isAuthenticated && starStatus?.starred
const headlineVersion = getHeadlineVersion(skill)
const handleStarredBadgeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
@ -73,9 +75,9 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
)}
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{skill.latestVersion && (
{headlineVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
v{skill.latestVersion}
v{headlineVersion.version}
</span>
)}
<span className="flex items-center gap-1">

View file

@ -599,7 +599,7 @@
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored.",
"unarchiveErrorTitle": "Failed to restore skill",
"withdrawReviewConfirmTitle": "Withdraw review",
"withdrawReviewConfirmDescription": "After withdrawal, version {{version}} will leave the review queue and be removed from this skill.",
"withdrawReviewConfirmDescription": "After withdrawal, version {{version}} will leave the review queue and return to draft so it can be submitted again later.",
"withdrawReviewSuccessTitle": "Review withdrawn",
"withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.",
"withdrawReviewErrorTitle": "Failed to withdraw review",
@ -610,6 +610,18 @@
"deleteVersionSuccessDescription": "Version {{version}} has been deleted.",
"deleteVersionErrorTitle": "Failed to delete version",
"currentVersion": "Current",
"currentPublicVersion": "Current Public Version",
"pendingReviewSectionTitle": "Pending Review Version",
"pendingReviewSectionDescription": "v{{pendingVersion}} is currently under review. The public and installable version remains v{{publishedVersion}}.",
"pendingReviewVersionLabel": "Pending version",
"pendingReviewStatusLabel": "Review status",
"pendingReviewStatusValue": "In review",
"lifecyclePublicVersionLabel": "Current public version",
"lifecycleNoPublishedVersion": "No published version",
"lifecyclePendingVersionLabel": "Pending review version",
"lifecyclePendingVersionValue": "v{{version}} is in review",
"lifecycleNoPendingVersion": "No pending review version",
"lifecycleContainerStateLabel": "Skill container state",
"compareVersions": "Compare",
"compareDialogTitle": "Version comparison",
"compareDialogDescription": "Compare v{{source}} with v{{target}}.",

View file

@ -599,7 +599,7 @@
"unarchiveSuccessDescription": "“{{skill}}”已恢复。",
"unarchiveErrorTitle": "恢复技能失败",
"withdrawReviewConfirmTitle": "确认撤销审核",
"withdrawReviewConfirmDescription": "撤销后,版本 {{version}} 将不再进入审核流程,并从当前技能中移除。",
"withdrawReviewConfirmDescription": "撤销后,版本 {{version}} 将退出审核流程并回到草稿状态,之后可再次提交审核。",
"withdrawReviewSuccessTitle": "已撤销审核",
"withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。",
"withdrawReviewErrorTitle": "撤销审核失败",
@ -610,6 +610,18 @@
"deleteVersionSuccessDescription": "版本 {{version}} 已删除。",
"deleteVersionErrorTitle": "删除版本失败",
"currentVersion": "当前版本",
"currentPublicVersion": "当前公开版本",
"pendingReviewSectionTitle": "待审核新版本",
"pendingReviewSectionDescription": "v{{pendingVersion}} 正在审核中。当前对外可见和可安装的仍是 v{{publishedVersion}}。",
"pendingReviewVersionLabel": "待审核版本",
"pendingReviewStatusLabel": "审核状态",
"pendingReviewStatusValue": "审核中",
"lifecyclePublicVersionLabel": "当前公开版本",
"lifecycleNoPublishedVersion": "暂无已发布版本",
"lifecyclePendingVersionLabel": "待审核版本",
"lifecyclePendingVersionValue": "v{{version}} 正在审核中",
"lifecycleNoPendingVersion": "暂无待审核版本",
"lifecycleContainerStateLabel": "技能容器状态",
"compareVersions": "对比版本",
"compareDialogTitle": "版本对比",
"compareDialogDescription": "对比 v{{source}} 与 v{{target}}。",

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import type { SkillSummary } from '@/api/types'
import { useMySkills } from '@/shared/hooks/use-skill-queries'
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
import { TokenList } from '@/features/token/token-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { limitPreviewItems } from './dashboard-preview'
@ -131,9 +132,9 @@ export function DashboardPage() {
>
<div className="truncate text-sm font-medium">{skill.displayName}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">@{skill.namespace}</div>
{skill.latestVersion ? (
{getHeadlineVersion(skill) ? (
<div className="mt-2 inline-flex rounded-full bg-secondary px-2 py-1 text-xs text-muted-foreground">
v{skill.latestVersion}
v{getHeadlineVersion(skill)?.version}
</div>
) : null}
</Link>

View file

@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { useArchiveSkill, useMySkills, useSubmitPromotion, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle'
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
@ -191,116 +192,126 @@ export function MySkillsPage() {
<>
<div className="grid grid-cols-1 gap-4">
{skills.map((skill, idx) => (
<Card
key={skill.id}
className={`p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold font-heading text-lg mb-1 group-hover:text-primary transition-colors">
{skill.displayName}
</h3>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
)}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="handle-tag">@{skill.namespace}</span>
{skill.latestVersion ? (
<span className="font-mono text-xs">v{skill.latestVersion}</span>
) : null}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
(() => {
const headlineVersion = getHeadlineVersion(skill)
const publishedVersion = getPublishedVersion(skill)
const ownerPreviewVersion = getOwnerPreviewVersion(skill)
const hasPendingPreview = hasPendingOwnerPreview(skill)
return (
<Card
key={skill.id}
className={`p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold font-heading text-lg mb-1 group-hover:text-primary transition-colors">
{skill.displayName}
</h3>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
)}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="handle-tag">@{skill.namespace}</span>
{headlineVersion ? (
<span className="font-mono text-xs">v{headlineVersion.version}</span>
) : null}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
{formatCompactCount(skill.downloadCount)}
</span>
{skill.status ? (
<span className={resolveStatusClassName(skill.status)}>
{resolveStatusLabel(skill.status)}
</span>
) : null}
{headlineVersion?.status ? (
<span className={resolveStatusClassName(headlineVersion.status)}>
{resolveStatusLabel(headlineVersion.status)}
</span>
) : null}
{hasPendingPreview && ownerPreviewVersion?.version !== headlineVersion?.version ? (
<span className={resolveStatusClassName(ownerPreviewVersion?.status)}>
{resolveStatusLabel(ownerPreviewVersion?.status)}
</span>
) : null}
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{hasPendingPreview && ownerPreviewVersion ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setWithdrawTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
version: ownerPreviewVersion.version,
})
}}
>
{t('mySkills.withdrawReview')}
</Button>
) : skill.canSubmitPromotion && publishedVersion ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setPromotionTarget({
skillId: skill.id,
versionId: publishedVersion.id,
name: skill.displayName,
version: publishedVersion.version,
})
}}
>
{t('mySkills.promoteToGlobal')}
</Button>
) : skill.status === 'ARCHIVED' ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setUnarchiveTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
})
}}
>
{t('mySkills.unarchive')}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setArchiveTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
})
}}
>
{t('mySkills.archive')}
</Button>
)}
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{formatCompactCount(skill.downloadCount)}
</span>
{skill.status ? (
<span className={resolveStatusClassName(skill.status)}>
{resolveStatusLabel(skill.status)}
</span>
) : null}
{skill.latestVersionStatus ? (
<span className={resolveStatusClassName(skill.latestVersionStatus)}>
{resolveStatusLabel(skill.latestVersionStatus)}
</span>
) : null}
</div>
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
const pendingVersion = skill.latestVersion
if (!pendingVersion) {
return
}
setWithdrawTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
version: pendingVersion,
})
}}
>
{t('mySkills.withdrawReview')}
</Button>
) : skill.canSubmitPromotion && skill.latestVersionId && skill.latestVersion ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setPromotionTarget({
skillId: skill.id,
versionId: skill.latestVersionId!,
name: skill.displayName,
version: skill.latestVersion!,
})
}}
>
{t('mySkills.promoteToGlobal')}
</Button>
) : skill.status === 'ARCHIVED' ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setUnarchiveTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
})
}}
>
{t('mySkills.unarchive')}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setArchiveTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
})
}}
>
{t('mySkills.archive')}
</Button>
)}
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</Card>
</Card>
)
})()
))}
</div>

View file

@ -17,6 +17,7 @@ import { incrementSkillDownloadCount } from '@/shared/lib/skill-download-cache'
import { getSkillSquareSearch, normalizeSkillDetailReturnTo } from '@/shared/lib/skill-navigation'
import { formatCompactCount } from '@/shared/lib/number-format'
import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation'
import { getHeadlineVersion, getOwnerPreviewVersion, getPublishedVersion, isOwnerPreviewResolution } from '@/shared/lib/skill-lifecycle'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
@ -94,7 +95,10 @@ export function SkillDetailPage() {
const { data: skill, isLoading: isLoadingSkill, error: skillError } = useSkillDetail(namespace, slug)
const { data: versions } = useSkillVersions(namespace, slug)
const selectedVersion = skill?.latestVersion ?? versions?.[0]?.version
const headlineVersion = skill ? getHeadlineVersion(skill) : null
const publishedVersion = skill ? getPublishedVersion(skill) : null
const ownerPreviewVersion = skill ? getOwnerPreviewVersion(skill) : null
const selectedVersion = headlineVersion?.version ?? versions?.[0]?.version
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0]
const { data: files } = useSkillFiles(namespace, slug, selectedVersion)
const documentationPath = resolveDocumentationFilePath(files)
@ -109,7 +113,9 @@ export function SkillDetailPage() {
const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined, diffCompareDocumentationPath)
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const canHideSkill = hasRole('SUPER_ADMIN')
const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW'
const isPendingPreview = skill ? isOwnerPreviewResolution(skill) : false
const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW'
const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview)
const canInteract = skill?.canInteract ?? true
const canReport = skill?.canReport ?? true
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
@ -376,17 +382,17 @@ export function SkillDetailPage() {
}
const handleSubmitPromotion = async () => {
if (!skill?.latestVersionId) {
if (!skill || !publishedVersion) {
return
}
try {
await submitPromotionMutation.mutateAsync({
sourceSkillId: skill.id,
sourceVersionId: skill.latestVersionId,
sourceVersionId: publishedVersion.id,
})
toast.success(
t('skillDetail.promotionSuccessTitle'),
t('skillDetail.promotionSuccessDescription', { skill: skill.displayName, version: skill.latestVersion ?? '' }),
t('skillDetail.promotionSuccessDescription', { skill: skill.displayName, version: publishedVersion.version }),
)
setPromotionConfirmOpen(false)
} catch (error) {
@ -554,9 +560,11 @@ export function SkillDetailPage() {
{version.status}
</span>
)}
{skill.latestVersion === version.version && (
{headlineVersion?.version === version.version && (
<span className="rounded-full bg-primary px-2.5 py-0.5 text-xs text-primary-foreground">
{t('skillDetail.currentVersion')}
{t(hasPublishedPendingReview && publishedVersion?.version === version.version
? 'skillDetail.currentPublicVersion'
: 'skillDetail.currentVersion')}
</span>
)}
</span>
@ -627,7 +635,7 @@ export function SkillDetailPage() {
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">{t('skillDetail.version')}</div>
<div className="font-semibold font-mono text-foreground">
{skill.latestVersion ? `v${skill.latestVersion}` : '—'}
{headlineVersion ? `v${headlineVersion.version}` : '—'}
</div>
</div>
@ -676,7 +684,7 @@ export function SkillDetailPage() {
</div>
</Card>
{skill.latestVersion && canInteract && (
{publishedVersion && canInteract && (
<Card className="p-5 space-y-4">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.install')}</div>
{skill.status === 'ARCHIVED' && (
@ -685,11 +693,50 @@ export function SkillDetailPage() {
<InstallCommand
namespace={namespace}
slug={slug}
version={skill.latestVersion}
version={publishedVersion.version}
/>
</Card>
)}
{hasPublishedPendingReview && ownerPreviewVersion && (
<Card className="border-amber-500/30 bg-amber-500/5 p-5 space-y-4">
<div className="space-y-1">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.pendingReviewSectionTitle')}</div>
<p className="text-sm text-muted-foreground">
{t('skillDetail.pendingReviewSectionDescription', {
pendingVersion: ownerPreviewVersion.version,
publishedVersion: publishedVersion?.version ?? '',
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-amber-500/20 bg-background/70 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('skillDetail.pendingReviewVersionLabel')}
</div>
<div className="mt-2 font-mono text-sm font-semibold text-foreground">
v{ownerPreviewVersion.version}
</div>
</div>
<div className="rounded-xl border border-amber-500/20 bg-background/70 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('skillDetail.pendingReviewStatusLabel')}
</div>
<div className="mt-2 text-sm font-semibold text-amber-700">
{t('skillDetail.pendingReviewStatusValue')}
</div>
</div>
</div>
<Button
variant="outline"
onClick={() => setWithdrawVersionTarget(ownerPreviewVersion.version)}
disabled={withdrawReviewMutation.isPending}
>
{withdrawReviewMutation.isPending ? t('skillDetail.processing') : t('skillDetail.withdrawReview')}
</Button>
</Card>
)}
<Button
className="w-full"
variant="outline"
@ -711,6 +758,34 @@ export function SkillDetailPage() {
? t('skillDetail.archivedPublishHint')
: t('skillDetail.lifecycleHint')}
</p>
<div className="space-y-3">
<div className="rounded-xl border border-border/60 bg-secondary/20 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('skillDetail.lifecyclePublicVersionLabel')}
</div>
<div className="mt-2 text-sm font-semibold text-foreground">
{publishedVersion ? `v${publishedVersion.version}` : t('skillDetail.lifecycleNoPublishedVersion')}
</div>
</div>
<div className="rounded-xl border border-border/60 bg-secondary/20 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('skillDetail.lifecyclePendingVersionLabel')}
</div>
<div className="mt-2 text-sm font-semibold text-foreground">
{hasPendingOwnerPreview && ownerPreviewVersion
? t('skillDetail.lifecyclePendingVersionValue', { version: ownerPreviewVersion.version })
: t('skillDetail.lifecycleNoPendingVersion')}
</div>
</div>
<div className="rounded-xl border border-border/60 bg-secondary/20 p-3">
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('skillDetail.lifecycleContainerStateLabel')}
</div>
<div className="mt-2 text-sm font-semibold text-foreground">
{resolveSkillStatusLabel(skill.status)}
</div>
</div>
</div>
{skill.status === 'ARCHIVED' ? (
<Button variant="outline" onClick={() => setUnarchiveConfirmOpen(true)} disabled={unarchiveMutation.isPending}>
{unarchiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unarchiveSkill')}
@ -723,11 +798,11 @@ export function SkillDetailPage() {
</Card>
)}
{skill.canSubmitPromotion && skill.latestVersion && skill.latestVersionId && (
{skill.canSubmitPromotion && publishedVersion && (
<Card className="p-5 space-y-3">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.promotionSectionTitle')}</div>
<p className="text-sm text-muted-foreground">
{t('skillDetail.promotionSectionDescription', { version: skill.latestVersion })}
{t('skillDetail.promotionSectionDescription', { version: publishedVersion.version })}
</p>
<Button variant="outline" onClick={() => setPromotionConfirmOpen(true)} disabled={submitPromotionMutation.isPending}>
{submitPromotionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.promoteToGlobal')}
@ -797,7 +872,7 @@ export function SkillDetailPage() {
title={t('skillDetail.promotionConfirmTitle')}
description={t('skillDetail.promotionConfirmDescription', {
skill: skill.displayName,
version: skill.latestVersion ?? '',
version: publishedVersion?.version ?? '',
})}
confirmText={t('skillDetail.promoteToGlobal')}
onConfirm={handleSubmitPromotion}

View file

@ -3,6 +3,12 @@ import { describe, expect, it } from 'vitest'
import type { PagedResponse, SkillDetail, SkillSummary } from '@/api/types'
import { incrementSkillDownloadCount } from './skill-download-cache'
const publishedLifecycleVersion = {
id: 100,
version: '1.0.0',
status: 'PUBLISHED',
} as const
function createSkillSummary(overrides: Partial<SkillSummary> = {}): SkillSummary {
return {
id: 1,
@ -14,12 +20,13 @@ function createSkillSummary(overrides: Partial<SkillSummary> = {}): SkillSummary
starCount: 2,
ratingAvg: 5,
ratingCount: 1,
latestVersion: '1.0.0',
latestVersionId: 100,
latestVersionStatus: 'PUBLISHED',
namespace: 'team',
updatedAt: '2026-03-16T00:00:00Z',
canSubmitPromotion: false,
headlineVersion: publishedLifecycleVersion,
publishedVersion: publishedLifecycleVersion,
ownerPreviewVersion: undefined,
resolutionMode: 'PUBLISHED',
...overrides,
}
}
@ -37,14 +44,15 @@ function createSkillDetail(overrides: Partial<SkillDetail> = {}): SkillDetail {
ratingAvg: 5,
ratingCount: 1,
hidden: false,
latestVersion: '1.0.0',
latestVersionId: 100,
namespace: 'team',
canManageLifecycle: false,
canSubmitPromotion: false,
viewingVersionStatus: 'PUBLISHED',
canInteract: true,
canReport: true,
headlineVersion: publishedLifecycleVersion,
publishedVersion: publishedLifecycleVersion,
ownerPreviewVersion: undefined,
resolutionMode: 'PUBLISHED',
...overrides,
}
}

View file

@ -0,0 +1,24 @@
import type { SkillDetail, SkillLifecycleVersion, SkillSummary } from '@/api/types'
type SkillLifecycleCarrier = Pick<SkillSummary, 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'resolutionMode'>
| Pick<SkillDetail, 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'resolutionMode'>
export function getHeadlineVersion(skill: SkillLifecycleCarrier): SkillLifecycleVersion | null {
return skill.headlineVersion ?? null
}
export function getPublishedVersion(skill: SkillLifecycleCarrier): SkillLifecycleVersion | null {
return skill.publishedVersion ?? null
}
export function getOwnerPreviewVersion(skill: SkillLifecycleCarrier): SkillLifecycleVersion | null {
return skill.ownerPreviewVersion ?? null
}
export function hasPendingOwnerPreview(skill: SkillLifecycleCarrier): boolean {
return skill.ownerPreviewVersion?.status === 'PENDING_REVIEW'
}
export function isOwnerPreviewResolution(skill: SkillLifecycleCarrier): boolean {
return skill.resolutionMode === 'OWNER_PREVIEW' && skill.headlineVersion?.status === 'PENDING_REVIEW'
}