14 KiB
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 或 namespaceADMIN/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 响应:
{
"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 直接更新,可接受。如出现热点行瓶颈,切换为:
- Redis
INCR做实时计数(key:skill:downloads:{skillId}) - 定时任务每 5 分钟批量回写 PostgreSQL
- 查询时合并 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 格式)- 客户端不传时,服务端自动生成但不做幂等去重
去重流程:
- Redis
SETNXkey=idempotent:{requestId}(快速去重缓存,TTL=24h)- key 已存在:查询
idempotency_record表返回原始结果
- key 已存在:查询
- key 不存在:插入
idempotency_record(status=PROCESSING) - 执行业务逻辑
- 成功:更新 record 为
COMPLETED,填充resource_type+resource_id+response_status_code - 失败:更新 record 为
FAILED - 重复请求时:查 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,允许重试