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