feat(skill): add UPLOADED status for PRIVATE skill lifecycle

## 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
This commit is contained in:
xiose 2026-04-13 09:26:01 +08:00
parent 2def67b037
commit f55c520ebe
29 changed files with 2019 additions and 37 deletions

View file

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

View file

@ -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
```
### 配置参数说明

View file

@ -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<SkillFileResponse>` |
| 版本下载 | `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 → YANKEDdownloadReady → 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 被删除/归档/隐藏,或版本被 yankAstronClaw 本地安装快照仍可正常使用和卸载。
## 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<SkillLabelDto>` |
### 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: <skill-package.tar.gz>
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 的复杂关系。

View file

@ -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
visibilityPRIVATE
owner 可下载/安装/测试
市场不可见
管理员可见(用于审计)
已有检测报告
owner 测试满意确认发布confirm-publish
状态PUBLISHED
visibilityPRIVATE正式私有版本
owner 可下载/安装
市场不可见
用户想公开,提交审核
状态PENDING_REVIEW
requestedVisibilityPUBLIC
owner 仍可下载/测试
审核通过
状态PUBLISHED
visibilityPUBLIC不再是 PRIVATE
市场可见,所有人可下载
```
### 4.3 PUBLIC/NAMESPACE_ONLY skill 生命周期
```
用户发布 PUBLIC/NAMESPACE_ONLY skill
状态PENDING_REVIEW
owner 可下载/测试
审核通过
状态PUBLISHED
visibilityPUBLIC 或 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 处理

View file

@ -5,8 +5,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
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 class SkillLifecycleController extends BaseApiController {
userNsRoles,
AuditRequestContext.from(httpRequest)));
}
@PostMapping("/{namespace}/{slug}/submit-review")
public ApiResponse<SkillLifecycleMutationResponse> submitForReview(@PathVariable String namespace,
@PathVariable String slug,
@Valid @RequestBody SubmitReviewRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<SkillLifecycleMutationResponse> confirmPublish(@PathVariable String namespace,
@PathVariable String slug,
@Valid @RequestBody ConfirmPublishRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
return ok("response.success.updated",
governanceWorkflowAppService.confirmPublish(
namespace,
slug,
request.version(),
userId,
userNsRoles,
AuditRequestContext.from(httpRequest)));
}
}

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -254,4 +254,36 @@ public class GovernanceWorkflowAppService {
AuditRequestContext auditContext) {
return namespacePortalCommandAppService.restoreNamespace(slug, userId, auditContext);
}
public SkillLifecycleMutationResponse submitForReview(String namespace,
String slug,
String version,
String targetVisibility,
String userId,
Map<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
return skillLifecycleAppService.confirmPublish(
namespace,
slug,
version,
userId,
userNsRoles,
auditContext);
}
}

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.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 class SkillLifecycleAppService {
SkillGovernanceService skillGovernanceService,
ReviewService reviewService,
SkillPublishService skillPublishService,
SkillReviewSubmitService skillReviewSubmitService,
AuditLogService auditLogService,
SkillSlugResolutionService skillSlugResolutionService) {
this.namespaceRepository = namespaceRepository;
@ -46,6 +49,7 @@ public class SkillLifecycleAppService {
this.skillGovernanceService = skillGovernanceService;
this.reviewService = reviewService;
this.skillPublishService = skillPublishService;
this.skillReviewSubmitService = skillReviewSubmitService;
this.auditLogService = auditLogService;
this.skillSlugResolutionService = skillSlugResolutionService;
}
@ -171,6 +175,74 @@ public class SkillLifecycleAppService {
);
}
@Transactional
public SkillLifecycleMutationResponse submitForReview(String namespace,
String slug,
String version,
String targetVisibility,
String userId,
Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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)

View file

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

View file

@ -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 个字符之间

View file

@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
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
);

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.security;
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 class SecurityScanService {
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);
}

View file

@ -4,6 +4,7 @@ public enum SkillVersionStatus {
DRAFT,
SCANNING,
SCAN_FAILED,
UPLOADED,
PENDING_REVIEW,
PUBLISHED,
REJECTED,

View file

@ -164,12 +164,15 @@ public class SkillDownloadService {
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 @@ public class SkillDownloadService {
}
}
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());
}
}
}

