skillhub/docs/02-domain-model.md

422 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# skillhub 领域模型与数据模型
## 0. 用户标识约束
- 用户身份主键全链路统一为 `string`
- 本约束覆盖 `user_id``owner_id``created_by``updated_by``published_by``reviewed_by``actor_user_id` 及所有等价语义字段。
- 历史文档里写成 `bigint` / `BIGINT` 的用户关联字段均应按字符串重新解释;这些旧类型描述不再作为实现依据。
- 若未来数据库为了索引或存储效率引入内部 surrogate key也只能作为内部实现细节不能替代字符串 `userId` 成为认证、授权、审计和 API 契约的主键。
## 3.1 核心实体
### namespace
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| slug | varchar(64) | URL 友好标识 |
| display_name | varchar(128) | 展示名 |
| type | enum | `GLOBAL` / `TEAM` |
| description | text | 描述 |
| avatar_url | varchar(512) | 头像 |
| status | enum | `ACTIVE` / `FROZEN` / `ARCHIVED` |
| created_by | varchar(128) | 创建人 |
| created_at | datetime | |
| updated_at | datetime | |
- `GLOBAL` 类型全局唯一(只有一个 `@global`),由平台管理员管理
- `TEAM` 类型对应部门/团队,可创建多个
- 技能完整寻址:`@{namespace_slug}/{skill_slug}`
- slug 唯一约束:`slug`
- slug 格式校验:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,长度 2-64且不得包含连续两个以上的连字符 `--`(为兼容层坐标映射保留)
- slug 保留词列表(用户创建 namespace 时不可使用):`admin`, `api`, `dashboard`, `search`, `auth`, `me`, `global`, `system`, `static`, `assets`, `health`
- 系统内置 namespace`@global`)在数据库初始化时由 Flyway 脚本预置,绕过 slug 校验规则。保留词校验仅作用于用户创建 namespace 的接口
- 状态语义:
- `ACTIVE`:正常使用
- `FROZEN`:冻结,只读不可发布新版本,已有技能仍可浏览/下载
- `ARCHIVED`:归档,对外不可见
### namespace_member
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| namespace_id | bigint | |
| user_id | varchar(128) | |
| role | enum | `OWNER` / `ADMIN` / `MEMBER` |
| created_at | datetime | |
| updated_at | datetime | |
- `OWNER`:命名空间创建者,可转让
- `ADMIN`:可审核该空间内的技能发布、管理成员
- `MEMBER`:可在该空间内发布技能(提交审核)
- 唯一约束:`(namespace_id, user_id)`,一个用户在一个空间只有一个角色
### skill
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| namespace_id | bigint | 所属命名空间 |
| slug | varchar(128) | URL 友好标识 |
| display_name | varchar(256) | |
| summary | varchar(512) | |
| owner_id | varchar(128) | 主要维护人(可转让) |
| source_skill_id | bigint | 派生来源(团队技能提升到全局时记录原 skill IDnullable |
| visibility | enum | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` |
| status | enum | `ACTIVE` / `ARCHIVED` |
| latest_version_id | bigint | latest published pointer仅指向最新 `PUBLISHED` 版本;若不存在已发布版本则可为 `null` |
| download_count | bigint | |
| star_count | int | |
| rating_avg | decimal(3,2) | 平均评分 |
| rating_count | int | 评分人数 |
| created_by | varchar(128) | |
| created_at | datetime | |
| updated_by | varchar(128) | |
| updated_at | datetime | |
- 唯一约束:`(namespace_id, slug)`
- `status` 表示 skill 容器生命周期,不再承载“隐藏”语义。隐藏是独立的治理覆盖层,由 `hidden` / `hidden_at` / `hidden_by` 表达
- 当前代码下的实际可见性判定以 `VisibilityChecker` 为准,规则如下:
-`hidden=true`:仅 skill owner 或该 namespace 的 `ADMIN` / `OWNER` 可读
-`latest_version_id is null`:仅 skill owner 可读;即使 `visibility=PUBLIC` 也不会对外公开
- `PUBLIC`:任意人可读 skill 容器与已发布版本
- `NAMESPACE_ONLY`:该 namespace 任意成员可读(`MEMBER` / `ADMIN` / `OWNER`
- `PRIVATE`:仅 skill owner 或该 namespace 的 `ADMIN` / `OWNER` 可读,普通 `MEMBER` 不可读
- `owner_id` 语义为"主要维护人",可转让。权限主轴是 namespace role不是 owner
- namespace ADMIN 对空间内所有 skill 有完整管理权(归档、版本管理、提升到全局),不受 owner 限制
- owner 作为 MEMBER 时可管理自己创建的 skill提交审核、编辑草稿
- owner 离职/换组后namespace ADMIN 仍能完整管理所有技能
- `rating_avg` / `rating_count` 冗余字段,避免每次查询聚合
- `slug`:面向用户的 URL 标识,来自 SKILL.md 的 `name` 字段首次发布后不可变更。slug 格式校验规则与 namespace slug 相同:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,同样适用保留词限制,且不得包含连续两个以上的连字符 `--`(为兼容层坐标映射保留)。全局空间(`@global`)下的 skill slug 额外禁止包含 `--`,以避免与兼容层 canonical slug 产生歧义
- `source_skill_id`:仅在"团队技能提升到全局"场景下填充,记录原始团队空间的 skill ID用于追溯来源
- 提升关系的唯一事实来源是 `promotion_request`UI 查询"是否已提升"通过 `SELECT ... FROM promotion_request WHERE source_skill_id=? AND status='APPROVED'` 判定
### skill_version
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_id | bigint | |
| version | varchar(32) | semver |
| version_sort | bigint | 排序用数值 |
| changelog | text | |
| manifest_json | json | 文件清单 |
| parsed_metadata_json | json | SKILL.md frontmatter 解析结果 |
| status | enum | `DRAFT` / `PENDING_REVIEW` / `PUBLISHED` / `REJECTED` / `YANKED` |
| reject_reason | varchar(512) | 拒绝原因 |
| published_by | varchar(128) | |
| published_at | datetime | |
| created_at | datetime | |
- `status` 表示 version 发布生命周期,和 skill 容器状态、review task 状态分离
- 当前代码下的实际迁移约束:
- 普通用户首次上传/重传新版本后,版本直接进入 `PENDING_REVIEW`
- `SUPER_ADMIN` 直发时可直接进入 `PUBLISHED`
- 审核通过:`PENDING_REVIEW → PUBLISHED`
- 审核拒绝:`PENDING_REVIEW → REJECTED`
- 撤回审核:`PENDING_REVIEW → DRAFT`
- 已发布撤回:`PUBLISHED → YANKED`
- 唯一约束:`(skill_id, version)` 防止重复发布
- `YANKED` 状态:已发布后撤回
- 当前代码下的实际读权限补充:
- 普通详情 / 下载 / resolve / tag / 文件读取,只接受 `PUBLISHED`
- owner 可通过常规版本详情预览自己的 `PENDING_REVIEW` 版本
- owner / namespace `ADMIN` / `OWNER` 在版本列表中可看到全部五种状态:`PUBLISHED / PENDING_REVIEW / DRAFT / REJECTED / YANKED`
- 但常规版本详情接口并不会放行 `DRAFT / REJECTED / YANKED`
- 审核详情页走独立 review 读路径,可查看待审版本及完整版本快照
版本号不可变性规则:
| 版本状态 | 版本号处理 |
|---------|-----------|
| DRAFT | 可删除该版本记录,重新使用同版本号 |
| PENDING_REVIEW | 可撤回到 DRAFT |
| REJECTED | 可删除该版本记录,重新使用同版本号 |
| PUBLISHED | 版本号永久占用,不可复用 |
| YANKED | 版本号永久占用,不可复用,版本列表中显示但标记为不可下载 |
### skill_file
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_version_id | bigint | |
| file_path | varchar(512) | |
| content_type | varchar(128) | |
| size_bytes | bigint | |
| sha256 | varchar(64) | |
| object_key | varchar(512) | |
| is_entry_file | boolean | |
| created_at | datetime | |
### skill_tag
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_id | bigint | |
| tag_name | varchar(64) | |
| target_version_id | bigint | |
| created_by | varchar(128) | |
| created_at | datetime | |
| updated_by | varchar(128) | |
| updated_at | datetime | |
- `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`;其语义严格等价于“最新已发布版本”,不允许 API 手动移动
- 自定义标签(如 `beta``stable-2026q1`)允许人工创建和移动
- 唯一约束:`(skill_id, tag_name)`
- `target_version_id` 必须指向 `status = PUBLISHED` 的版本,应用层校验
### review_task
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_version_id | bigint | 关联的版本 |
| namespace_id | bigint | 所属空间(决定谁能审核) |
| status | enum | `PENDING` / `APPROVED` / `REJECTED` |
| version | int | 乐观锁版本号,默认 1 |
| submitted_by | varchar(128) | 提交人 |
| reviewed_by | varchar(128) | 审核人 |
| review_comment | text | 审核意见 |
| submitted_at | datetime | |
| reviewed_at | datetime | |
- 仅用于普通发布审核,"提升到全局"使用独立的 `promotion_request`
- `version` 字段用于乐观锁,防止多 Pod 并发审核
- 业务约束:同一 `skill_version_id``status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict。撤回时删除 `PENDING` review_task并将 `skill_version` 回退到 `DRAFT`
- PostgreSQL 并发约束落地:通过唯一索引 `(skill_version_id)` + 软删除标记实现。`review_task` 表增加 `deleted` 字段bigint, 默认 0唯一索引改为 `(skill_version_id, deleted)`。撤回时将 `deleted` 设为 `id`(非零值),新提交时 `deleted=0`,利用唯一索引防止并发重复提交。或者采用更简单的方案:撤回时物理删除 review_task 记录,依赖 `INSERT` 的唯一约束 `(skill_version_id)` 防并发。PostgreSQL 还支持 partial unique index 方案:`CREATE UNIQUE INDEX ON review_task (skill_version_id) WHERE status = 'PENDING'`,更优雅地实现"PENDING 状态唯一"约束
### promotion_request
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| source_skill_id | bigint | 来源团队 skill |
| source_version_id | bigint | 申请提升的版本 |
| target_namespace_id | bigint | 目标全局 namespace |
| target_skill_id | bigint | 审批通过后生成的全局 skill IDnullable |
| status | enum | `PENDING` / `APPROVED` / `REJECTED` |
| version | int | 乐观锁版本号,默认 1 |
| submitted_by | varchar(128) | 提交人 |
| reviewed_by | varchar(128) | 审核人 |
| review_comment | text | 审核意见 |
| submitted_at | datetime | |
| reviewed_at | datetime | |
- 完整表达"哪个团队 skill 的哪一版被申请提升到哪个全局空间"
- 审批通过后填充 `target_skill_id`,指向全局空间新创建的 skill
- `promotion_request` 是提升关系的唯一事实来源skill 表不再冗余 `promoted_to_skill_id`
- 业务约束:同一 `source_version_id``status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict
- PostgreSQL 并发约束落地:与 `review_task` 类似,通过唯一索引防止并发重复提交。推荐使用 partial unique index`CREATE UNIQUE INDEX ON promotion_request (source_version_id) WHERE status = 'PENDING'`,或增加 `deleted` 字段 + `(source_version_id, deleted)` 唯一约束,或采用物理删除 + `(source_version_id)` 唯一约束方案
### skill_star
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_id | bigint | |
| user_id | varchar(128) | |
| created_at | datetime | |
唯一约束:`(skill_id, user_id)`
### skill_rating
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_id | bigint | |
| user_id | varchar(128) | |
| score | tinyint | 1-5 |
| created_at | datetime | |
| updated_at | datetime | |
唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改
### user_account
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| display_name | varchar(128) | |
| email | varchar(256) | |
| avatar_url | varchar(512) | |
| status | enum | `ACTIVE` / `PENDING` / `DISABLED` / `MERGED` |
| merged_to_user_id | varchar(128) | 合并目标用户 ID仅 MERGED 状态有值 |
| created_at | datetime | |
| updated_at | datetime | |
- 状态语义:
- `ACTIVE`:正常使用
- `PENDING`等待管理员审批AccessPolicy 返回 PENDING_APPROVAL 时创建)
- `DISABLED`:管理员封禁,登录后拒绝所有操作,返回 403
- `MERGED`:已合并到其他账号,保留记录不物理删除,登录时自动跳转到合并目标账号
- 授权层在每次请求时检查用户状态,非 `ACTIVE` 用户拒绝所有写操作
### identity_binding
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| user_id | varchar(128) | |
| provider_code | varchar(64) | 如 `github` |
| subject | varchar(256) | OAuth Provider 返回的唯一用户标识 |
| login_name | varchar(128) | 如 GitHub login |
| extra_json | json | 原始扩展字段 |
| created_at | datetime | |
| updated_at | datetime | |
- 唯一约束:`(provider_code, subject)`
- 一期只接入 GitHub OAuth但表结构支持后续扩展多个 OAuth Provider
### api_token
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| subject_type | varchar(32) | `USER`(一期)/ `SERVICE_ACCOUNT`(预留) |
| subject_id | varchar(128) | 关联主体 ID一期等同于 user_id |
| user_id | varchar(128) | 兼容字段,一期与 subject_id 相同 |
| name | varchar(128) | Token 名称(必填),如"CI/CD"、"本地开发" |
| token_prefix | varchar(16) | |
| token_hash | varchar(64) | |
| scope_json | json | |
| expires_at | datetime | |
| last_used_at | datetime | |
| revoked_at | datetime | |
| created_at | datetime | |
### audit_log
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| actor_user_id | varchar(128) | |
| action | varchar(64) | |
| target_type | varchar(64) | |
| target_id | bigint | |
| request_id | varchar(64) | |
| client_ip | varchar(64) | |
| user_agent | varchar(512) | |
| detail_json | json | |
| created_at | datetime | |
## 3.2 RBAC 实体
一期即上线完整 RBAC平台角色按最小权限拆分避免所有治理能力压在单一超管角色上。
平台角色一期内置Flyway 预置):
| 角色 code | 说明 | 典型权限 |
|-----------|------|---------|
| `SUPER_ADMIN` | 平台超管,拥有所有权限 | 全部 |
| `SKILL_ADMIN` | 技能治理:全局空间审核、提升审核、隐藏/恢复、撤回已发布版本 | `review:approve`, `skill:manage`, `promotion:approve` |
| `USER_ADMIN` | 用户治理:准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN | `user:manage`, `user:approve` |
| `AUDITOR` | 审计只读:查看审计日志 | `audit:read` |
- 命名空间权限仍由 `namespace_member.role`OWNER / ADMIN / MEMBER决定不走 RBAC 表
- 一个用户可持有多个平台角色(多条 `user_role_binding`
- `SUPER_ADMIN` 隐含所有权限,代码中硬判定短路
### role
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| code | varchar(64) | `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR` |
| name | varchar(128) | 展示名 |
| description | varchar(512) | |
| is_system | boolean | 系统内置角色不可删除 |
| created_at | datetime | |
### permission
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| code | varchar(128) | 如 `skill:publish`, `review:approve`, `user:manage` |
| name | varchar(128) | |
| group_code | varchar(64) | 权限分组 |
### role_permission
| 字段 | 类型 | 说明 |
|------|------|------|
| role_id | bigint | |
| permission_id | bigint | |
### user_role_binding
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| user_id | varchar(128) | |
| role_id | bigint | |
| created_at | datetime | |
## 3.3 搜索文档表
### skill_search_document
一个 skill 对应一条搜索文档,内容取“最新已发布版本”。实现上可由 `latest_version_id` 作为缓存指针承载,但其语义只能是 latest published pointer。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | |
| skill_id | bigint | 唯一,一 skill 一条 |
| namespace_id | bigint | 用于空间过滤 |
| owner_id | varchar(128) | 用于 PRIVATE 可见性判定 |
| title | varchar(256) | |
| summary | varchar(512) | |
| keywords | varchar(512) | |
| search_text | text | `displayName``slug``summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
| visibility | enum | 冗余,避免搜索时 join |
| status | enum | |
| updated_at | datetime | |
PostgreSQL Full-Text Index`skill_search_document` 表增加 `search_vector tsvector` 列,通过触发器或 `GENERATED ALWAYS AS` 自动维护,建立 GIN 索引。
## 3.4 幂等记录表
### idempotency_record
| 字段 | 类型 | 说明 |
|------|------|------|
| request_id | varchar(64) | 主键,客户端传入的 UUID v4 |
| resource_type | varchar(64) | 如 `skill_version`, `api_token` |
| resource_id | bigint | 业务操作产生的资源 ID |
| status | enum | `PROCESSING` / `COMPLETED` / `FAILED` |
| response_status_code | int | 原始响应状态码 |
| created_at | datetime | |
| expires_at | datetime | 过期时间(默认 24h |
- 流程:收到请求 → 插入 recordPROCESSING→ 业务处理 → 更新为 COMPLETED + resource_id → 重复请求时查 record 返回已有结果
- Redis 做快速去重缓存SETNXPostgreSQL 做持久化兜底
- 定时任务清理过期记录
## 3.5 关键索引设计
| 表 | 索引 | 用途 |
|------|------|------|
| namespace | `(slug)` UNIQUE | 唯一约束 |
| skill | `(namespace_id, status)` | 命名空间内技能列表 |
| skill | `(namespace_id, slug)` UNIQUE | 唯一约束 |
| skill_version | `(skill_id, status)` | 版本列表 |
| skill_version | `(skill_id, version)` UNIQUE | 唯一约束 |
| skill_tag | `(skill_id, tag_name)` UNIQUE | 标签唯一约束 |
| review_task | `(namespace_id, status)` | 审核列表 |
| review_task | `(submitted_by, status)` | 我的提交 |
| promotion_request | `(source_skill_id)` | 按来源 skill 查询 |
| promotion_request | `(status)` | 待审核列表 |
| idempotency_record | `(expires_at)` | 过期清理 |
| audit_log | `(created_at)` | 审计查询 |
| audit_log | `(actor_user_id, created_at)` | 用户操作历史 |
| skill_star | `(user_id)` | 我的收藏 |
| skill_star | `(skill_id)` | 技能收藏数 |
| skill_rating | `(skill_id)` | 评分聚合 |
| namespace_member | `(namespace_id, user_id)` UNIQUE | 成员唯一约束 |
| namespace_member | `(user_id)` | 用户所属空间 |
| identity_binding | `(provider_code, subject)` UNIQUE | 身份查找 |
| api_token | `(token_hash)` | Token 校验 |