skillhub/docs/05-business-flows.md
XiaoSeS 45d341f144
feat(review): add skill comments and user feedback (#793)
* feat(review): add skill review domain model

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* feat(review): expose skill reviews in API and UI

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): preserve moderation under concurrent edits

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): scope concurrent write conflicts

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): restore web build compatibility

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): keep author cleanup available

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): preserve author cleanup access

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): require review score contract

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): strengthen failure and concurrency coverage

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): tighten persistence assertions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): disambiguate repository ports

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): enable request validation

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(review): align validation and postgres coverage

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(namespace): verify invalid batch has no side effects

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(web): align accessibility and plural assertions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(i18n): require complete plural references

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): wrap editor actions on mobile

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): wrap long mobile labels

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): disable edits for archived skills

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(review): enforce archived mutation guard

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-09-02 11:15:52 +08:00

14 KiB
Raw Blame History

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 响应:

{
  "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 分布式锁防重复重算)

文字评价复用同一条 skill_rating 记录:用户提交 score + review_text 时同步更新评分并触发 SkillRatedEvent;删除评价只清空文字,保留星级评分。公开列表仅返回 VISIBLE 评价; SKILL_ADMIN / SUPER_ADMIN 可隐藏或恢复评价,管理动作写入审计日志且不改变评分聚合。

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,允许重试