mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-09 22:31:14 +00:00
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:
parent
2def67b037
commit
f55c520ebe
29 changed files with 2019 additions and 37 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
### 配置参数说明
|
||||
|
|
|
|||
460
docs/oss-01-core-contract-freeze.md
Normal file
460
docs/oss-01-core-contract-freeze.md
Normal 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 → 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<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 的复杂关系。
|
||||
663
docs/oss-02-core-semantic-rules.md
Normal file
663
docs/oss-02-core-semantic-rules.md
Normal 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
|
||||
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 处理
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 个字符之间
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ public enum SkillVersionStatus {
|
|||
DRAFT,
|
||||
SCANNING,
|
||||
SCAN_FAILED,
|
||||
UPLOADED,
|
||||
PENDING_REVIEW,
|
||||
PUBLISHED,
|
||||
REJECTED,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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}} 删除后无法恢复,确定继续吗?",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue