diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index da47d4ab..295e46fc 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -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) diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index dddf9414..61a82975 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -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。 | 字段 | 类型 | 说明 | |------|------|------| diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index c79746ce..2f491704 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -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` | diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 098f2e66..4b38a3eb 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -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` | diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index d2f22fe6..c5f53d03 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -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 下载流程 ``` diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 6946db25..ee3a49e2 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -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 解析 响应: diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 5f144449..958c4bb6 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -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 标签(自定义标签) diff --git a/docs/08-frontend-architecture.md b/docs/08-frontend-architecture.md index cda7e19a..681d24bc 100644 --- a/docs/08-frontend-architecture.md +++ b/docs/08-frontend-architecture.md @@ -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 登录流程(前端视角) diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index 924dd84c..f1d5b8c6 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -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 支持,只需配置 + 属性映射 | diff --git a/docs/14-skill-lifecycle.md b/docs/14-skill-lifecycle.md new file mode 100644 index 00000000..197f5558 --- /dev/null +++ b/docs/14-skill-lifecycle.md @@ -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` 必须与本文件保持一致 +- 若后续代码再次改变生命周期语义,应先修改代码,再同步更新本文件和相关子文档 diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 554d17ae..28c87140 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -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 diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index d29b88a8..2c2f7f44 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -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 技能**:无需认证 - **团队命名空间的所有技能**:需要认证 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index e5f0e391..8cfe7518 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -119,7 +119,7 @@ private ClawHubSearchResponse.ClawHubSearchResult toSearchResult(SkillSummaryRes 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 @@ private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryRespo : 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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 934b3ecc..0477ea41 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -95,7 +95,7 @@ public ClawHubRegistrySkillResponse getSkill( 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 @@ private ClawHubRegistrySearchItem toSearchItem(SkillSummaryResponse item, int in 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 latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version); + Optional 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(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index c70948fc..08d05538 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -5,6 +5,7 @@ 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.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 ApiResponse getSkillDetail( 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 @@ private boolean isSecureRequest(HttpServletRequest request) { } return request.isSecure(); } + + private SkillLifecycleVersionResponse toLifecycleVersion(SkillLifecycleProjectionService.VersionProjection projection) { + if (projection == null) { + return null; + } + return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index d1db37a2..adcc8224 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -131,7 +131,7 @@ public ApiResponse withdrawReview(@PathVariable 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 ApiResponse withdrawReview(@PathVariable ); 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") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 5232661a..70d8ae7e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -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 ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java new file mode 100644 index 00000000..aea959be --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillLifecycleVersionResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +public record SkillLifecycleVersionResponse( + Long id, + String version, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 2a5c60bc..f9cfcdb8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -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 ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 750088ce..00183e97 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -10,8 +10,10 @@ 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 listMySkills(String userId, int page, int size) { Page skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); List skills = skillPage.getContent(); - Map versionsBySkillId = loadLatestRelevantVersions(skills); - List namespaceIds = skills.stream() .map(Skill::getNamespaceId) .distinct() @@ -60,7 +63,7 @@ public PageResponse listMySkills(String userId, int page, .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); List 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 PageResponse listMyStars(String userId, int page, i : skillRepository.findByIdIn(skillIds).stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); - Map versionsBySkillId = loadLatestRelevantVersions(skillsById.values()); - List namespaceIds = skillsById.values().stream() .map(Skill::getNamespaceId) .distinct() @@ -96,7 +97,7 @@ public PageResponse listMyStars(String userId, int page, i List 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 PageResponse listMyStars(String userId, int page, i private SkillSummaryResponse toSummaryResponse( Skill skill, - Map versionsBySkillId, + String currentUserId, Map 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 @@ private SkillSummaryResponse toSummaryResponse( 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 @@ private boolean canSubmitPromotion( 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 loadLatestRelevantVersions(java.util.Collection skills) { - if (skills.isEmpty()) { - return Map.of(); - } - - List explicitLatestVersionIds = skills.stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) - .distinct() - .toList(); - Map versionsById = explicitLatestVersionIds.isEmpty() - ? Map.of() - : skillVersionRepository.findByIdIn(explicitLatestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); - - List skillIdsNeedingFallback = skills.stream() - .filter(skill -> skill.getLatestVersionId() == null || !versionsById.containsKey(skill.getLatestVersionId())) - .map(Skill::getId) - .distinct() - .toList(); - Map 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 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); - } + private SkillLifecycleVersionResponse toLifecycleVersion(SkillLifecycleProjectionService.VersionProjection projection) { + if (projection == null) { + return null; } - return resolvedVersions; + return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 4a7cbf32..13a01261 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -8,8 +8,7 @@ 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 @@ private List mapVisibleSkillSummaries( Map skillsById = matchedSkills.stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); - List latestVersionIds = matchedSkills.stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) - .distinct() - .toList(); - Map versionsById = latestVersionIds.isEmpty() - ? Map.of() - : skillVersionRepository.findByIdIn(latestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); - List namespaceIds = matchedSkills.stream() .map(Skill::getNamespaceId) .distinct() @@ -177,19 +166,18 @@ private List mapVisibleSkillSummaries( .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 versionsById, Map 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 @@ private SkillSummaryResponse toSummaryResponse( 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() ); } diff --git a/server/skillhub-app/src/main/resources/db/migration/V14__add_skill_version_stats.sql b/server/skillhub-app/src/main/resources/db/migration/V14__add_skill_version_stats.sql new file mode 100644 index 00000000..67a67c58 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V14__add_skill_version_stats.sql @@ -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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 36da9c89..c1f00fb9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -4,6 +4,7 @@ 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 @@ void search_returns_mapped_results() throws Exception { 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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index 5ea7d706..035205ac 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -5,6 +5,7 @@ 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 @@ void listMySkills_returns_paginated_items() throws Exception { 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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index b11b533c..5809dd66 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -123,26 +123,26 @@ void getSkillDetailShouldExposePendingPreviewFlags() throws Exception { 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)); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 3023255a..bd7ef931 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -172,6 +172,10 @@ void withdrawReview_returnsUnifiedEnvelope() throws Exception { 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 @@ void withdrawReview_returnsUnifiedEnvelope() throws Exception { .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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index 615feba6..dfa191a6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -11,6 +11,7 @@ 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 @@ void listMyStars_loadsAllPagesOfStars() { 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 @@ void listMySkills_includes_pendingReviewVersionWhenNoPublishedPointerExists() { 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 @@ void listMySkills_marksTeamPublishedSkillAsPromotable() { 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 @@ void listMySkills_marksTeamPublishedSkillAsPromotable() { 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 @@ void listMySkills_hidesPromotionWhenPendingRequestExists() { 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"))); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index e739ce30..dc176c8b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -10,6 +10,7 @@ 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 @@ void setUp() { searchQueryService, skillRepository, namespaceRepository, - skillVersionRepository, namespaceService, - new VisibilityChecker() + new VisibilityChecker(), + new SkillLifecycleProjectionService(skillVersionRepository) ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 93330f71..da988d4a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -241,7 +241,7 @@ public ReviewTask rejectReview(Long reviewTaskId, String reviewerId, String comm } @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 void withdrawReview(Long skillVersionId, String userId) { 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, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java new file mode 100644 index 00000000..c4f98ab2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java @@ -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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java new file mode 100644 index 00000000..28685aa2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatsRepository.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.skill; + +import java.util.Optional; + +public interface SkillVersionStatsRepository { + Optional findBySkillVersionId(Long skillVersionId); + void incrementDownloadCount(Long skillVersionId, Long skillId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 7f26b8a7..f45fda49 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -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 SkillDownloadService( NamespaceRepository namespaceRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, + SkillVersionStatsRepository skillVersionStatsRepository, SkillFileRepository skillFileRepository, SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, @@ -49,6 +51,7 @@ public 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 @@ private DownloadResult downloadVersion(Skill skill, SkillVersion version) { } skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); return result; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 8e7d8c9d..0bed401e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -161,51 +161,52 @@ public void deleteVersion(Skill skill, } @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 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 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 userNamespaceRoles) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java new file mode 100644 index 00000000..73b2d0e7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -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 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 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 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 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 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()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 0ed3bde7..79dd45d3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -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 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 SkillQueryService( this.visibilityChecker = visibilityChecker; this.promotionRequestRepository = promotionRequestRepository; this.skillSlugResolutionService = skillSlugResolutionService; + this.skillLifecycleProjectionService = skillLifecycleProjectionService; } public record SkillDetailDTO( @@ -75,17 +78,17 @@ public record SkillDetailDTO( 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 SkillDetailDTO getSkillDetail( 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 SkillDetailDTO getSkillDetail( 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 @@ private SkillVersion resolveLatestVersion(Skill skill) { 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 @@ private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map< private boolean canSubmitPromotion( Namespace namespace, Skill skill, - SkillVersion latestVersionEntity, + SkillLifecycleProjectionService.VersionProjection publishedVersion, String currentUserId, Map userNsRoles) { if (namespace.getType() == NamespaceType.GLOBAL) { @@ -537,7 +518,7 @@ private boolean canSubmitPromotion( 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()) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index ab659faa..576852fb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -527,16 +527,18 @@ void shouldWithdrawReviewSuccessfully() { 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 @@ void shouldRejectWithdrawWhenNamespaceArchived() { } @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); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 0b407ded..9ff965df 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -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 @@ void setUp() { namespaceRepository, skillRepository, skillVersionRepository, + skillVersionStatsRepository, skillFileRepository, skillTagRepository, objectStorageService, @@ -111,6 +114,7 @@ void testDownloadLatest_Success() throws Exception { 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 @@ void testDownloadByTag_Success() throws Exception { 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 @@ void testDownloadVersion_WithPresignedUrlStillProvidesStreamFallback() throws Ex 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 @@ void testDownloadVersion_ShouldRejectDraftVersion() throws Exception { 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 @@ void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throw } verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -299,6 +311,9 @@ void testDownloadVersion_AllowsAnonymousForGlobalPublicSkill() throws Exception 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 @@ void testDownloadVersion_RejectsAnonymousForTeamNamespacePublicSkill() throws Ex 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 { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 469bd2fc..e9923e14 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -119,6 +119,7 @@ void yankVersion_setsYankedStatus() { 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 @@ void yankVersion_setsYankedStatus() { 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); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 4b56ff9b..93a371d9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -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 @@ void setUp() { objectStorageService, visibilityChecker, promotionRequestRepository, - skillSlugResolutionService + skillSlugResolutionService, + skillLifecycleProjectionService ); } @@ -88,6 +91,7 @@ void testGetSkillDetail_Success() throws Exception { 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 @@ void testGetSkillDetail_Success() throws Exception { 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 @@ void testGetSkillDetail_PrefersCurrentUsersOwnSkillOverOtherPublishedSkill() thr 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 @@ void testGetSkillDetail_PrefersCurrentUsersOwnSkillOverOtherPublishedSkill() thr 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 @@ void testGetSkillDetail_ShouldAllowPromotionForTeamOwnerOnPublishedSkill() throw 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 @@ void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Excep 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 @@ void testGetSkillDetail_ShouldKeepPublishedVersionWhenSkillAlreadyPublic() throw 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()); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java new file mode 100644 index 00000000..3a926142 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionStatsJpaRepository.java @@ -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, SkillVersionStatsRepository { + + @Override + default Optional 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); +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 542100c2..1799b873 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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 */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0feeb931..82860131 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 { diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 4e3c2bf4..d05e1bf2 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -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) => { event.stopPropagation() @@ -73,9 +75,9 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard )}
- {skill.latestVersion && ( + {headlineVersion && ( - v{skill.latestVersion} + v{headlineVersion.version} )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c047bd06..83645f49 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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}}.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 9127aae3..f4b9e035 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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}}。", diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 4102396b..33160d4c 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -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() { >
{skill.displayName}
@{skill.namespace}
- {skill.latestVersion ? ( + {getHeadlineVersion(skill) ? (
- v{skill.latestVersion} + v{getHeadlineVersion(skill)?.version}
) : null} diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 760a4b9e..c489feaa 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -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() { <>
{skills.map((skill, idx) => ( - handleSkillClick(skill.namespace, skill.slug)} - > -
-
-

- {skill.displayName} -

- {skill.summary && ( -

{skill.summary}

- )} -
- @{skill.namespace} - {skill.latestVersion ? ( - v{skill.latestVersion} - ) : null} - - - + (() => { + const headlineVersion = getHeadlineVersion(skill) + const publishedVersion = getPublishedVersion(skill) + const ownerPreviewVersion = getOwnerPreviewVersion(skill) + const hasPendingPreview = hasPendingOwnerPreview(skill) + + return ( + handleSkillClick(skill.namespace, skill.slug)} + > +
+
+

+ {skill.displayName} +

+ {skill.summary && ( +

{skill.summary}

+ )} +
+ @{skill.namespace} + {headlineVersion ? ( + v{headlineVersion.version} + ) : null} + + + + + {formatCompactCount(skill.downloadCount)} + + {skill.status ? ( + + {resolveStatusLabel(skill.status)} + + ) : null} + {headlineVersion?.status ? ( + + {resolveStatusLabel(headlineVersion.status)} + + ) : null} + {hasPendingPreview && ownerPreviewVersion?.version !== headlineVersion?.version ? ( + + {resolveStatusLabel(ownerPreviewVersion?.status)} + + ) : null} +
+
+
+ {hasPendingPreview && ownerPreviewVersion ? ( + + ) : skill.canSubmitPromotion && publishedVersion ? ( + + ) : skill.status === 'ARCHIVED' ? ( + + ) : ( + + )} + + - {formatCompactCount(skill.downloadCount)} - - {skill.status ? ( - - {resolveStatusLabel(skill.status)} - - ) : null} - {skill.latestVersionStatus ? ( - - {resolveStatusLabel(skill.latestVersionStatus)} - - ) : null} +
-
-
- {skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? ( - - ) : skill.canSubmitPromotion && skill.latestVersionId && skill.latestVersion ? ( - - ) : skill.status === 'ARCHIVED' ? ( - - ) : ( - - )} - - - -
-
- + + ) + })() ))}
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index be795a5b..05abbc66 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -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} )} - {skill.latestVersion === version.version && ( + {headlineVersion?.version === version.version && ( - {t('skillDetail.currentVersion')} + {t(hasPublishedPendingReview && publishedVersion?.version === version.version + ? 'skillDetail.currentPublicVersion' + : 'skillDetail.currentVersion')} )} @@ -627,7 +635,7 @@ export function SkillDetailPage() {
{t('skillDetail.version')}
- {skill.latestVersion ? `v${skill.latestVersion}` : '—'} + {headlineVersion ? `v${headlineVersion.version}` : '—'}
@@ -676,7 +684,7 @@ export function SkillDetailPage() {
- {skill.latestVersion && canInteract && ( + {publishedVersion && canInteract && (
{t('skillDetail.install')}
{skill.status === 'ARCHIVED' && ( @@ -685,11 +693,50 @@ export function SkillDetailPage() {
)} + {hasPublishedPendingReview && ownerPreviewVersion && ( + +
+
{t('skillDetail.pendingReviewSectionTitle')}
+

+ {t('skillDetail.pendingReviewSectionDescription', { + pendingVersion: ownerPreviewVersion.version, + publishedVersion: publishedVersion?.version ?? '', + })} +

+
+
+
+
+ {t('skillDetail.pendingReviewVersionLabel')} +
+
+ v{ownerPreviewVersion.version} +
+
+
+
+ {t('skillDetail.pendingReviewStatusLabel')} +
+
+ {t('skillDetail.pendingReviewStatusValue')} +
+
+
+ +
+ )} +