mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
314 lines
14 KiB
Markdown
314 lines
14 KiB
Markdown
# skillhub 核心业务流
|
||
|
||
## 1 发布流程
|
||
|
||
一期采用同步发布模型:上传、校验、存储、持久化在一次请求中同步完成。前端通过异步上传(带进度条)提升用户体验,但后端处理是同步的。
|
||
|
||
> **设计决策**:一期暂不考虑异步发布(uploadId、publishId、状态轮询、异步转正等)。一期技能包为文本资源包,体积有限(上限 10MB),同步处理足以满足需求。如后续引入大文件或复杂校验流程,再考虑异步模型。
|
||
|
||
### 1.1 当前发布流程基线
|
||
|
||
```
|
||
用户提交发布
|
||
│
|
||
▼
|
||
① 身份与权限校验(用户是否为该 namespace 的 MEMBER 以上)
|
||
│
|
||
▼
|
||
② 技能包校验
|
||
- SKILL.md 存在性、frontmatter 格式
|
||
- 文件类型白名单、单文件大小限制、总包大小限制
|
||
- 版本号 semver 合法性、不与已有版本冲突
|
||
- [扩展点] PrePublishValidator 链(一期空实现)
|
||
│
|
||
▼
|
||
③ 同步写入对象存储
|
||
- 文件逐个上传到正式路径 `skills/{skillId}/{versionId}/{filePath}`,记录 SHA-256
|
||
- 生成预打包 zip 到 `packages/{skillId}/{versionId}/bundle.zip`
|
||
│
|
||
▼
|
||
④ 持久化数据
|
||
- 创建或关联 skill 记录(首次发布时创建 skill)
|
||
- 创建 skill_version(普通用户进入 `PENDING_REVIEW`,`SUPER_ADMIN` 直达 `PUBLISHED`)
|
||
- 创建 skill_file 记录
|
||
- 解析 SKILL.md frontmatter → parsed_metadata_json
|
||
- 生成 manifest_json
|
||
- 直发场景更新 skill.latest_version_id
|
||
│
|
||
▼
|
||
⑤ 同步写入审计日志
|
||
│
|
||
▼
|
||
⑥ 异步触发搜索索引写入
|
||
```
|
||
|
||
当前版本采用审核流,不再区分“Phase 2 直发”与“Phase 3 恢复审核”两套现实实现:
|
||
|
||
- 普通用户发布请求创建 `skill_version(status=PENDING_REVIEW)`
|
||
- 同步创建 `review_task(status=PENDING)`
|
||
- 审核通过后转为 `PUBLISHED`
|
||
- 审核拒绝后转为 `REJECTED`
|
||
- 撤回审核时删除 `PENDING review_task`,并将 `skill_version` 回退到 `DRAFT`
|
||
- 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员
|
||
- 上述例外必须对 Web、`/api/v1/publish`、`/api/v1/publish` 保持一致
|
||
- 若重传新版本时发现旧的 `PENDING_REVIEW` 版本,旧版本会被自动降回 `DRAFT`,再创建新的待审版本
|
||
|
||
### 1.2 生命周期读模型
|
||
|
||
当前代码中的 skill 生命周期展示与操作判断,不再依赖旧的 `latestVersionStatus`、`viewingVersionStatus` 一类拼装字段,而统一基于以下 projection:
|
||
|
||
- `headlineVersion`:当前详情页/我的技能列表主展示版本
|
||
- `publishedVersion`:当前最新可公开分发的已发布版本
|
||
- `ownerPreviewVersion`:详情 projection 中仅暴露给 owner / namespace 管理者的 `PENDING_REVIEW` 版本
|
||
- `resolutionMode`:`PUBLISHED` / `OWNER_PREVIEW` / `NONE`
|
||
|
||
业务规则:
|
||
|
||
- 公开入口只认 `publishedVersion`
|
||
- owner 进入详情页时,如果没有可用 `publishedVersion`,才允许 `headlineVersion = ownerPreviewVersion`
|
||
- 推广到全局、安装命令、公开下载都只能绑定到 `publishedVersion`
|
||
- `hidden` 是独立治理覆盖层,不属于 skill 生命周期状态机
|
||
|
||
### 1.3 Skill 可见性与角色访问矩阵
|
||
|
||
以下矩阵以当前后端实现为准,综合了 `VisibilityChecker`、`SkillQueryService`、`SkillDownloadService`、`ReviewPermissionChecker` 的实际行为。
|
||
|
||
#### 1.3.1 Skill 容器读取
|
||
|
||
| 角色 | PUBLIC | NAMESPACE_ONLY | PRIVATE | hidden 任意 visibility | 无 `publishedVersion`(`latest_version_id=null`) |
|
||
|------|--------|----------------|---------|------------------------|-----------------------------------------------|
|
||
| 匿名用户 | 可读 | 不可读 | 不可读 | 不可读 | 不可读 |
|
||
| 登录非成员 | 可读 | 不可读 | 不可读 | 不可读 | 不可读 |
|
||
| namespace MEMBER | 可读 | 可读 | 不可读 | 不可读 | 仅自己是 owner 时可读 |
|
||
| skill owner | 可读 | 可读 | 可读 | 可读 | 可读 |
|
||
| namespace ADMIN / OWNER | 可读 | 可读 | 可读 | 可读 | 不可读,除非本人也是 skill owner |
|
||
| SKILL_ADMIN / SUPER_ADMIN(仅平台角色) | 与普通登录用户一致;普通读路径不会因为平台角色自动穿透 private / hidden / unpublished |
|
||
|
||
补充:
|
||
- `hidden=true` 时,可读权限会收敛为“skill owner 或 namespace `ADMIN` / `OWNER`”
|
||
- `visibility=PUBLIC` 也不意味着未发布 skill 可见;当 `latest_version_id` 为空时,只有 owner 能读
|
||
|
||
#### 1.3.2 Version 状态读取
|
||
|
||
| 场景 / 角色 | DRAFT | PENDING_REVIEW | PUBLISHED | REJECTED | YANKED |
|
||
|------------|-------|----------------|-----------|----------|--------|
|
||
| 普通 skill 详情页主版本投影 | 不展示 | owner / namespace 管理者可作为 `ownerPreviewVersion` 展示 | 展示 | 不展示 | 不展示 |
|
||
| 普通 `listVersions` 访客 | 不可见 | 不可见 | 可见 | 不可见 | 不可见 |
|
||
| `listVersions` 的 owner / namespace ADMIN / OWNER | 可见 | 可见 | 可见 | 可见 | 可见 |
|
||
| 常规 `getVersionDetail` | 不可读 | 仅 owner 可读 | 可读 | 不可读 | 不可读 |
|
||
| 下载 / resolve / tag / 文件读取 | 不可用 | 不可用 | 可用 | 不可用 | 不可用 |
|
||
| review 详情页 | 可见完整快照 | 可见完整快照 | 可见完整快照 | 可见完整快照 | 可见完整快照 |
|
||
|
||
补充:
|
||
- `YANKED` 版本仍出现在管理视角的版本列表中,但不可下载
|
||
- `yank` 当前最新已发布版本时,会重算 `latest_version_id` 指向下一个最新的 `PUBLISHED` 版本;若没有,则置空
|
||
|
||
#### 1.3.3 审核 / 推广 / 治理动作
|
||
|
||
| 角色 | 发布新版本 | 提交审核 | 审核团队空间 | 审核全局空间 | 提交推广 | 审核推广 | hide / unhide | yank 已发布版本 |
|
||
|------|------------|----------|--------------|--------------|----------|----------|---------------|----------------|
|
||
| 匿名用户 | 不可 | 不可 | 不可 | 不可 | 不可 | 不可 | 不可 | 不可 |
|
||
| namespace MEMBER | 可发布到所属 namespace;新版本进入 `PENDING_REVIEW` | 自己作为 owner 时可;不能代别人提审 | 不可 | 不可 | 自己作为 owner 时可 | 不可 | 不可 | 不可 |
|
||
| skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 |
|
||
| namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 |
|
||
| SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 |
|
||
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 |
|
||
|
||
### 对象存储写入策略
|
||
|
||
一期同步写入正式路径,不使用临时区:
|
||
- 文件直接写入 `skills/{skillId}/{versionId}/{filePath}`
|
||
- 如果数据库事务失败,对象存储中的文件成为孤儿对象
|
||
- 定时 GC 任务:每天扫描对象存储中存在但数据库中无对应 `skill_file` 记录的文件,清理孤儿对象
|
||
- 删除 DRAFT/REJECTED 版本时,同步清理对应的对象存储文件
|
||
|
||
### CLI publish 请求规范
|
||
|
||
```
|
||
POST /api/v1/publish
|
||
Content-Type: multipart/form-data
|
||
Parts:
|
||
- file: zip 包(必需)
|
||
- namespace: 目标命名空间 slug(必需)
|
||
```
|
||
|
||
一期同步响应:服务端同步完成上传、校验、存储、持久化,返回 `200 OK` + skill_version 信息。
|
||
|
||
当前 CLI 默认行为:上传 → 进入审核。
|
||
如果调用方持有 `SUPER_ADMIN`,则直接发布为 `PUBLISHED`。
|
||
Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确的审核提示。
|
||
|
||
`/api/v1/publish` 响应:
|
||
|
||
```json
|
||
{
|
||
"data": {
|
||
"skillId": 456,
|
||
"skillVersionId": 123,
|
||
"version": "1.2.0",
|
||
"status": "PUBLISHED",
|
||
"namespace": "team-name",
|
||
"slug": "my-skill"
|
||
}
|
||
}
|
||
```
|
||
|
||
## 2 团队技能提升到全局空间(派生发布)
|
||
|
||
不直接修改原 skill 的 `namespace_id`,而是在全局空间创建新的 skill,保留来源追溯。原团队 skill 继续存在,安装坐标 `@team/skill` 不受影响。
|
||
|
||
```
|
||
团队空间技能(已发布)
|
||
│
|
||
▼
|
||
① 技能 owner 或 namespace admin 发起"提升到全局"申请
|
||
│
|
||
▼
|
||
② 创建 promotion_request (source_skill_id, source_version_id, target_namespace_id, status=PENDING)
|
||
│
|
||
▼
|
||
③ 平台管理员审核
|
||
├── 通过 →
|
||
│ ① 在全局空间创建新 skill(source_skill_id = 原 skill ID)
|
||
│ ② 复制 source_version_id 对应版本的文件和元数据到新 skill(严格使用申请时指定的版本,不取最新)
|
||
│ ③ 新 skill.visibility = PUBLIC
|
||
│ ④ promotion_request.target_skill_id = 新 skill ID,status → APPROVED
|
||
│ ⑤ 搜索索引写入新 skill,同步写入审计日志
|
||
│ (提升关系唯一事实来源是 promotion_request,UI 查询"是否已提升"通过该表判定)
|
||
│
|
||
└── 拒绝 → 记录原因,原技能不受影响
|
||
```
|
||
|
||
后续版本更新:
|
||
- 全局空间的新 skill 由其 owner 独立管理版本
|
||
- 原团队 skill 可继续独立迭代
|
||
- 两者版本不自动同步,如需同步由 owner 手动操作
|
||
|
||
提升流程当前严格绑定已发布版本:
|
||
|
||
- promotion request 的 `source_version_id` 必须指向 `publishedVersion.id`
|
||
- 不允许直接提升 `ownerPreviewVersion`
|
||
|
||
## 3 下载流程
|
||
|
||
```
|
||
下载请求
|
||
│
|
||
▼
|
||
① 校验技能状态(ACTIVE)、版本状态(PUBLISHED)
|
||
│
|
||
▼
|
||
② 可见性检查
|
||
- PUBLIC: 任何人(包括匿名用户)
|
||
- NAMESPACE_ONLY: 该 namespace 的成员(需登录)
|
||
- PRIVATE: owner 本人 + 该 namespace 的 ADMIN 以上(需登录)
|
||
│
|
||
▼
|
||
③ 返回预生成包或按文件清单打包
|
||
│
|
||
▼
|
||
④ 审计与统计
|
||
- audit_log 同步写入(记录下载人/IP/版本)
|
||
- download_count 异步更新(原子 SQL: download_count = download_count + 1)
|
||
- 匿名下载:审计记录 IP + User-Agent,不关联用户
|
||
- 已登录下载:审计记录用户 ID
|
||
```
|
||
|
||
### download_count 热点行优化预案
|
||
|
||
一期使用原子 SQL 直接更新,可接受。如出现热点行瓶颈,切换为:
|
||
1. Redis `INCR` 做实时计数(key: `skill:downloads:{skillId}`)
|
||
2. 定时任务每 5 分钟批量回写 PostgreSQL
|
||
3. 查询时合并 PostgreSQL 存量 + Redis 增量
|
||
|
||
## 4 搜索流程
|
||
|
||
```
|
||
搜索请求 (keyword, namespaceSlug?, sortBy)
|
||
│
|
||
▼
|
||
① 构建 SearchQuery
|
||
- 匿名用户:visibility 限定为 PUBLIC
|
||
- 已登录用户:根据命名空间成员关系计算可见范围
|
||
│
|
||
▼
|
||
② SearchQueryService.search(query)
|
||
│
|
||
▼
|
||
③ 返回分页结果(技能摘要 + 命名空间信息 + 评分 + 下载量)
|
||
```
|
||
|
||
## 5 收藏流程
|
||
|
||
```
|
||
收藏/取消收藏(需登录)→ 校验权限 → 写入/删除 skill_star
|
||
→ 异步更新 skill.star_count(原子 SQL)
|
||
```
|
||
|
||
## 6 评分流程
|
||
|
||
```
|
||
提交评分 (score: 1-5)(需登录)→ 校验权限 → 写入/更新 skill_rating
|
||
→ 异步重算 skill.rating_avg 和 rating_count(SELECT AVG + Redis 分布式锁防重复重算)
|
||
```
|
||
|
||
## 7 异步事件汇总
|
||
|
||
| 事件 | 触发时机 | 消费方 |
|
||
|------|---------|--------|
|
||
| `SkillPublishedEvent` | 审核通过 | 搜索索引写入 |
|
||
| `SkillYankedEvent` | 版本撤回 | 搜索索引移除 |
|
||
| `SkillDownloadedEvent` | 下载完成 | 下载计数 |
|
||
| `SkillStarredEvent` | 收藏/取消 | 收藏计数 |
|
||
| `SkillRatedEvent` | 评分提交 | 评分重算 |
|
||
| `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) |
|
||
| `SkillPromotedEvent` | 提升到全局 | 搜索索引写入(新 skill) |
|
||
|
||
一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。
|
||
|
||
### 审计日志写入策略
|
||
|
||
审计日志统一同步落库,与业务操作在同一请求内同步写入,不走异步事件。审计是企业内部平台的刚性需求,不可容忍丢失。
|
||
|
||
异步事件仅用于搜索索引、计数器等可容忍延迟的场景。如果后续需要更强一致性,引入 outbox 模式,不依赖 ApplicationEvent + @Async 承担可靠性。
|
||
|
||
### 异步事件可靠性保障
|
||
|
||
Spring ApplicationEvent + @Async 存在 Pod 被杀时事件丢失的风险。补充以下兜底机制:
|
||
|
||
- 搜索索引:定时任务每小时检查 `skill_version.status = PUBLISHED` 但 `skill_search_document` 中无对应记录的版本,补建索引
|
||
- 计数器:可接受少量丢失,定时任务每天凌晨从 `skill_star` / `skill_rating` 表重算修正
|
||
- 优雅停机:`@Async` 线程池配置 `awaitTerminationSeconds=25`,配合 30s shutdown timeout
|
||
|
||
## 8 分布式并发安全措施
|
||
|
||
| 操作 | 并发控制方式 |
|
||
|------|-------------|
|
||
| 审核通过/拒绝 | 乐观锁:`UPDATE review_task SET status=? WHERE id=? AND version=?` |
|
||
| 版本发布 | 唯一约束:`(skill_id, version)` |
|
||
| 计数器更新 | 原子 SQL:`SET count = count + 1` |
|
||
| 评分重算 | 异步 + Redis 分布式锁防重复重算 |
|
||
| 写操作幂等 | Redis 存储 `X-Request-Id`,TTL 24h |
|
||
|
||
### 幂等去重规范
|
||
|
||
基于 `idempotency_record` 表实现完整幂等:
|
||
|
||
- `X-Request-Id` 由客户端生成(UUID v4 格式)
|
||
- 客户端不传时,服务端自动生成但不做幂等去重
|
||
|
||
去重流程:
|
||
1. Redis `SETNX` key=`idempotent:{requestId}`(快速去重缓存,TTL=24h)
|
||
- key 已存在:查询 `idempotency_record` 表返回原始结果
|
||
2. key 不存在:插入 `idempotency_record`(status=`PROCESSING`)
|
||
3. 执行业务逻辑
|
||
4. 成功:更新 record 为 `COMPLETED`,填充 `resource_type` + `resource_id` + `response_status_code`
|
||
5. 失败:更新 record 为 `FAILED`
|
||
6. 重复请求时:查 record,COMPLETED 返回原始资源 ID,PROCESSING 返回 `409 Conflict`,FAILED 允许重试
|
||
|
||
适用范围:所有 POST/PUT/DELETE 写操作(发布、提审、创建 Token 等)
|
||
|
||
异常恢复策略:
|
||
- Redis key 存在但 `idempotency_record` 无记录(进程在两步之间崩溃):视为脏状态,删除 Redis key,允许请求正常重入
|
||
- `idempotency_record.status = FAILED`:删除对应 Redis key,允许客户端用相同 `request_id` 重试
|
||
- `idempotency_record.status = PROCESSING` 超过 5 分钟未更新:视为僵死,标记为 FAILED,删除 Redis key,允许重试
|