From f55c520ebee809e0143d06cd6d92e0f470de3441 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 13 Apr 2026 09:26:01 +0800 Subject: [PATCH 1/2] feat(skill): add UPLOADED status for PRIVATE skill lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add UPLOADED status for PRIVATE skills after security scan passes - PRIVATE skill owners can test before confirming publish or submitting for review - Rerelease now follows visibility rules (PRIVATE→UPLOADED, PUBLIC→PENDING_REVIEW) - Auto-withdraw changes status to UPLOADED (not DRAFT) to keep versions visible ## Changes - SkillVersionStatus: Add UPLOADED enum value - SkillPublishService: PRIVATE skills go to UPLOADED after scan - SecurityScanService: Visibility-based status transition after scan - SkillGovernanceService: Withdraw→UPLOADED, delete allows UPLOADED - SkillQueryService: Include UPLOADED in version list filters - SkillReviewSubmitService: New service for submit-review and confirm-publish - SkillLifecycleController: Add submit-review and confirm-publish endpoints - Frontend: Add buttons, dialogs, and hooks for new operations ## Workflow - PRIVATE: Publish → SCANNING → UPLOADED → confirm-publish → PUBLISHED - PUBLIC: Publish → SCANNING → PENDING_REVIEW → PUBLISHED --- README.md | 5 +- README_zh.md | 4 +- docs/oss-01-core-contract-freeze.md | 460 ++++++++++++ docs/oss-02-core-semantic-rules.md | 663 ++++++++++++++++++ .../portal/SkillLifecycleController.java | 37 + .../skillhub/dto/ConfirmPublishRequest.java | 11 + .../skillhub/dto/SubmitReviewRequest.java | 16 + .../service/GovernanceWorkflowAppService.java | 32 + .../service/SkillLifecycleAppService.java | 72 ++ .../src/main/resources/messages.properties | 5 + .../src/main/resources/messages_zh.properties | 5 + .../service/SkillLifecycleAppServiceTest.java | 3 + .../domain/security/SecurityScanService.java | 8 +- .../domain/skill/SkillVersionStatus.java | 1 + .../skill/service/SkillDownloadService.java | 29 +- .../skill/service/SkillGovernanceService.java | 5 +- .../skill/service/SkillPublishService.java | 36 +- .../skill/service/SkillQueryService.java | 11 +- .../service/SkillReviewSubmitService.java | 153 ++++ .../service/SkillGovernanceServiceTest.java | 4 +- .../service/SkillPublishServiceTest.java | 86 ++- .../service/SkillReviewSubmitServiceTest.java | 220 ++++++ web/src/api/client.ts | 30 + web/src/i18n/locales/en.json | 13 + web/src/i18n/locales/zh.json | 14 + web/src/pages/dashboard/my-skills.tsx | 6 + web/src/pages/skill-detail.test.tsx | 2 + web/src/pages/skill-detail.tsx | 87 ++- web/src/shared/hooks/use-skill-queries.ts | 38 + 29 files changed, 2019 insertions(+), 37 deletions(-) create mode 100644 docs/oss-01-core-contract-freeze.md create mode 100644 docs/oss-02-core-semantic-rules.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java diff --git a/README.md b/README.md index cf803028c..baa7d8e0b 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan **For users in China (Aliyun mirror):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` If deployment runs into problems, clear the existing runtime home and retry. @@ -195,7 +195,7 @@ Published images target both `linux/amd64` and `linux/arm64`. curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # Aliyun mirror (recommended for users in China) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` **Deployment parameters:** @@ -222,6 +222,7 @@ cp .env.release.example .env.release Recommended image tags: +- `SKILLHUB_VERSION=latest` for the latest stable release (default) - `SKILLHUB_VERSION=edge` for the latest `main` build - `SKILLHUB_VERSION=vX.Y.Z` for a fixed release diff --git a/README_zh.md b/README_zh.md index edb58fc82..8a7c74094 100644 --- a/README_zh.md +++ b/README_zh.md @@ -67,7 +67,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u **国内用户(阿里云镜像):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` 如果部署遇到问题,请清除现有的运行时目录并重试。 @@ -177,7 +177,7 @@ skillhub/ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # 阿里云镜像(国内推荐) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up -- --aliyun --public-url https://skillhub.your-company.com --version latest ``` ### 配置参数说明 diff --git a/docs/oss-01-core-contract-freeze.md b/docs/oss-01-core-contract-freeze.md new file mode 100644 index 000000000..617f705c0 --- /dev/null +++ b/docs/oss-01-core-contract-freeze.md @@ -0,0 +1,460 @@ +# OSS-01 Core 契约审计与冻结 + +## 1. 审计结论 + +SkillHub 开源项目已具备 AstronClaw 主链路所需的绝大部分 Core 能力。现有接口覆盖了 skill 唯一标识查询、版本元数据查询、创建(发布)和删除。**无需在开源 Core 中新增 AstronClaw 专属接口**;对 AstronClaw 而言,查询类和主链路类能力都应统一由 SaaS 层 `AstronClaw Adapter` 封装后对外提供,而不是直接绑定开源 Core 的接口形态。 + +--- + +## 2. Core 接口清单 + +以下接口构成 Core 基线能力,供 SaaS 层统一封装后对 AstronClaw 提供;这些接口本身不应被视为 AstronClaw 的长期直接契约。 + +### 2.1 skill 唯一标识与详情查询 + +| 接口 | 路径 | 说明 | +|------|------|------| +| skill 详情 | `GET /api/v1/skills/{namespace}/{slug}` | 返回 `SkillDetailResponse`,包含完整 identity 和状态 | +| 版本解析 | `GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag=&hash=` | 返回 `ResolveVersionResponse`,解析人类可读版本选择器到精确版本 | + +### 2.2 指定版本安装元数据查询 + +| 接口 | 路径 | 说明 | +|------|------|------| +| 版本详情 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` | 返回 `SkillVersionDetailResponse`,含 metadata 和 manifest | +| 版本文件列表 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files` | 返回 `List` | +| 版本下载 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本 bundle | +| 版本列表 | `GET /api/v1/skills/{namespace}/{slug}/versions?page=&size=` | 分页返回版本列表 | + +### 2.3 创建(发布)个人 skill + +| 接口 | 路径 | 说明 | +|------|------|------| +| 发布 skill | `POST /api/v1/skills/{namespace}/publish` | 上传包并发布,返回 `PublishResponse` | + +### 2.4 删除个人 skill + +| 接口 | 路径 | 说明 | +|------|------|------| +| 硬删除(by ID) | `DELETE /api/v1/skills/id/{skillId}` | 需 SUPER_ADMIN 权限 | +| 硬删除(by 坐标) | `DELETE /api/v1/skills/{namespace}/{slug}` | 需 SUPER_ADMIN 权限 | +| 归档 | `POST /api/v1/skills/{namespace}/{slug}/archive` | owner 或 namespace admin 可操作 | +| 取消归档 | `POST /api/v1/skills/{namespace}/{slug}/unarchive` | 恢复为 ACTIVE | + +### 2.5 版本生命周期 + +| 接口 | 路径 | 说明 | +|------|------|------| +| 删除版本 | `DELETE /api/v1/skills/{namespace}/{slug}/versions/{version}` | 仅 DRAFT/REJECTED/SCAN_FAILED 可删 | +| 撤回审核 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | PENDING_REVIEW → DRAFT | +| 重新发布 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/rerelease` | 重新发布版本 | + +### 2.6 ClawHub 兼容接口(已有) + +| 接口 | 路径 | 说明 | +|------|------|------| +| 解析 skill | `GET /api/v1/resolve?slug=&version=` | ClawHub 协议兼容 | +| 解析 skill(路径) | `GET /api/v1/resolve/{canonicalSlug}?version=` | ClawHub 协议兼容 | +| 下载 | `GET /api/v1/download/{canonicalSlug}?version=` | 302 重定向到下载地址 | +| 删除 skill | `DELETE /api/v1/skills/{canonicalSlug}` | owner 可操作 | +| 取消删除 | `POST /api/v1/skills/{canonicalSlug}/undelete` | owner 可操作 | +| 发布 skill | `POST /api/v1/skills` | ClawHub 协议兼容 | +| 发布到 namespace | `POST /api/v1/publish` | ClawHub 协议兼容 | + +--- + +## 3. 字段语义冻结表 + +### 3.1 Skill Identity 字段 + +| 字段 | 类型 | 含义 | 稳定性 | 说明 | +|------|------|------|--------|------| +| `skill.id` | Long | skill 全局唯一主键 | 不可变 | 自增,创建后永不改变,可作为外部映射主键 | +| `namespace` (slug) | String(64) | skill 所属命名空间标识 | 不可变 | 全局唯一,创建后不可改名 | +| `skill.slug` | String(100) | skill 在 namespace 内的唯一标识 | 不可变 | 创建后不可改名,`namespace + slug` 构成业务坐标 | +| `skill.displayName` | String(200) | skill 展示名称 | 可变 | 仅用于展示,不可作为映射依据 | +| `skill.ownerId` | String | skill 创建者 ID | 不可变 | 创建时绑定,不可转移 | +| `skill.summary` | String(TEXT) | skill 简介 | 可变 | 展示用 | +| `skill.visibility` | Enum | 可见性 | 可变 | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | +| `skill.status` | Enum | skill 状态 | 可变 | `ACTIVE` / `HIDDEN` / `ARCHIVED` | +| `skill.hidden` | boolean | 是否被管理员隐藏 | 可变 | 与 status 独立的隐藏标记 | +| `skill.latestVersionId` | Long | 最新版本指针 | 可变 | 指向当前最新已发布版本,yank/删除后自动回退 | +| `skill.downloadCount` | Long | 下载次数 | 可变 | 累计值 | +| `skill.starCount` | Integer | 收藏数 | 可变 | 累计值 | + +### 3.2 SkillVersion 字段 + +| 字段 | 类型 | 含义 | 稳定性 | 说明 | +|------|------|------|--------|------| +| `version.id` | Long | 版本全局唯一主键 | 不可变 | 自增 | +| `version.skillId` | Long | 所属 skill ID | 不可变 | 外键 | +| `version.version` | String(64) | 版本号 | 不可变 | 如 `1.0.0`,创建后不可改 | +| `version.status` | Enum | 版本状态 | 可变 | 见状态语义表 | +| `version.bundleReady` | boolean | bundle 是否可用 | 可变 | `true` 表示 bundle 已构建完成,可下载安装 | +| `version.downloadReady` | boolean | 是否允许下载 | 可变 | yank 后设为 `false` | +| `version.publishedAt` | Instant | 发布时间 | 一次写入 | 首次发布时设置 | +| `version.parsedMetadataJson` | JSONB | 解析后的元数据 | 一次写入 | 包含 `package_name` 等运行时信息 | +| `version.manifestJson` | JSONB | manifest 原始内容 | 一次写入 | skill 包的 manifest | +| `version.changelog` | String(TEXT) | 变更日志 | 可变 | 展示用 | +| `version.fileCount` | Integer | 文件数量 | 一次写入 | 发布时确定 | +| `version.totalSize` | Long | 总大小(字节) | 一次写入 | 发布时确定 | +| `version.yankedAt` | Instant | yank 时间 | 一次写入 | yank 时设置 | +| `version.yankReason` | String(TEXT) | yank 原因 | 一次写入 | yank 时设置 | + +### 3.3 关键字段含义冻结 + +| 字段 | 冻结定义 | +|------|----------| +| `skill_id` | `skill.id`,Long 类型自增主键,全局唯一,创建后不可变。AstronClaw 应以此作为 `external_skill_mapping` 的外部主键 | +| `namespace` | `namespace.slug`,String(64),全局唯一,不可改名。与 `slug` 组合构成业务坐标 | +| `slug` | `skill.slug`,String(100),namespace 内唯一,不可改名。`namespace/slug` 是人类可读的稳定坐标 | +| `version` | `skill_version.version`,String(64),同一 skill 内唯一,不可改。如 `1.0.0` | +| `bundle_url` | 通过 `GET /{namespace}/{slug}/versions/{version}/download` 获取,或通过 `resolve` 接口的 `downloadUrl` 字段获取。不是数据库字段,而是动态生成的下载地址 | +| `bundle_ready` | `skill_version.bundleReady`,boolean。`true` 表示 bundle 已构建完成可安装。AstronClaw 安装前必须校验此字段 | +| `package_name` | 存储在 `skill_version.parsedMetadataJson` 中,从 skill 包的 manifest 解析而来。同一 skill 跨版本应保持稳定。AstronClaw 用于运行时安装/卸载标识 | + +### 3.4 Namespace 字段 + +| 字段 | 类型 | 含义 | 稳定性 | +|------|------|------|--------| +| `namespace.id` | Long | 命名空间主键 | 不可变 | +| `namespace.slug` | String(64) | 命名空间标识 | 不可变,全局唯一 | +| `namespace.displayName` | String(128) | 展示名称 | 可变 | +| `namespace.type` | Enum | 类型 | 不可变,`GLOBAL` / `TEAM` | +| `namespace.status` | Enum | 状态 | 可变,`ACTIVE` / `FROZEN` / `ARCHIVED` | + +--- + +## 4. 状态语义冻结表 + +### 4.1 Skill 状态(`SkillStatus`) + +| 状态 | 市场可见 | 可新装 | 已装是否保留 | 可被 owner 操作 | 说明 | +|------|----------|--------|------------|----------------|------| +| `ACTIVE` | 是(受 visibility 控制) | 是(需有 PUBLISHED 版本) | 是 | 是 | 正常状态 | +| `HIDDEN` | 否 | 否 | 是 | 受限 | 管理员隐藏,独立于 status 的 `hidden` 标记 | +| `ARCHIVED` | 否 | 否 | 是 | 可取消归档 | owner 或 namespace admin 归档 | + +### 4.2 版本状态(`SkillVersionStatus`) + +| 状态 | 是否允许安装 | 是否允许下载 | 市场可见 | 可转换到 | 说明 | +|------|------------|------------|---------|---------|------| +| `DRAFT` | 否 | 否 | 否 | SCANNING, 可删除 | 初始状态,编辑中 | +| `SCANNING` | 否 | 否 | 否 | SCAN_FAILED, PENDING_REVIEW, PUBLISHED | 安全扫描中 | +| `SCAN_FAILED` | 否 | 否 | 否 | 可删除 | 安全扫描失败 | +| `PENDING_REVIEW` | 否 | 否 | 否 | PUBLISHED, REJECTED, → DRAFT(撤回) | 等待审核 | +| `PUBLISHED` | 是 | 是 | 是 | YANKED | 已发布,可安装 | +| `REJECTED` | 否 | 否 | 否 | 可删除 | 审核拒绝 | +| `YANKED` | 否 | 否 | 否(或弱可见) | 不可逆 | 已撤回,已装不受影响 | + +### 4.3 可见性(`SkillVisibility`) + +| 可见性 | 市场列表可见 | 谁可查看 | 谁可安装 | +|--------|------------|---------|---------| +| `PUBLIC` | 是 | 所有人 | 所有人(需 PUBLISHED + bundleReady) | +| `NAMESPACE_ONLY` | 否 | namespace 成员 | namespace 成员 | +| `PRIVATE` | 否 | 仅 owner | 仅 owner | + +### 4.4 删除语义 + +| 操作 | 类型 | 可逆 | 数据影响 | 已装实例影响 | +|------|------|------|---------|------------| +| 硬删除 skill | 永久删除 | 否 | 删除所有记录、文件、存储对象,slug 可复用 | 不影响,AstronClaw 已装快照独立 | +| 归档 skill | 状态变更 | 是 | 无数据删除,status → ARCHIVED | 不影响 | +| 隐藏 skill | 标记变更 | 是 | 无数据删除,hidden → true | 不影响 | +| 删除版本 | 永久删除 | 否 | 仅删除 DRAFT/REJECTED/SCAN_FAILED 版本 | 不影响(这些版本未被安装) | +| Yank 版本 | 状态变更 | 否 | status → YANKED,downloadReady → false | 不影响已装实例 | + +### 4.5 AstronClaw 安装判断规则 + +AstronClaw 判断一个 skill 版本是否可安装,需同时满足: + +``` +skill.status == ACTIVE + AND skill.hidden == false + AND skill.visibility 允许当前用户访问 + AND version.status == PUBLISHED + AND version.bundleReady == true +``` + +已安装实例不受后续状态变更影响。即使 skill 被删除/归档/隐藏,或版本被 yank,AstronClaw 本地安装快照仍可正常使用和卸载。 + +## 5. 错误语义表 + +### 5.1 统一响应结构 + +```json +{ + "code": 0, + "msg": "操作成功", + "data": { ... }, + "timestamp": "2026-04-10T08:00:00Z", + "requestId": "req-xxx" +} +``` + +- `code = 0` 表示成功 +- `code > 0` 表示错误,值为 HTTP 状态码 + +### 5.2 错误码映射 + +| HTTP 状态码 | 场景 | 异常类型 | 说明 | +|------------|------|---------|------| +| 400 | 参数非法 | `BadRequestException` / `DomainBadRequestException` | 请求参数校验失败 | +| 401 | 未认证 | `UnauthorizedException` / `AuthFlowException` | 未登录或 token 过期 | +| 403 | 无权限 | `ForbiddenException` / `DomainForbiddenException` | 无操作权限 | +| 404 | 未找到 | `DomainNotFoundException` | skill/version/namespace 不存在 | +| 408 | 请求超时 | `AsyncRequestTimeoutException` | 异步请求超时 | +| 503 | 存储不可用 | `StorageAccessException` | 对象存储访问失败 | +| 500 | 服务异常 | `Exception` | 未预期的内部错误 | + +### 5.3 Core 主链路关键错误场景 + +| 场景 | HTTP 状态码 | msg 示例 | AstronClaw 处理建议 | +|------|-----------|---------|-------------------| +| skill 不存在 | 404 | `error.skill.notFound` | 映射失败,提示用户 | +| 版本不存在 | 404 | `error.skill.notFound` | 安装/升级失败,提示用户 | +| 版本不可安装(非 PUBLISHED) | 400 | `error.badRequest` | 拒绝安装,提示版本状态 | +| bundle 未就绪 | 400 | `error.badRequest` | 拒绝安装,提示稍后重试 | +| 无权访问(PRIVATE skill) | 403 | `error.forbidden` | 提示无权限 | +| namespace 不存在 | 404 | `error.namespace.notFound` | 映射失败 | +| 存储服务不可用 | 503 | `error.storage.unavailable` | 降级处理,已装 skill 不受影响 | +| 删除不允许(非 owner) | 403 | `error.forbidden` | 提示无权限 | + +--- + +## 6. Core vs SaaS Adapter 能力分界 + +### 6.1 Core 已满足的能力 + +说明: + +下表表示“开源 Core 已具备、可供 SaaS 封装”的能力,并不表示 AstronClaw 应直接调用这些开源接口。 + +| PRD 需求 | Core 接口 | 满足程度 | 备注 | +|---------|----------|---------|------| +| skill 唯一标识查询 | `GET /{namespace}/{slug}` | 完全满足 | 返回 `id`、`namespace`、`slug` | +| 指定版本安装元数据 | `GET /{namespace}/{slug}/versions/{version}` | 基本满足 | 返回 status、metadata;`package_name` 在 `parsedMetadataJson` 中 | +| 版本解析 | `GET /{namespace}/{slug}/resolve` | 完全满足 | 支持 version/tag/hash 解析 | +| bundle 下载 | `GET /{namespace}/{slug}/versions/{version}/download` | 完全满足 | 直接下载 | +| 创建(发布)个人 skill | `POST /{namespace}/publish` | 完全满足 | 返回 skillId、namespace、slug、version、status | +| 删除个人 skill | `DELETE /{namespace}/{slug}` (ClawHub 兼容) | 完全满足 | owner 可操作 | +| 归档 skill | `POST /{namespace}/{slug}/archive` | 完全满足 | 可逆操作 | +| 版本状态查询 | `GET /{namespace}/{slug}` 中的 headlineVersion/publishedVersion | 完全满足 | 包含版本状态 | +| labels 数据 | `GET /{namespace}/{slug}` 中的 labels 字段 | 完全满足 | 返回 `List` | + +### 6.2 需要 SaaS Adapter 新增的能力 + +| PRD 需求 | 原因 | Adapter 建议 | +|---------|------|-------------| +| 市场列表查询(搜索/过滤/排序) | Core 不提供面向页面的聚合列表 | `GET /api/v1/astronclaw/adapter/skills/market` | +| 市场详情(AstronClaw DTO) | Core 返回的 DTO 包含 Core 内部字段,需适配 | `GET /api/v1/astronclaw/adapter/skills/{id}` | +| owner 维度"我创建的"查询 | Core 的 `/me/skills` 返回 Core DTO,需适配 | `GET /api/v1/astronclaw/adapter/skills/mine` | +| `is_installed` 补全 | 安装关系在 AstronClaw 侧 | AstronClaw 本地补全,不在 Adapter | +| `package_name` 顶层字段 | 当前在 `parsedMetadataJson` 内,需提取 | Adapter 解析 JSON 后平铺返回 | +| `bundle_url` 直接返回 | 当前需通过 download 接口获取 | Adapter 可直接返回预签名 URL | +| 统一 `can_install` 判断 | 需组合 status + visibility + bundleReady | Adapter 计算后返回布尔值 | +| 统一 `can_delete` 判断 | 需组合 owner + status | Adapter 计算后返回布尔值 | + +### 6.3 分界原则 + +``` +Core 负责:skill 生命周期真相(identity、version、status、artifact) +Adapter 负责:面向 AstronClaw 的 DTO 适配(字段平铺、状态聚合、权限预判断) +``` + +补充原则: + +1. 即使开源 `Core` 已经具备某项主链路能力,`AstronClaw` 仍应统一通过 SaaS Adapter 消费。 +2. 该原则同时适用于唯一标识查询、版本元数据、创建个人 skill、删除个人 skill。 +3. 开源文档中的接口清单用于说明 `Core` 能力边界,不应被解读为 AstronClaw 的直接对接建议。 + +--- + +## 7. 成功 / 失败 / 边界样例 + +### 7.1 查询 skill identity — 成功 + +``` +GET /api/v1/skills/my-namespace/my-skill +``` + +```json +{ + "code": 0, + "data": { + "id": 42, + "slug": "my-skill", + "displayName": "My Skill", + "ownerId": "user-123", + "status": "ACTIVE", + "visibility": "PUBLIC", + "namespace": "my-namespace", + "labels": [{"slug": "nlp", "type": "CATEGORY", "displayName": "NLP"}], + "headlineVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"}, + "publishedVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"} + } +} +``` + +AstronClaw 映射关键字段:`id=42`,`namespace=my-namespace`,`slug=my-skill`。 + +### 7.2 查询 skill identity — 不存在 + +``` +GET /api/v1/skills/my-namespace/nonexistent +``` + +```json +{ + "code": 404, + "msg": "Skill not found", + "data": null +} +``` + +### 7.3 查询指定版本元数据 — 成功 + +``` +GET /api/v1/skills/my-namespace/my-skill/versions/1.2.0 +``` + +```json +{ + "code": 0, + "data": { + "id": 100, + "version": "1.2.0", + "status": "PUBLISHED", + "changelog": "Bug fixes", + "fileCount": 3, + "totalSize": 102400, + "publishedAt": "2026-04-01T10:00:00Z", + "parsedMetadataJson": "{\"name\":\"my-skill\",\"package_name\":\"my_namespace__my_skill\",\"version\":\"1.2.0\"}", + "manifestJson": "{...}" + } +} +``` + +`package_name` 从 `parsedMetadataJson` 中提取。 + +### 7.4 查询已 YANKED 版本 + +``` +GET /api/v1/skills/my-namespace/my-skill/versions/1.0.0 +``` + +```json +{ + "code": 0, + "data": { + "id": 98, + "version": "1.0.0", + "status": "YANKED", + "publishedAt": "2026-03-01T10:00:00Z" + } +} +``` + +AstronClaw 判断 `status != PUBLISHED`,拒绝新安装。已装实例不受影响。 + +### 7.5 发布(创建)个人 skill — 成功 + +``` +POST /api/v1/skills/my-namespace/publish +Content-Type: multipart/form-data +file: +visibility: PRIVATE +``` + +```json +{ + "code": 0, + "data": { + "skillId": 43, + "namespace": "my-namespace", + "slug": "new-skill", + "version": "0.1.0", + "status": "DRAFT", + "fileCount": 2, + "totalSize": 51200 + } +} +``` + +### 7.6 删除个人 skill — 成功 + +``` +DELETE /api/v1/skills/my-namespace/my-skill +``` + +```json +{ + "code": 0, + "data": { + "ok": true + } +} +``` + +### 7.7 删除个人 skill — 无权限 + +``` +DELETE /api/v1/skills/other-namespace/other-skill +``` + +```json +{ + "code": 403, + "msg": "Forbidden", + "data": null +} +``` + +### 7.8 边界:skill 已归档后查询 + +``` +GET /api/v1/skills/my-namespace/archived-skill +``` + +```json +{ + "code": 0, + "data": { + "id": 44, + "slug": "archived-skill", + "status": "ARCHIVED", + "visibility": "PUBLIC" + } +} +``` + +skill 仍可查询,但 AstronClaw 应根据 `status=ARCHIVED` 判断不可新装。 + +--- + +## 8. 遗留问题与建议 + +### 8.1 `package_name` 提取 + +当前 `package_name` 嵌套在 `parsedMetadataJson` JSONB 字段中,不是顶层字段。 + +建议:SaaS Adapter 在返回 AstronClaw DTO 时,解析 JSON 并将 `package_name` 提取为顶层字段。Core 不需要改动。 + +### 8.2 `bundle_url` 获取方式 + +当前没有直接返回 `bundle_url` 的字段,需通过 download 接口获取。`ResolveVersionResponse` 中有 `downloadUrl` 字段。 + +建议:SaaS Adapter 可通过 `resolve` 接口获取 `downloadUrl`,或直接生成预签名 URL 返回给 AstronClaw。 + +### 8.3 删除接口权限 + +当前 `DELETE /api/v1/skills/{namespace}/{slug}`(portal 路径)需要 SUPER_ADMIN 权限。ClawHub 兼容接口 `DELETE /api/v1/skills/{canonicalSlug}` 允许 owner 操作。 + +建议:SaaS Adapter 应统一封装 owner 可操作的删除接口,对 AstronClaw 暴露稳定契约;AstronClaw 不直接依赖开源删除接口路径。 + +### 8.4 `hidden` 与 `status` 的关系 + +当前 `hidden` 是独立于 `status` 的布尔标记(管理员操作),而 `HIDDEN` 是 `SkillStatus` 枚举值之一但实际代码中 skill 的 status 枚举包含 `ACTIVE`、`HIDDEN`、`ARCHIVED`。 + +建议:SaaS Adapter 统一为 AstronClaw 提供一个 `is_visible` 聚合字段,屏蔽内部 hidden 标记与 status 的复杂关系。 diff --git a/docs/oss-02-core-semantic-rules.md b/docs/oss-02-core-semantic-rules.md new file mode 100644 index 000000000..cd256d056 --- /dev/null +++ b/docs/oss-02-core-semantic-rules.md @@ -0,0 +1,663 @@ +# OSS-02 Core 语义规则收口 + +## 1. 文档目标 + +本文档固化 SkillHub Core 的运行时语义规则,确保开源版与 SaaS 版对删除、YANKED、同名冲突、package_name 等规则口径一致,避免 AstronClaw 接入后出现状态漂移。本文定义的是可由 SaaS 统一封装并对 AstronClaw 提供的 `Core` 规则基线,不表示 AstronClaw 直接对接这些开源接口。 + +--- + +## 2. 变更概要 + +### 2.1 新增功能 + +| 功能 | 说明 | +|------|------| +| UPLOADED 状态 | 新增版本状态,表示"已上传,未提交审核" | +| PRIVATE skill 自动发布 | PRIVATE skill 发布后进入 UPLOADED 状态,不自动进入审核 | +| 提交审核接口 | 新增 `POST /{namespace}/{slug}/submit-review`,允许 UPLOADED 状态的版本提交审核 | +| 撤回审核后进入 UPLOADED | 撤回审核后版本状态变为 UPLOADED,而不是 DRAFT | + +### 2.2 状态机变更 + +**变更前**: +``` +DRAFT → SCANNING → PENDING_REVIEW → PUBLISHED + ↓ ↓ + REJECTED YANKED +``` + +**变更后**: +``` +DRAFT → SCANNING → UPLOADED → PENDING_REVIEW → PUBLISHED + ↓ ↓ ↓ ↓ + SCAN_FAILED (可删除) REJECTED YANKED + ↓ ↓ + (可删除) (可删除) +``` + +### 2.3 权限模型变更 + +**核心原则**:权限只和 status 相关,visibility 只影响状态流转。 + +--- + +## 3. 版本状态定义 + +### 3.1 状态枚举 + +```java +public enum SkillVersionStatus { + DRAFT, // 草稿,编辑中 + SCANNING, // 安全扫描中 + SCAN_FAILED, // 扫描失败 + UPLOADED, // 已上传,未提交审核(新增) + PENDING_REVIEW, // 等待审核 + PUBLISHED, // 已发布 + REJECTED, // 审核拒绝 + YANKED // 已撤回 +} +``` + +### 3.2 状态语义 + +| 状态 | 含义 | 文件状态 | 可下载 | 可编辑 | 有检测报告 | +|------|------|---------|-------|-------|----------| +| DRAFT | 草稿,编辑中 | 可能不完整 | 否 | 是 | 否 | +| SCANNING | 安全扫描中 | 完整 | 否 | 否 | 否 | +| SCAN_FAILED | 扫描失败 | 完整 | 否 | 是 | 是(失败) | +| UPLOADED | 已上传,扫描通过 | 完整 | owner | 否 | 是 | +| PENDING_REVIEW | 审核中 | 完整 | owner | 否 | 是 | +| PUBLISHED | 已发布 | 完整 | 看 visibility | 否 | 是 | +| REJECTED | 审核拒绝 | 完整 | 否 | 是 | 是 | +| YANKED | 已撤回 | 完整 | 否 | 否 | 是 | + +--- + +## 4. 发布流程设计 + +### 4.1 发布路径 + +| visibility | 发布后初始状态 | 是否创建审核任务 | +|------------|--------------|----------------| +| PRIVATE | UPLOADED | 否 | +| NAMESPACE_ONLY | PENDING_REVIEW | 是 | +| PUBLIC | PENDING_REVIEW | 是 | + +### 4.2 PRIVATE skill 完整生命周期 + +``` +用户发布 PRIVATE skill + ↓ +状态:SCANNING(安全扫描中) + ↓ +扫描通过 + ↓ +状态:UPLOADED +visibility:PRIVATE + ↓ +owner 可下载/安装/测试 +市场不可见 +管理员可见(用于审计) +已有检测报告 + ↓ +owner 测试满意,确认发布(confirm-publish) + ↓ +状态:PUBLISHED +visibility:PRIVATE(正式私有版本) + ↓ +owner 可下载/安装 +市场不可见 + ↓ +用户想公开,提交审核 + ↓ +状态:PENDING_REVIEW +requestedVisibility:PUBLIC + ↓ +owner 仍可下载/测试 + ↓ +审核通过 + ↓ +状态:PUBLISHED +visibility:PUBLIC(不再是 PRIVATE) + ↓ +市场可见,所有人可下载 +``` + +### 4.3 PUBLIC/NAMESPACE_ONLY skill 生命周期 + +``` +用户发布 PUBLIC/NAMESPACE_ONLY skill + ↓ +状态:PENDING_REVIEW + ↓ +owner 可下载/测试 + ↓ +审核通过 + ↓ +状态:PUBLISHED +visibility:PUBLIC 或 NAMESPACE_ONLY + ↓ +市场可见(受 visibility 控制) +``` + +--- + +## 5. 权限矩阵 + +### 5.1 status 决定下载权限 + +| status | 市场可见 | 可下载 | +|--------|---------|-------| +| DRAFT | 否 | 否 | +| SCANNING | 否 | 否 | +| SCAN_FAILED | 否 | 否 | +| UPLOADED | 否 | owner | +| PENDING_REVIEW | 否 | owner | +| PUBLISHED | 看 visibility | 看 visibility | +| REJECTED | 否 | 否 | +| YANKED | 否 | 否 | + +### 5.2 PUBLISHED 状态下,visibility 决定可见性 + +| visibility | 市场可见 | 可下载 | +|------------|---------|-------| +| PUBLIC | 是 | 所有人 | +| NAMESPACE_ONLY | 命名空间内 | 命名空间成员 | +| PRIVATE | 否 | owner | + +### 5.3 AstronClaw 安装判断规则 + +``` +可安装 = + skill.status == ACTIVE + AND skill.hidden == false + AND 存在至少一个可下载版本 + AND 该版本 bundleReady == true + +可下载版本判断: + - UPLOADED/PENDING_REVIEW:仅 owner + - PUBLISHED:按 visibility 规则 +``` + +--- + +## 6. 状态流转详细设计 + +### 6.1 状态转换表 + +| 当前状态 | 操作 | 目标状态 | 说明 | +|---------|------|---------|------| +| DRAFT | 上传包 | SCANNING | 开始安全扫描 | +| SCANNING | 扫描通过 | UPLOADED 或 PENDING_REVIEW | 看 visibility | +| SCANNING | 扫描失败 | SCAN_FAILED | - | +| SCAN_FAILED | 重新上传 | SCANNING | - | +| UPLOADED | 提交审核 | PENDING_REVIEW | 新增操作 | +| UPLOADED | 确认发布 | PUBLISHED | PRIVATE skill 正式发布,不触发新扫描 | +| UPLOADED | 重新上传 | SCANNING | 允许重新上传 | +| UPLOADED | 删除 | (删除) | 允许删除,未正式发布 | +| PENDING_REVIEW | 审核通过 | PUBLISHED | - | +| PENDING_REVIEW | 审核拒绝 | REJECTED | - | +| PENDING_REVIEW | 撤回审核 | UPLOADED | 变更:原为 DRAFT | +| PUBLISHED | Yank | YANKED | - | +| REJECTED | 重新上传 | SCANNING | - | + +### 6.2 状态机图 + +``` + ┌─────────────────────────────────────────┐ + │ 上传包 │ + └─────────────────────────────────────────┘ + ↓ + ┌───────────────┐ + │ SCANNING │ + └───────────────┘ + / \ + 扫描通过 / \ 扫描失败 + / \ + ┌────────────────────────┐ ┌───────────────┐ + │ visibility=PRIVATE │ │ SCAN_FAILED │ + │ → UPLOADED │ └───────────────┘ + │ visibility=PUBLIC/ │ │ + │ NAMESPACE_ONLY │ │ 重新上传 + │ → PENDING_REVIEW │ ↓ + └────────────────────────┘ ┌───────────────┐ + │ │ SCANNING │ + ↓ └───────────────┘ + ┌────────────────────────┐ + │ UPLOADED │◄────────────────────────┐ + │ (PRIVATE skill 专属) │ │ + │ 已有检测报告 │ │ + └────────────────────────┘ │ + / \ │ + 确认发布 / \ 提交审核 │ + (不触发新扫描) / \ │ + / \ │ + ↓ ↓ │ + ┌───────────────────┐ ┌───────────────────┐ │ + │ PUBLISHED │ │ PENDING_REVIEW │ │ + │ visibility=PRIVATE│ └───────────────────┘ │ + └───────────────────┘ │ │ + │ │ │ + │ 提交审核 │ 审核通过 │ + ↓ ↓ │ + ┌───────────────────┐ ┌───────────────────┐ │ + │ PENDING_REVIEW │ │ PUBLISHED │ │ + └───────────────────┘ │ visibility=PUBLIC │ │ + │ │ 或 NAMESPACE_ONLY │ │ + │ └───────────────────┘ │ + │ 撤回审核 │ │ + └──────────────────────┘ │ + (进入 UPLOADED) │ + │ + ┌───────────────────┐ │ + │ REJECTED │────────────────────────────────────────┘ + └───────────────────┘ 重新上传 + │ + │ 删除 + ↓ + (删除) +``` + +--- + +## 7. 新增接口设计 + +说明: + +以下接口属于开源 `Core` 为 SaaS 提供的基础状态机能力。对 `AstronClaw` 而言,后续仍应统一通过 `SkillHub SaaS` 的 `AstronClaw Adapter` 消费这些能力,而不是直接绑定这些开源接口路径。 + +### 7.1 提交审核接口 + +**接口**:`POST /api/v1/skills/{namespace}/{slug}/submit-review` + +**请求参数**: +```json +{ + "version": "1.0.0", + "targetVisibility": "PUBLIC" +} +``` + +**前置条件**: +- 版本状态为 UPLOADED +- 操作者为 skill owner 或 namespace ADMIN/OWNER + +**执行效果**: +- 版本状态 → PENDING_REVIEW +- `requestedVisibility` 设为目标可见性 +- 创建审核任务 + +**响应**: +```json +{ + "code": 0, + "data": { + "versionId": 100, + "status": "PENDING_REVIEW", + "requestedVisibility": "PUBLIC" + } +} +``` + +### 7.2 确认发布接口(PRIVATE skill) + +**接口**:`POST /api/v1/skills/{namespace}/{slug}/confirm-publish` + +**请求参数**: +```json +{ + "version": "1.0.0" +} +``` + +**前置条件**: +- 版本状态为 UPLOADED +- skill.visibility = PRIVATE +- 操作者为 skill owner + +**执行效果**: +- 版本状态 → PUBLISHED +- visibility 保持 PRIVATE +- **不触发新的扫描**,复用 UPLOADED 时的扫描结果 +- 未来可扩展:加入"发布扫描"功能 + +**响应**: +```json +{ + "code": 0, + "data": { + "skillId": 42, + "versionId": 100, + "status": "PUBLISHED", + "visibility": "PRIVATE" + } +} +``` + +--- + +## 8. 删除 / 隐藏 / 归档 / YANKED 语义规则 + +### 8.1 操作语义总表 + +| 操作 | 触发方式 | 可逆 | 市场可见 | 可新装 | 已装保留 | 可卸载 | slug 可复用 | +|------|---------|------|---------|-------|---------|-------|-----------| +| **硬删除 skill** | owner 或 SUPER_ADMIN | 否 | 否 | 否 | 是 | 是 | 是 | +| **归档 skill** | owner / namespace admin | 是 | 否 | 否 | 是 | 是 | 否 | +| **隐藏 skill** | 管理员 | 是 | 否 | 否 | 是 | 是 | 否 | +| **Yank 版本** | owner / namespace admin | 否 | 否 | 否 | 是 | 是 | N/A | + +### 8.2 Yank 版本 + +**定义**:YANK 是"撤回已发布版本"的操作,用于将一个已发布的版本从可用状态移除。 + +**触发条件**: +- owner 或 namespace ADMIN/OWNER 对 PUBLISHED 状态的版本执行 yank + +**执行效果**: +- `version.status` → `YANKED`(不可逆,无 un-yank 操作) +- `version.downloadReady` → `false` +- 记录 `yankedAt`、`yankedBy`、`yankReason` +- 如果该版本是 `skill.latestVersionId` 指向的版本: + - 自动回退到上一个 PUBLISHED 版本 + - 如果没有其他 PUBLISHED 版本,`latestVersionId` → `null` + +**对 AstronClaw 的影响**: +- 已安装实例不受影响 +- 无法新装该版本 +- 升级场景:目标版本被 yank → 升级失败 + +对接原则: +- 上述语义应由 SaaS Adapter 原样继承并稳定对外提供 +- AstronClaw 通过 Adapter 感知这些状态,不直接绑定开源返回形态 + +**补救方式**: +- 不能 un-yank +- 只能发布新版本(rerelease 或重新上传) + +--- + +## 9. 同名冲突规则 + +### 9.1 唯一性约束 + +数据库约束:`UNIQUE(namespace_id, slug, owner_id)` + +含义: +- 同一 namespace 下,不同 owner 可以有相同 slug +- 同一 namespace 下,同一 owner 只能有一个相同 slug 的 skill + +### 9.2 冲突规则设计原则 + +**核心原则**:只有 PUBLISHED 状态才会阻塞同名发布,但区分 visibility。 + +| 对方状态 | 我发布同名 PRIVATE | 我发布同名 PUBLIC | 说明 | +|---------|-------------------|------------------|------| +| UPLOADED | ✅ 允许 | ✅ 允许 | 多个 UPLOADED 可共存 | +| PENDING_REVIEW | ✅ 允许 | ✅ 允许 | 还未正式发布 | +| PRIVATE + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 只允许一个正式私有版本 | +| PUBLIC + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 市场已占用 | + +### 9.3 冲突规则表(详细) + +| 场景 | 是否允许 | 说明 | +|------|---------|------| +| 同 namespace,同 slug,同 owner | 允许(复用) | 新版本挂到已有 skill 下 | +| 同 namespace,同 slug,不同 owner,对方只有 UPLOADED | 允许 | 多个 UPLOADED 可共存测试 | +| 同 namespace,同 slug,不同 owner,对方只有 PENDING_REVIEW | 允许 | 还未正式发布 | +| 同 namespace,同 slug,不同 owner,对方有 PRIVATE + PUBLISHED | 拒绝 | 只允许一个正式私有版本 | +| 同 namespace,同 slug,不同 owner,对方有 PUBLIC/NAMESPACE_ONLY + PUBLISHED | 拒绝 | 市场已占用 | +| 不同 namespace,同 slug | 允许 | namespace 隔离 | + +### 9.4 完整流程示例 + +``` +用户 A 发布 PRIVATE `ns/my-skill` + ↓ +状态:UPLOADED + ↓ +用户 B 发布 PRIVATE `ns/my-skill` + ↓ +状态:UPLOADED ✅ 允许(多个 UPLOADED 可共存) + ↓ +用户 A 确认发布 → PRIVATE + PUBLISHED ✅ 允许 + ↓ +用户 B 确认发布 → ❌ 被拒绝 + ↓ +错误信息:error.skill.publish.nameConflict.private + ↓ +用户 B 可以: + 1. 改名发布 + 2. 等用户 A 删除/归档后再发布 + 3. 提交审核变成 PUBLIC(如果 A 是 PRIVATE) +``` + +### 9.5 代码改动 + +**文件**:`SkillPublishService.java` + +```java +// 冲突检查逻辑(第 230-242 行) +for (Skill existing : existingSkills) { + if (!existing.getOwnerId().equals(publisherId)) { + // 检查是否有 PUBLISHED 版本 + boolean hasPublished = !skillVersionRepository + .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) + .isEmpty(); + + if (hasPublished) { + // PUBLISHED 版本存在,无论 visibility 如何都拒绝 + // 因为只允许一个 PRIVATE + PUBLISHED 或 PUBLIC + PUBLISHED + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } + } + } +} +``` + +### 9.6 错误信息 + +| 错误码 | 说明 | +|-------|------| +| `error.skill.publish.nameConflict` | 已有同名 PUBLIC/NAMESPACE_ONLY skill 发布 | +| `error.skill.publish.nameConflict.private` | 已有同名 PRIVATE skill 正式发布 | + +--- + +## 10. package_name / runtime 规则 + +### 10.1 当前实现 + +- `package_name` 不是 Core 的结构化字段 +- 存储在 `skill_version.parsedMetadataJson` JSONB 字段中 +- 由 skill 作者在 SKILL.md frontmatter 中定义 + +### 10.2 SaaS Adapter 职责 + +- 从 `parsedMetadataJson` 中提取 `package_name` +- 作为顶层字段返回给 AstronClaw +- 可选:检查跨 skill 的 package_name 唯一性 +- 统一封装 `submit-review`、`confirm-publish`、删除、查询等 Core 能力,对 AstronClaw 暴露稳定接口 + +### 10.3 规则建议 + +| 规则 | 建议 | +|------|------| +| 格式 | 建议使用 `namespace__slug` 格式,避免冲突 | +| 跨版本稳定性 | 同一 skill 跨版本应保持 package_name 一致 | +| 唯一性 | SaaS Adapter 可检查并警告冲突,但不强制阻止 | + +--- + +## 11. 代码改动清单 + +说明: + +以下改动属于开源 `Core` 的规则实现,用于给 SaaS 封装层提供稳定能力基线;不等同于直接向 AstronClaw 暴露这些开源接口。 + +### 11.1 枚举新增 + +**文件**:`SkillVersionStatus.java` + +```java +public enum SkillVersionStatus { + DRAFT, + SCANNING, + SCAN_FAILED, + UPLOADED, // 新增 + PENDING_REVIEW, + PUBLISHED, + REJECTED, + YANKED +} +``` + +### 11.2 发布逻辑改动 + +**文件**:`SkillPublishService.java` + +```java +// 第 279-285 行,改为 +if (visibility == SkillVisibility.PRIVATE) { + version.setStatus(SkillVersionStatus.UPLOADED); + version.setPublishedAt(currentTime()); + // 不创建审核任务 +} else if (autoPublish) { + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(currentTime()); +} else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + // 创建审核任务 +} +``` + +### 11.3 撤回审核改动 + +**文件**:`SkillGovernanceService.java` + +```java +// withdrawPendingVersion 方法,改为 +skillVersion.setStatus(SkillVersionStatus.UPLOADED); // 原为 DRAFT +``` + +### 11.4 下载权限改动 + +**文件**:`SkillDownloadService.java`、`SkillQueryService.java` + +```java +// UPLOADED 和 PENDING_REVIEW 状态允许 owner 下载 +private boolean canDownload(SkillVersion version, Skill skill, String currentUserId) { + return switch (version.getStatus()) { + case UPLOADED, PENDING_REVIEW -> skill.getOwnerId().equals(currentUserId); + case PUBLISHED -> true; // 按 visibility 判断 + default -> false; + }; +} +``` + +### 11.5 新增服务 + +**文件**:`SkillReviewSubmitService.java`(新增) + +- 实现 UPLOADED 版本提交审核逻辑 + +### 11.6 新增控制器 + +**文件**:`SkillReviewSubmitController.java`(新增) + +- 暴露 `POST /{namespace}/{slug}/submit-review` 接口 +- 暴露 `POST /{namespace}/{slug}/confirm-publish` 接口 + +### 11.7 管理员可见性 + +**文件**:`VisibilityChecker.java` + +- SUPER_ADMIN 可以看到所有 skill,包括 UPLOADED 状态 + +### 11.8 数据库迁移 + +**文件**:新增迁移脚本 + +- 更新 `skill_version_status` 枚举类型,添加 UPLOADED 值 + +--- + +## 12. 阻塞上线条件 + +| 问题 | 严重程度 | 状态 | +|------|---------|------| +| 新增 UPLOADED 状态 | 高 | 待实现 | +| PRIVATE skill 发布逻辑改动 | 高 | 待实现 | +| 提交审核接口 | 高 | 待实现 | +| 撤回审核后进入 UPLOADED | 中 | 待实现 | +| 同名冲突检查补全 | 中 | 待实现 | +| 管理员可见 UPLOADED skill | 低 | 待实现 | +| package_name 唯一性检查 | 低 | 可选 | + +--- + +## 13. 对老版本的影响 + +### 13.1 数据兼容性 + +| 影响点 | 分析 | 需要处理 | +|--------|------|---------| +| 老版本数据 | 不受影响,状态不变 | 否 | +| 数据库枚举 | 需添加 UPLOADED 值 | 是 | +| API 兼容性 | 新接口是新增,不影响老接口 | 否 | + +### 13.2 状态流转影响 + +| 场景 | 老逻辑 | 新逻辑 | 影响 | +|------|--------|--------|------| +| 老版本撤回审核 | PENDING_REVIEW → DRAFT | PENDING_REVIEW → UPLOADED | 前端需适配新状态 | +| 老版本删除 | DRAFT/REJECTED/SCAN_FAILED 可删 | UPLOADED 也可删 | 需更新代码判断 | + +### 13.3 代码改动点 + +**文件**:`SkillGovernanceService.java` + +**1. 删除版本逻辑**(第163-166行): +```java +// 原代码 +if (version.getStatus() != SkillVersionStatus.DRAFT + && version.getStatus() != SkillVersionStatus.REJECTED + && version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); +} + +// 改为:允许删除 UPLOADED 状态 +if (version.getStatus() != SkillVersionStatus.DRAFT + && version.getStatus() != SkillVersionStatus.REJECTED + && version.getStatus() != SkillVersionStatus.SCAN_FAILED + && version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); +} +``` + +**2. 撤回审核逻辑**(第245行): +```java +// 原代码 +version.setStatus(SkillVersionStatus.DRAFT); + +// 改为 +version.setStatus(SkillVersionStatus.UPLOADED); +``` + +### 13.4 前端适配 + +| 状态 | 前端展示建议 | +|------|-------------| +| UPLOADED | "已上传" 或 "待确认" | +| 可删除状态 | DRAFT、SCAN_FAILED、REJECTED、UPLOADED | +| 可编辑状态 | DRAFT、SCAN_FAILED、REJECTED | + +### 13.5 迁移策略 + +1. **数据库迁移**:添加 UPLOADED 枚举值 +2. **代码部署**:先部署后端,再部署前端 +3. **老数据处理**:无需处理,老版本状态保持不变 +4. **回滚方案**:如需回滚,UPLOADED 状态的版本按 DRAFT 处理 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 16b7d2e44..b590fa22d 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 @@ -5,8 +5,10 @@ import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.ConfirmPublishRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; +import com.iflytek.skillhub.dto.SubmitReviewRequest; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import jakarta.validation.Valid; @@ -118,4 +120,39 @@ public ApiResponse rereleaseVersion(@PathVariabl userNsRoles, AuditRequestContext.from(httpRequest))); } + + @PostMapping("/{namespace}/{slug}/submit-review") + public ApiResponse submitForReview(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody SubmitReviewRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.submitForReview( + namespace, + slug, + request.version(), + request.targetVisibility(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{namespace}/{slug}/confirm-publish") + public ApiResponse confirmPublish(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody ConfirmPublishRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.confirmPublish( + namespace, + slug, + request.version(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java new file mode 100644 index 000000000..cfcb29918 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request to confirm publish for a PRIVATE skill version. + */ +public record ConfirmPublishRequest( + @NotBlank(message = "Version is required") + String version +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java new file mode 100644 index 000000000..817e9c970 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Request to submit a skill version for review. + */ +public record SubmitReviewRequest( + @NotBlank(message = "Version is required") + String version, + + @NotBlank(message = "Target visibility is required") + @Pattern(regexp = "PUBLIC|NAMESPACE_ONLY", message = "Target visibility must be PUBLIC or NAMESPACE_ONLY") + String targetVisibility +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 4432d0605..6cca39525 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -254,4 +254,36 @@ public NamespaceResponse restoreNamespace(String slug, AuditRequestContext auditContext) { return namespacePortalCommandAppService.restoreNamespace(slug, userId, auditContext); } + + public SkillLifecycleMutationResponse submitForReview(String namespace, + String slug, + String version, + String targetVisibility, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.submitForReview( + namespace, + slug, + version, + targetVisibility, + userId, + userNsRoles, + auditContext); + } + + public SkillLifecycleMutationResponse confirmPublish(String namespace, + String slug, + String version, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.confirmPublish( + namespace, + slug, + version, + userId, + userNsRoles, + auditContext); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index fda05a726..5670ebef9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; @@ -31,6 +32,7 @@ public class SkillLifecycleAppService { private final SkillGovernanceService skillGovernanceService; private final ReviewService reviewService; private final SkillPublishService skillPublishService; + private final SkillReviewSubmitService skillReviewSubmitService; private final AuditLogService auditLogService; private final SkillSlugResolutionService skillSlugResolutionService; @@ -39,6 +41,7 @@ public SkillLifecycleAppService(NamespaceRepository namespaceRepository, SkillGovernanceService skillGovernanceService, ReviewService reviewService, SkillPublishService skillPublishService, + SkillReviewSubmitService skillReviewSubmitService, AuditLogService auditLogService, SkillSlugResolutionService skillSlugResolutionService) { this.namespaceRepository = namespaceRepository; @@ -46,6 +49,7 @@ public SkillLifecycleAppService(NamespaceRepository namespaceRepository, this.skillGovernanceService = skillGovernanceService; this.reviewService = reviewService; this.skillPublishService = skillPublishService; + this.skillReviewSubmitService = skillReviewSubmitService; this.auditLogService = auditLogService; this.skillSlugResolutionService = skillSlugResolutionService; } @@ -171,6 +175,74 @@ public SkillLifecycleMutationResponse rereleaseVersion(String namespace, ); } + @Transactional + public SkillLifecycleMutationResponse submitForReview(String namespace, + String slug, + String version, + String targetVisibility, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillReviewSubmitService.submitForReview( + skill.getId(), + skillVersion.getId(), + com.iflytek.skillhub.domain.skill.SkillVisibility.valueOf(targetVisibility), + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "SUBMIT_REVIEW", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\",\"targetVisibility\":\"" + targetVisibility + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "SUBMIT_REVIEW", + "PENDING_REVIEW" + ); + } + + @Transactional + public SkillLifecycleMutationResponse confirmPublish(String namespace, + String slug, + String version, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + SkillVersion skillVersion = findVersion(skill.getId(), version); + skillReviewSubmitService.confirmPublish( + skill.getId(), + skillVersion.getId(), + userId, + normalizeRoles(userNamespaceRoles) + ); + auditLogService.record( + userId, + "CONFIRM_PUBLISH", + "SKILL_VERSION", + skillVersion.getId(), + null, + auditContext.clientIp(), + auditContext.userAgent(), + "{\"version\":\"" + version.replace("\"", "\\\"") + "\"}" + ); + return new SkillLifecycleMutationResponse( + skill.getId(), + skillVersion.getId(), + "CONFIRM_PUBLISH", + "PUBLISHED" + ); + } + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index eba859f54..6e7541862 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -133,7 +133,12 @@ error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace +error.skill.publish.nameConflict.private=A private skill with name ''{0}'' has already been published in this namespace error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace +error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be submitted for review +error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed +error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish +error.skill.version.notDownloadable=Version ''{0}'' is not available for download # Profile update error.profile.displayName.length=Display name must be between 2 and 32 characters diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index d220e409b..541770db9 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -133,7 +133,12 @@ error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SU error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交 +error.skill.publish.nameConflict.private=该命名空间下已存在名为"{0}"的已发布私有技能,无法提交 error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能 +error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无法提交审核 +error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布 +error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能 +error.skill.version.notDownloadable=版本"{0}"不可下载 # 用户资料修改 error.profile.displayName.length=昵称长度需在 2-32 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index 532150111..e2c101462 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.AdminSkillActionRequest; import org.junit.jupiter.api.Test; @@ -33,6 +34,7 @@ class SkillLifecycleAppServiceTest { private final SkillGovernanceService skillGovernanceService = mock(SkillGovernanceService.class); private final ReviewService reviewService = mock(ReviewService.class); private final SkillPublishService skillPublishService = mock(SkillPublishService.class); + private final SkillReviewSubmitService skillReviewSubmitService = mock(SkillReviewSubmitService.class); private final AuditLogService auditLogService = mock(AuditLogService.class); private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class); private final SkillLifecycleAppService service = new SkillLifecycleAppService( @@ -41,6 +43,7 @@ class SkillLifecycleAppServiceTest { skillGovernanceService, reviewService, skillPublishService, + skillReviewSubmitService, auditLogService, skillSlugResolutionService ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 195510a5e..fb24451f1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -105,7 +106,12 @@ public void processScanResult(Long versionId, ScannerType scannerType, SecurityS audit.setScannedAt(Instant.now(Clock.systemUTC())); auditRepository.save(audit); - version.setStatus(SkillVersionStatus.PENDING_REVIEW); + // Set status based on requestedVisibility + if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) { + version.setStatus(SkillVersionStatus.UPLOADED); + } else { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + } skillVersionRepository.save(version); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 78fa2bb16..21978985b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,6 +4,7 @@ public enum SkillVersionStatus { DRAFT, SCANNING, SCAN_FAILED, + UPLOADED, PENDING_REVIEW, PUBLISHED, REJECTED, 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 259d9e18c..3bb194ff3 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 @@ -164,12 +164,15 @@ public DownloadResult downloadReviewVersion(Skill skill, SkillVersion version) { private DownloadResult downloadVersion(Skill skill, SkillVersion version) { assertPublishedAccessible(skill); - assertPublishedVersion(version); + assertDownloadableVersion(skill, version); DownloadResult result = buildDownloadResult(skill, version); - skillRepository.incrementDownloadCount(skill.getId()); - skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + // Only increment download count for PUBLISHED versions + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + } return result; } @@ -292,9 +295,21 @@ private void assertPublishedAccessible(Skill skill) { } } - private void assertPublishedVersion(SkillVersion version) { - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); + /** + * Asserts that the version can be downloaded. + * - PUBLISHED: anyone with skill access can download + * - UPLOADED/PENDING_REVIEW: only skill owner can download + */ + private void assertDownloadableVersion(Skill skill, SkillVersion version) { + switch (version.getStatus()) { + case PUBLISHED -> { + // Anyone with skill access can download published versions + } + case UPLOADED, PENDING_REVIEW -> { + // Only owner can download UPLOADED/PENDING_REVIEW versions + // Note: This check is already done in assertCanDownload via visibilityChecker + } + default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); } } } 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 9f0be2bf7..0dfcb5f35 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 @@ -162,7 +162,8 @@ public void deleteVersion(Skill skill, assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED - && version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + && version.getStatus() != SkillVersionStatus.SCAN_FAILED + && version.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion()); } @@ -242,7 +243,7 @@ public SkillVersion withdrawPendingVersion(Skill skill, if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) { throw new DomainBadRequestException("review.withdraw.not_pending", version.getId()); } - version.setStatus(SkillVersionStatus.DRAFT); + version.setStatus(SkillVersionStatus.UPLOADED); SkillVersion savedVersion = skillVersionRepository.save(version); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 1afb2a1c9..07ca9735f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -172,14 +172,17 @@ public PublishResult rereleasePublishedVersion( List entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion); + // Rerelease follows the same visibility-based workflow as normal publish: + // - PRIVATE skills go to UPLOADED status + // - PUBLIC/NAMESPACE_ONLY skills go to PENDING_REVIEW (or UPLOADED after scan) return publishFromEntriesInternal( resolveNamespaceSlug(skill.getNamespaceId()), entries, publisherId, skill.getVisibility(), Set.of(), - true, - true, + false, // confirmWarnings=false: no warnings to confirm for rerelease + false, // forceAutoPublish=false: respect visibility rules true ); } @@ -250,13 +253,19 @@ private PublishResult publishFromEntriesInternal( List existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug); // Check if any other owner's skill has published versions + // Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed) for (Skill existing : existingSkills) { if (!existing.getOwnerId().equals(publisherId)) { boolean hasPublished = !skillVersionRepository .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) .isEmpty(); if (hasPublished) { - throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + // Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts + if (existing.getVisibility() == SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug); + } else { + throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug); + } } } } @@ -274,12 +283,13 @@ private PublishResult publishFromEntriesInternal( } // 6c. Auto-withdraw pending review versions + // When publishing a new version, existing PENDING_REVIEW versions are withdrawn to UPLOADED status List pendingVersions = skillVersionRepository .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW); for (SkillVersion pending : pendingVersions) { reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) .ifPresent(reviewTaskRepository::delete); - pending.setStatus(SkillVersionStatus.DRAFT); + pending.setStatus(SkillVersionStatus.UPLOADED); skillVersionRepository.save(pending); } @@ -300,6 +310,10 @@ private PublishResult publishFromEntriesInternal( if (autoPublish) { version.setStatus(SkillVersionStatus.PUBLISHED); version.setPublishedAt(currentTime()); + } else if (visibility == SkillVisibility.PRIVATE) { + // PRIVATE skill goes to UPLOADED status, no review task created + version.setStatus(SkillVersionStatus.UPLOADED); + version.setPublishedAt(currentTime()); } else { version.setStatus(SkillVersionStatus.PENDING_REVIEW); } @@ -376,7 +390,8 @@ private PublishResult publishFromEntriesInternal( version.setDownloadReady(!skillFiles.isEmpty()); skillVersionRepository.save(version); - if (!autoPublish) { + // Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE) + if (!autoPublish && visibility != SkillVisibility.PRIVATE) { ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask); eventPublisher.publishEvent(new ReviewSubmittedEvent( @@ -386,15 +401,18 @@ private PublishResult publishFromEntriesInternal( savedReviewTask.getSubmittedBy(), savedReviewTask.getNamespaceId() )); - if (securityScanService.isEnabled()) { - securityScanService.triggerScan(version.getId(), entries, publisherId); - } + } + + // Trigger security scan for all non-autoPublish versions + if (!autoPublish && securityScanService.isEnabled()) { + securityScanService.triggerScan(version.getId(), entries, publisherId); } // 12. Update skill metadata and move the published pointer for auto-publish flows skill.setDisplayName(metadata.name()); skill.setSummary(metadata.description()); - if (autoPublish) { + if (autoPublish || visibility == SkillVisibility.PRIVATE) { + // Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status) skill.setLatestVersionId(version.getId()); skill.setVisibility(visibility); } 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 376f43b32..0a64260c4 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 @@ -333,6 +333,7 @@ public Page listVersions(String namespaceSlug, visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.UPLOADED || version.getStatus() == SkillVersionStatus.DRAFT || version.getStatus() == SkillVersionStatus.REJECTED || version.getStatus() == SkillVersionStatus.YANKED @@ -382,6 +383,7 @@ public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) { List versions = skillVersionRepository.findBySkillId(skill.getId()).stream() .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED || version.getStatus() == SkillVersionStatus.PENDING_REVIEW + || version.getStatus() == SkillVersionStatus.UPLOADED || version.getStatus() == SkillVersionStatus.DRAFT || version.getStatus() == SkillVersionStatus.REJECTED || version.getStatus() == SkillVersionStatus.YANKED @@ -689,15 +691,18 @@ private int lifecycleListPriority(SkillVersionStatus status) { if (status == SkillVersionStatus.SCAN_FAILED) { return 1; } - if (status == SkillVersionStatus.REJECTED) { + if (status == SkillVersionStatus.UPLOADED) { return 2; } - if (status == SkillVersionStatus.PENDING_REVIEW) { + if (status == SkillVersionStatus.REJECTED) { return 3; } - if (status == SkillVersionStatus.DRAFT) { + if (status == SkillVersionStatus.PENDING_REVIEW) { return 4; } + if (status == SkillVersionStatus.DRAFT) { + return 5; + } if (status == SkillVersionStatus.YANKED) { return 5; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java new file mode 100644 index 000000000..1ce26fd38 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.*; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Map; + +/** + * Service for submitting skill versions for review and confirming private publishes. + * + *

This service handles two key workflows for UPLOADED skill versions: + *

    + *
  • submitForReview: Transitions an UPLOADED version to PENDING_REVIEW status, + * creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.
  • + *
  • confirmPublish: Transitions an UPLOADED version directly to PUBLISHED status + * for PRIVATE skills without requiring review.
  • + *
+ * + * @see SkillVersionStatus#UPLOADED + * @see SkillVisibility#PRIVATE + */ +@Service +public class SkillReviewSubmitService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final ReviewTaskRepository reviewTaskRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public SkillReviewSubmitService( + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + ReviewTaskRepository reviewTaskRepository, + NamespaceMemberRepository namespaceMemberRepository, + ApplicationEventPublisher eventPublisher, + Clock clock) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.reviewTaskRepository = reviewTaskRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + * + * @param skillId the skill ID + * @param versionId the version ID + * @param targetVisibility the target visibility after approval + * @param actorUserId the user performing the action + * @param userNamespaceRoles user's namespace roles + */ + @Transactional + public void submitForReview(Long skillId, Long versionId, SkillVisibility targetVisibility, + String actorUserId, Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + // Validate ownership + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + // Validate version status + if (version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion()); + } + + // Validate version belongs to skill + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.mismatch"); + } + + // Update version + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setRequestedVisibility(targetVisibility); + skillVersionRepository.save(version); + + // Create review task + ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId); + reviewTaskRepository.save(reviewTask); + } + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + * + * @param skillId the skill ID + * @param versionId the version ID + * @param actorUserId the user performing the action + * @param userNamespaceRoles user's namespace roles + */ + @Transactional + public void confirmPublish(Long skillId, Long versionId, String actorUserId, + Map userNamespaceRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + // Validate ownership + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + + // Validate skill visibility is PRIVATE + if (skill.getVisibility() != SkillVisibility.PRIVATE) { + throw new DomainBadRequestException("error.skill.confirm.notPrivate"); + } + + // Validate version status + if (version.getStatus() != SkillVersionStatus.UPLOADED) { + throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion()); + } + + // Validate version belongs to skill + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.mismatch"); + } + + // Update version to PUBLISHED + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.now(clock)); + skillVersionRepository.save(version); + + // Update skill's latest version + skill.setLatestVersionId(versionId); + skill.setUpdatedBy(actorUserId); + skillRepository.save(skill); + } + + private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { + NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + boolean canManage = skill.getOwnerId().equals(actorUserId) + || namespaceRole == NamespaceRole.ADMIN + || namespaceRole == NamespaceRole.OWNER; + if (!canManage) { + throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); + } + } +} 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 f2b254fa6..b6f3faff4 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 @@ -156,7 +156,7 @@ void yankVersion_setsYankedStatus() { } @Test - void withdrawPendingVersion_demotesVersionToDraft() { + void withdrawPendingVersion_demotesVersionToUploaded() { 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"); @@ -167,7 +167,7 @@ void withdrawPendingVersion_demotesVersionToDraft() { SkillVersion result = service.withdrawPendingVersion(skill, version, "owner"); - assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT); + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.UPLOADED); verify(skillVersionRepository).save(version); verify(skillRepository).save(skill); verify(objectStorageService, never()).deleteObject(any()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 7e2ff7c10..ae0918893 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -796,7 +796,7 @@ void testPublishFromEntries_AllowsDescriptionLongerThanPreviousDatabaseLimit() t } @Test - void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception { + void testRereleasePublishedVersion_ShouldCloneFilesAndSubmitForReview() throws Exception { String publisherId = "user-100"; Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 11L); @@ -859,11 +859,11 @@ void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Excep ); assertEquals("1.2.4", result.version().getVersion()); - assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); - assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); - assertEquals(30L, skill.getLatestVersionId()); - verify(reviewTaskRepository, never()).save(any()); - verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + // Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules) + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + // Review task should be created for PUBLIC skill + verify(reviewTaskRepository).save(any()); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); verify(skillPackageValidator).validate(argThat(entries -> entries.size() == 2 && entries.stream().anyMatch(entry -> @@ -896,6 +896,76 @@ void testRereleasePublishedVersion_ShouldRejectDuplicateTargetVersion() throws E )); } + @Test + void testRereleasePublishedVersion_PrivateSkill_ShouldGoToUploaded() throws Exception { + String publisherId = "user-100"; + Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PRIVATE); + setId(skill, 11L); + skill.setDisplayName("Demo Skill"); + skill.setSummary("Original summary"); + Namespace namespace = new Namespace("global", "Global", "owner"); + setId(namespace, 1L); + + SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); + setId(sourceVersion, 21L); + sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); + + String sourceSkillMd = """ + --- + name: Demo Skill + description: Original summary + version: 1.2.3 + --- + Hello world + """; + + SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md"); + + SkillMetadata rereleaseMetadata = new SkillMetadata( + "Demo Skill", + "Original summary", + "1.2.4", + "Hello world", + Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4")); + + when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty()); + when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile)); + when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8))); + when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 30L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + SkillPublishService.PublishResult result = service.rereleasePublishedVersion( + skill.getId(), + "1.2.3", + "1.2.4", + publisherId, + Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER) + ); + + assertEquals("1.2.4", result.version().getVersion()); + // Rerelease for PRIVATE skill should go to UPLOADED status + assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus()); + // No review task for PRIVATE skill + verify(reviewTaskRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); + // latestVersionId should be updated for PRIVATE skill + assertEquals(30L, skill.getLatestVersionId()); + } + @Test void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception { String namespaceSlug = "test-ns"; @@ -1017,8 +1087,8 @@ void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); - // Verify pending version was withdrawn to DRAFT - assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus()); + // Verify pending version was withdrawn to UPLOADED (not DRAFT, so it remains visible) + assertEquals(SkillVersionStatus.UPLOADED, pendingV1.getStatus()); verify(reviewTaskRepository).delete(pendingTask); verify(skillVersionRepository).save(pendingV1); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java new file mode 100644 index 000000000..1fc266b5c --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -0,0 +1,220 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Clock; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link SkillReviewSubmitService}. + */ +@ExtendWith(MockitoExtension.class) +class SkillReviewSubmitServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private SkillReviewSubmitService service; + + @BeforeEach + void setUp() { + service = new SkillReviewSubmitService( + skillRepository, + skillVersionRepository, + reviewTaskRepository, + null, // namespaceMemberRepository not used in these tests + eventPublisher, + Clock.systemUTC() + ); + } + + @Nested + @DisplayName("submitForReview") + class SubmitForReviewTests { + + @Test + @DisplayName("should transition UPLOADED version to PENDING_REVIEW") + void shouldTransitionToPendingReview() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Map roles = Map.of(); + + // When + service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles); + + // Then + assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus()); + assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + } + + @Test + @DisplayName("should reject when version is not UPLOADED") + void shouldRejectWhenNotUploaded() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, Map.of())); + } + + @Test + @DisplayName("should reject when user is not owner") + void shouldRejectWhenNotOwner() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String ownerId = "owner-1"; + String otherUserId = "other-user"; + + Skill skill = createSkill(skillId, ownerId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainForbiddenException.class, + () -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, otherUserId, Map.of())); + } + } + + @Nested + @DisplayName("confirmPublish") + class ConfirmPublishTests { + + @Test + @DisplayName("should transition UPLOADED version to PUBLISHED for PRIVATE skill") + void shouldTransitionToPublished() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When + service.confirmPublish(skillId, versionId, userId, Map.of()); + + // Then + assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); + assertNotNull(version.getPublishedAt()); + assertEquals(versionId, skill.getLatestVersionId()); + verify(skillRepository).save(skill); + } + + @Test + @DisplayName("should reject when skill is not PRIVATE") + void shouldRejectWhenNotPrivate() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PUBLIC); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.confirmPublish(skillId, versionId, userId, Map.of())); + } + + @Test + @DisplayName("should reject when version is not UPLOADED") + void shouldRejectWhenNotUploaded() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When/Then + assertThrows(DomainBadRequestException.class, + () -> service.confirmPublish(skillId, versionId, userId, Map.of())); + } + } + + private Skill createSkill(Long id, String ownerId, Long namespaceId, SkillVisibility visibility) { + Skill skill = new Skill(namespaceId, "test-skill", ownerId, visibility); + setField(skill, "id", id); + return skill; + } + + private SkillVersion createVersion(Long id, Long skillId, SkillVersionStatus status) { + SkillVersion version = new SkillVersion(skillId, "1.0.0", "user-1"); + setField(version, "id", id); + version.setStatus(status); + return version; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 928036a0a..714111e39 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -476,6 +476,36 @@ export const skillLifecycleApi = { body: JSON.stringify({ targetVersion }), }) }, + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ + async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version, targetVisibility }), + }) + }, + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ + async confirmPublish(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version }), + }) + }, } function normalizeNamespaceSlug(namespace: string): string { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 9835b1b68..3af596c91 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -777,6 +777,7 @@ "versionStatusDraft": "Draft", "versionStatusScanning": "Scanning", "versionStatusScanFailed": "Scan Failed", + "versionStatusUploaded": "Uploaded", "versionStatusPendingReview": "Pending Review", "versionStatusPublished": "Published", "versionStatusRejected": "Rejected", @@ -825,6 +826,18 @@ "withdrawReviewSuccessTitle": "Review withdrawn", "withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.", "withdrawReviewErrorTitle": "Failed to withdraw review", + "confirmPublish": "Confirm Publish", + "confirmPublishDialogTitle": "Confirm publish", + "confirmPublishDialogDescription": "Publish version {{version}} as a private skill? It will be available for you to download and install, but not visible on the marketplace.", + "confirmPublishSuccessTitle": "Version published", + "confirmPublishSuccessDescription": "Version {{version}} has been published as a private skill.", + "confirmPublishErrorTitle": "Failed to confirm publish", + "submitReview": "Submit for Review", + "submitReviewDialogTitle": "Submit for review", + "submitReviewDialogDescription": "Submit version {{version}} for public review? Once approved, it will be visible on the marketplace.", + "submitReviewSuccessTitle": "Submitted for review", + "submitReviewSuccessDescription": "Version {{version}} has been submitted for review.", + "submitReviewErrorTitle": "Failed to submit for review", "deleteVersion": "Delete Version", "deleteVersionConfirmTitle": "Delete version", "deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 7a8e187b6..17c2eb9a2 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -777,6 +777,7 @@ "versionStatusDraft": "草稿", "versionStatusScanning": "安全扫描中", "versionStatusScanFailed": "扫描失败", + "versionStatusUploaded": "已上传", "versionStatusPendingReview": "审核中", "versionStatusPublished": "已发布", "versionStatusRejected": "已拒绝", @@ -825,6 +826,19 @@ "withdrawReviewSuccessTitle": "已撤销审核", "withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。", "withdrawReviewErrorTitle": "撤销审核失败", + "confirmPublish": "确认发布", + "confirmPublishDialogTitle": "确认发布", + "confirmPublishDialogDescription": "将版本 {{version}} 发布为私有技能?发布后您可以下载和安装,但不会在市场展示。", + "confirmPublishSuccessTitle": "版本已发布", + "confirmPublishSuccessDescription": "版本 {{version}} 已发布为私有技能。", + "confirmPublishErrorTitle": "确认发布失败", + "submitReview": "提交审核", + "submitReviewDialogTitle": "提交审核", + "submitReviewDialogDescription": "将版本 {{version}} 提交公开审核?审核通过后将在市场展示。", + "submitReviewSuccessTitle": "已提交审核", + "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。", + "submitReviewErrorTitle": "提交审核失败", + "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 74cd0c65f..5f437cd79 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -83,6 +83,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return t('mySkills.statusScanFailed') } + if (status === 'UPLOADED') { + return t('skillDetail.versionStatusUploaded') + } return status } @@ -108,6 +111,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return 'status-pill status-pill--rejected' } + if (status === 'UPLOADED') { + return 'status-pill status-pill--review' + } return 'status-pill' } diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index deed65d32..067a171fb 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -112,6 +112,8 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useRereleaseSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), useUnarchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useWithdrawSkillReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSubmitForReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useConfirmPublish: () => ({ mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/shared/hooks/use-label-queries', () => ({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 990396b4c..d28b4f75d 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -53,6 +53,8 @@ import { useRereleaseSkillVersion, useUnarchiveSkill, useWithdrawSkillReview, + useSubmitForReview, + useConfirmPublish, } from '@/shared/hooks/use-skill-queries' import { useSubmitPromotion } from '@/shared/hooks/use-user-queries' @@ -115,6 +117,8 @@ export function SkillDetailPage() { const [rereleaseTarget, setRereleaseTarget] = useState(null) const [targetVersionInput, setTargetVersionInput] = useState('') const [diffSourceVersion, setDiffSourceVersion] = useState(null) + const [confirmPublishTarget, setConfirmPublishTarget] = useState(null) + const [submitReviewTarget, setSubmitReviewTarget] = useState(null) const [diffCompareVersion, setDiffCompareVersion] = useState(null) const [isOverviewExpanded, setIsOverviewExpanded] = useState(false) const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false) @@ -260,6 +264,8 @@ export function SkillDetailPage() { const rereleaseVersionMutation = useRereleaseSkillVersion() const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) + const submitForReviewMutation = useSubmitForReview() + const confirmPublishMutation = useConfirmPublish() const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') @@ -380,6 +386,7 @@ export function SkillDetailPage() { DRAFT: t('skillDetail.versionStatusDraft'), SCANNING: t('skillDetail.versionStatusScanning'), SCAN_FAILED: t('skillDetail.versionStatusScanFailed'), + UPLOADED: t('skillDetail.versionStatusUploaded'), PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'), PUBLISHED: t('skillDetail.versionStatusPublished'), REJECTED: t('skillDetail.versionStatusRejected'), @@ -388,7 +395,7 @@ export function SkillDetailPage() { return status ? (map[status] ?? status) : '' } - const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' + const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED' const isLastVersion = versions?.length === 1 const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' @@ -529,6 +536,40 @@ export function SkillDetailPage() { } } + const handleConfirmPublish = async () => { + if (!confirmPublishTarget) { + return + } + try { + await confirmPublishMutation.mutateAsync({ namespace, slug, version: confirmPublishTarget }) + toast.success( + t('skillDetail.confirmPublishSuccessTitle'), + t('skillDetail.confirmPublishSuccessDescription', { version: confirmPublishTarget }), + ) + setConfirmPublishTarget(null) + } catch (error) { + toast.error(t('skillDetail.confirmPublishErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + + const handleSubmitForReview = async () => { + if (!submitReviewTarget) { + return + } + try { + await submitForReviewMutation.mutateAsync({ namespace, slug, version: submitReviewTarget, targetVisibility: 'PUBLIC' }) + toast.success( + t('skillDetail.submitReviewSuccessTitle'), + t('skillDetail.submitReviewSuccessDescription', { version: submitReviewTarget }), + ) + setSubmitReviewTarget(null) + } catch (error) { + toast.error(t('skillDetail.submitReviewErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + const handleOpenRerelease = (version: string) => { setRereleaseTarget(version) setTargetVersionInput(suggestNextVersion(version)) @@ -884,6 +925,24 @@ export function SkillDetailPage() { {t('skillDetail.withdrawReview')} )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )} {version.changelog && ( @@ -1370,6 +1429,32 @@ export function SkillDetailPage() { + { + if (!open) { + setConfirmPublishTarget(null) + } + }} + title={t('skillDetail.confirmPublishDialogTitle')} + description={confirmPublishTarget ? t('skillDetail.confirmPublishDialogDescription', { version: confirmPublishTarget }) : ''} + confirmText={t('skillDetail.confirmPublish')} + onConfirm={handleConfirmPublish} + /> + + { + if (!open) { + setSubmitReviewTarget(null) + } + }} + title={t('skillDetail.submitReviewDialogTitle')} + description={submitReviewTarget ? t('skillDetail.submitReviewDialogDescription', { version: submitReviewTarget }) : ''} + confirmText={t('skillDetail.submitReview')} + onConfirm={handleSubmitForReview} + /> + { diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index c72e738c5..4784ada54 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -216,3 +216,41 @@ export function useRereleaseSkillVersion() { }, }) } + +/** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ +export function useSubmitForReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version, targetVisibility }: { namespace: string; slug: string; version: string; targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY' }) => + skillLifecycleApi.submitForReview(namespace, slug, version, targetVisibility), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} + +/** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ +export function useConfirmPublish() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) => + skillLifecycleApi.confirmPublish(namespace, slug, version), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} From f70d09aac77fb9fef51101c6347d9376548777f8 Mon Sep 17 00:00:00 2001 From: xiose Date: Mon, 13 Apr 2026 09:55:41 +0800 Subject: [PATCH 2/2] feat(review): add backward compatibility for DRAFT status Support both DRAFT (legacy) and UPLOADED (new flow) status in: - SkillReviewSubmitService.submitForReview - SkillReviewSubmitService.confirmPublish - ReviewService.submitReview (both overloads) This ensures existing data with DRAFT status continues to work with the new visibility-based workflow introduced in OSS-02. --- .../skillhub/domain/review/ReviewService.java | 8 ++- .../service/SkillReviewSubmitService.java | 20 +++--- .../service/SkillReviewSubmitServiceTest.java | 62 +++++++++++++++++-- 3 files changed, 76 insertions(+), 14 deletions(-) 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 bd31218a4..56ea78845 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 @@ -101,7 +101,9 @@ public ReviewTask submitReview(Long skillVersionId, throw new DomainForbiddenException("review.submit.no_permission"); } - if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + // Support both DRAFT (legacy) and UPLOADED (new flow) status + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT + && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); } @@ -137,7 +139,9 @@ public ReviewTask submitReview(Long skillVersionId, .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); assertNamespaceActive(namespace); - if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { + // Support both DRAFT (legacy) and UPLOADED (new flow) status + if (skillVersion.getStatus() != SkillVersionStatus.DRAFT + && skillVersion.getStatus() != SkillVersionStatus.UPLOADED) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index 1ce26fd38..df8e9fd10 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -55,8 +55,10 @@ public SkillReviewSubmitService( } /** - * Submit an UPLOADED version for review. - * Transitions version status from UPLOADED to PENDING_REVIEW. + * Submit an UPLOADED or DRAFT version for review. + * Transitions version status from UPLOADED/DRAFT to PENDING_REVIEW. + * + *

Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status. * * @param skillId the skill ID * @param versionId the version ID @@ -75,8 +77,9 @@ public void submitForReview(Long skillId, Long versionId, SkillVisibility target // Validate ownership assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); - // Validate version status - if (version.getStatus() != SkillVersionStatus.UPLOADED) { + // Validate version status - support both UPLOADED (new) and DRAFT (legacy) + if (version.getStatus() != SkillVersionStatus.UPLOADED + && version.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion()); } @@ -97,7 +100,9 @@ public void submitForReview(Long skillId, Long versionId, SkillVisibility target /** * Confirm publish for a PRIVATE skill version. - * Transitions version status from UPLOADED to PUBLISHED without review. + * Transitions version status from UPLOADED/DRAFT to PUBLISHED without review. + * + *

Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status. * * @param skillId the skill ID * @param versionId the version ID @@ -120,8 +125,9 @@ public void confirmPublish(Long skillId, Long versionId, String actorUserId, throw new DomainBadRequestException("error.skill.confirm.notPrivate"); } - // Validate version status - if (version.getStatus() != SkillVersionStatus.UPLOADED) { + // Validate version status - support both UPLOADED (new) and DRAFT (legacy) + if (version.getStatus() != SkillVersionStatus.UPLOADED + && version.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java index 1fc266b5c..4f8f9565c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -88,16 +88,43 @@ void shouldTransitionToPendingReview() { } @Test - @DisplayName("should reject when version is not UPLOADED") - void shouldRejectWhenNotUploaded() { + @DisplayName("should accept DRAFT version (legacy compatibility)") + void shouldAcceptDraftForLegacyCompatibility() { // Given Long skillId = 1L; Long versionId = 100L; String userId = "user-1"; + Long namespaceId = 10L; - Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Map roles = Map.of(); + + // When + service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles); + + // Then + assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus()); + assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); + } + + @Test + @DisplayName("should reject when version is neither UPLOADED nor DRAFT") + void shouldRejectWhenNotUploadedOrDraft() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + + Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED); + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); @@ -156,6 +183,31 @@ void shouldTransitionToPublished() { verify(skillRepository).save(skill); } + @Test + @DisplayName("should transition DRAFT version to PUBLISHED for PRIVATE skill (legacy compatibility)") + void shouldTransitionDraftToPublished() { + // Given + Long skillId = 1L; + Long versionId = 100L; + String userId = "user-1"; + Long namespaceId = 10L; + + Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE); + SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT); + + when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); + + // When + service.confirmPublish(skillId, versionId, userId, Map.of()); + + // Then + assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); + assertNotNull(version.getPublishedAt()); + assertEquals(versionId, skill.getLatestVersionId()); + verify(skillRepository).save(skill); + } + @Test @DisplayName("should reject when skill is not PRIVATE") void shouldRejectWhenNotPrivate() { @@ -176,8 +228,8 @@ void shouldRejectWhenNotPrivate() { } @Test - @DisplayName("should reject when version is not UPLOADED") - void shouldRejectWhenNotUploaded() { + @DisplayName("should reject when version is neither UPLOADED nor DRAFT") + void shouldRejectWhenNotUploadedOrDraft() { // Given Long skillId = 1L; Long versionId = 100L;