View file

@ -162,7 +162,8 @@ public class SkillGovernanceService {
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 class SkillGovernanceService {
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);

View file

@ -172,14 +172,17 @@ public class SkillPublishService {
List<PackageEntry> 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 @@ public class SkillPublishService {
List<Skill> 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 @@ public class SkillPublishService {
}
// 6c. Auto-withdraw pending review versions
// When publishing a new version, existing PENDING_REVIEW versions are withdrawn to UPLOADED status
List<SkillVersion> 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 @@ public class SkillPublishService {
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 @@ public class SkillPublishService {
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 @@ public class SkillPublishService {
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);
}

View file

@ -333,6 +333,7 @@ public class SkillQueryService {
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 class SkillQueryService {
List<SkillVersion> 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 @@ public class SkillQueryService {
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;
}

View file

@ -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.
*
* <p>This service handles two key workflows for UPLOADED skill versions:
* <ul>
* <li><b>submitForReview</b>: Transitions an UPLOADED version to PENDING_REVIEW status,
* creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.</li>
* <li><b>confirmPublish</b>: Transitions an UPLOADED version directly to PUBLISHED status
* for PRIVATE skills without requiring review.</li>
* </ul>
*
* @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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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");
}
}
}

View file

@ -156,7 +156,7 @@ class SkillGovernanceServiceTest {
}
@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 @@ class SkillGovernanceServiceTest {
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());

View file

@ -796,7 +796,7 @@ class SkillPublishServiceTest {
}
@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 @@ class SkillPublishServiceTest {
);
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 @@ class SkillPublishServiceTest {
));
}
@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 @@ class SkillPublishServiceTest {
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);
}

View file

@ -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<Long, NamespaceRole> 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);
}
}
}

View file

@ -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<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${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<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${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 {

View file

@ -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?",

View file

@ -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}} 删除后无法恢复,确定继续吗?",

View file

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

View file

@ -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', () => ({

View file

@ -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<string | null>(null)
const [targetVersionInput, setTargetVersionInput] = useState('')
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
const [confirmPublishTarget, setConfirmPublishTarget] = useState<string | null>(null)
const [submitReviewTarget, setSubmitReviewTarget] = useState<string | null>(null)
const [diffCompareVersion, setDiffCompareVersion] = useState<string | null>(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')}
</Button>
)}
{skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && (
<Button
size="sm"
variant="outline"
onClick={() => setConfirmPublishTarget(version.version)}
>
{t('skillDetail.confirmPublish')}
</Button>
)}
{skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && (
<Button
size="sm"
variant="outline"
onClick={() => setSubmitReviewTarget(version.version)}
>
{t('skillDetail.submitReview')}
</Button>
)}
</div>
</div>
{version.changelog && (
@ -1370,6 +1429,32 @@ export function SkillDetailPage() {
</DialogContent>
</Dialog>
<ConfirmDialog
open={!!confirmPublishTarget}
onOpenChange={(open) => {
if (!open) {
setConfirmPublishTarget(null)
}
}}
title={t('skillDetail.confirmPublishDialogTitle')}
description={confirmPublishTarget ? t('skillDetail.confirmPublishDialogDescription', { version: confirmPublishTarget }) : ''}
confirmText={t('skillDetail.confirmPublish')}
onConfirm={handleConfirmPublish}
/>
<ConfirmDialog
open={!!submitReviewTarget}
onOpenChange={(open) => {
if (!open) {
setSubmitReviewTarget(null)
}
}}
title={t('skillDetail.submitReviewDialogTitle')}
description={submitReviewTarget ? t('skillDetail.submitReviewDialogDescription', { version: submitReviewTarget }) : ''}
confirmText={t('skillDetail.submitReview')}
onConfirm={handleSubmitForReview}
/>
<Dialog
open={!!diffSourceVersion && !!diffCompareVersion}
onOpenChange={(open) => {

View file

@ -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'] })
},
})
}