# 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 ID），nullable |
| 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 ID，nullable |
| 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） |

- 流程：收到请求 → 插入 record（PROCESSING）→ 业务处理 → 更新为 COMPLETED + resource_id → 重复请求时查 record 返回已有结果
- Redis 做快速去重缓存（SETNX），PostgreSQL 做持久化兜底
- 定时任务清理过期记录

## 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 校验 |
