mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
c2284d8acf
commit
4aa3d29599
50 changed files with 1104 additions and 393 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@
|
|||
| owner_id | varchar(128) | 主要维护人(可转让) |
|
||||
| source_skill_id | bigint | 派生来源(团队技能提升到全局时记录原 skill ID),nullable |
|
||||
| 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。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 下载流程
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 解析
|
||||
|
||||
响应:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 标签(自定义标签)
|
||||
|
|
|
|||
|
|
@ -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 登录流程(前端视角)
|
||||
|
|
|
|||
|
|
@ -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
155
docs/14-skill-lifecycle.md
Normal 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` 必须与本文件保持一致
|
||||
- 若后续代码再次改变生命周期语义,应先修改代码,再同步更新本文件和相关子文档
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 技能**:无需认证
|
||||
- **团队命名空间的所有技能**:需要认证
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillLifecycleVersionResponse(
|
||||
Long id,
|
||||
String version,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
22
web/src/api/generated/schema.d.ts
vendored
22
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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}}。",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
web/src/shared/lib/skill-lifecycle.ts
Normal file
24
web/src/shared/lib/skill-lifecycle.ts
Normal 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'
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue