diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a0367c4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# OS files +.DS_Store +Thumbs.db + +# Editors / IDEs +.idea/ +.vscode/ +*.iml +*.swp +*.swo + +# Logs +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment / local config +.env +.env.* +!.env.example +!.env.*.example + +# Java / Spring Boot +target/ +build/ +.gradle/ +out/ +*.class +*.jar +*.war +*.ear + +# Keep wrapper binaries/config in VCS +!gradle/wrapper/gradle-wrapper.jar +!mvnw +!mvnw.cmd + +# React / frontend +node_modules/ +dist/ +coverage/ +.cache/ +.parcel-cache/ +.vite/ +.eslintcache +*.tsbuildinfo + +# Temporary files +.tmp/ +tmp/ diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index ef92d1a4..0a8cf95c 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -14,6 +14,38 @@ 同时,一期必须提供 ClawHub CLI 协议兼容层:服务端需要暴露一组与 ClawHub CLI 兼容的 registry API,使现有 ClawHub CLI 在不修改或仅最小配置修改的前提下可完成 registry 侧查询、解析、下载、发布、校验等核心操作。 +### 1.1 技能坐标体系(已冻结) + +skillhub 内部使用 namespace 坐标模型:`@{namespace_slug}/{skill_slug}`。 + +ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*[a-z0-9])?`,不允许 `/` 出现。 + +为同时满足两套模型,定义以下双向映射规则: + +**映射规则:** + +| skillhub 坐标 | 兼容层 canonical slug | 说明 | +|---|---|---| +| `@global/my-skill` | `my-skill` | 全局空间省略前缀,直接使用 skill slug | +| `@team-name/my-skill` | `team-name--my-skill` | 团队空间使用 `{namespace_slug}--{skill_slug}` 格式 | + +**约束规则:** +- 分隔符为双连字符 `--` +- skill slug 和 namespace slug 均禁止包含 `--`(在校验规则中追加此限制) +- slug 格式校验更新为:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,且不得包含连续两个以上的连字符 `--` +- 兼容层解析 canonical slug 时:包含 `--` 则拆分为 `namespace_slug` + `skill_slug`,不包含则视为 `@global/{slug}` +- 冲突规则:如果 `@global/team-name--my-skill` 与 `@team-name/my-skill` 产生冲突,以 `--` 拆分优先(即优先解析为团队空间技能)。全局空间的 skill slug 禁止包含 `--` 以避免歧义 +- 保留字规则:namespace slug 保留词列表同样适用于 canonical slug 的 namespace 部分 + +**显示规则:** +- Web 端始终显示完整坐标:`@global/my-skill`、`@team-name/my-skill` +- ClawHub CLI 兼容层返回 canonical slug:`my-skill`、`team-name--my-skill` +- skillhub 自有 CLI 支持两种格式输入,内部统一转换为 namespace 坐标 + +**Well-known 发现:** +- skillhub 服务端提供 `/.well-known/clawhub.json`,返回 `{ "apiBase": "/api/compat/v1" }` +- ClawHub CLI 通过此机制自动发现兼容层 API 基地址 + ## 2. 参考项目取舍 ### 2.1 继承 ClawHub 的部分 @@ -61,7 +93,7 @@ - 技能浏览、详情、下载(公共技能匿名可访问) - 标签管理(`latest` 系统保留只读 + 自定义标签人工维护) - 技能包文件校验与 SKILL.md 元数据抽取 -- 基于 MySQL 全文索引的搜索 +- 基于 PostgreSQL 全文索引的搜索 命名空间与组织: - 单一全局命名空间(`@global/skill-name`),由平台管理员管理,不支持多个平台级 namespace diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index 940dd3f7..d7814432 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -5,10 +5,10 @@ - JDK: 21 - Framework: Spring Boot 3.x(最新稳定版) - Security: Spring Security + spring-boot-starter-oauth2-client -- Database: MySQL 8.x +- Database: PostgreSQL 16.x - Cache/Session: Redis 7.x(一期必须依赖,用于 Session 存储 + 分布式锁 + 幂等去重) - Object Storage: S3 协议兼容对象存储 -- Search: MySQL Full-Text Search(一期) +- Search: PostgreSQL Full-Text Search(一期) - Future Search: Elasticsearch / OpenSearch / Vector Search ## 2. 总体架构 @@ -22,9 +22,9 @@ server/ ├── skillhub-app # 启动、配置装配、Controller 聚合 ├── skillhub-domain # 领域模型 + 领域服务 + 应用服务 ├── skillhub-auth # OAuth2 认证 + RBAC + 授权判定 -├── skillhub-search # 搜索 SPI + MySQL 全文实现 +├── skillhub-search # 搜索 SPI + PostgreSQL 全文实现 ├── skillhub-storage # 对象存储抽象 + S3 实现 -└── skillhub-infra # MyBatis、通用工具、配置基础 +└── skillhub-infra # JPA、通用工具、配置基础 ``` ## 4. 模块依赖方向(依赖倒置,禁止领域层依赖基础设施) @@ -39,9 +39,9 @@ storage → (独立抽象) # 纯 SPI,不依赖 domain 核心原则: - domain 是最内层,不依赖任何其他模块,只定义接口和实体 -- infra 实现 domain 中定义的 Repository 接口(MyBatis Mapper) +- infra 实现 domain 中定义的 Repository 接口(Spring Data JPA) - app 负责装配所有模块,通过 Spring 依赖注入将 infra 实现注入 domain 接口 -- 禁止 domain → infra 方向的依赖,避免领域层与 MyBatis、事件实现绑死 +- 禁止 domain → infra 方向的依赖,避免领域层与 JPA、事件实现绑死 ## 5. 各模块职责 @@ -68,7 +68,7 @@ storage → (独立抽象) # 纯 SPI,不依赖 domain ### skillhub-search - SPI 接口:`SearchIndexService`, `SearchQueryService`, `SearchRebuildService` -- 一期实现:`MysqlFullTextIndexService`, `MysqlFullTextQueryService` +- 一期实现:`PostgresFullTextIndexService`, `PostgresFullTextQueryService` - 独立搜索文档表 `skill_search_document` - 未来扩展点:ES / 向量检索实现 @@ -79,10 +79,9 @@ storage → (独立抽象) # 纯 SPI,不依赖 domain - 对象 key 规则(使用不可变 ID,避免命名空间变更导致 key 失效): - 正式路径:`skills/{skillId}/{versionId}/{filePath}` - 打包路径:`packages/{skillId}/{versionId}/bundle.zip` - - 临时上传:`tmp/{uploadId}/{filePath}`(24h GC 清理) ### skillhub-infra -- MyBatis-Plus Mapper 实现 +- Spring Data JPA Repository 实现 - Repository 实现 - 通用工具(ID 生成、时间、JSON 等) - Spring Events 异步事件基础设施 @@ -109,8 +108,13 @@ web/ ``` skillhub/ ├── server/ # Maven 多模块 Java 后端 +│ └── Dockerfile # 后端多阶段构建 ├── web/ # React 前端 -├── Makefile # 顶层构建编排(dev / build / docker) +│ ├── Dockerfile # 前端多阶段构建 +│ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理) +├── docker-compose.yml # 本地开发(仅依赖服务:PostgreSQL/Redis/MinIO) +├── docker-compose.prod.yml # 完整部署(前后端 + 依赖服务) +├── Makefile # 顶层构建编排(dev / build / docker / deploy) ├── docs/ # 设计文档 └── README.md ``` @@ -130,14 +134,14 @@ skillhub/ | 组件 | 一期要求 | 职责 | |------|---------|------| -| MySQL 8.x | 主从 | 主存储 | +| PostgreSQL 16.x | 主从 | 主存储 | | Redis 7.x | Sentinel 或 Cluster | Session 存储 + 分布式锁 + 幂等去重 | | S3 兼容存储 | MinIO 或云厂商 S3 | 技能包文件 + 预打包 zip | | Ingress | Nginx Ingress Controller | 路由分发 + TLS 终止 | ## 10. 推荐的一期技术决策 -- ORM:MyBatis-Plus +- ORM:Spring Data JPA (Hibernate) - API 文档:Springdoc OpenAPI - 对象存储:MinIO / AWS S3 兼容接口 - 异步任务:Spring Events + 异步线程池,后续视复杂度引入 MQ diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 5c835378..7e0aa16f 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -21,7 +21,7 @@ - `TEAM` 类型对应部门/团队,可创建多个 - 技能完整寻址:`@{namespace_slug}/{skill_slug}` - slug 唯一约束:`slug` -- slug 格式校验:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,长度 2-64 +- 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 的接口 - 状态语义: @@ -74,7 +74,7 @@ - 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])?`,同样适用保留词限制 +- `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'` 判定 @@ -90,7 +90,6 @@ | manifest_json | json | 文件清单 | | parsed_metadata_json | json | SKILL.md frontmatter 解析结果 | | status | enum | `DRAFT` / `PENDING_REVIEW` / `PUBLISHED` / `REJECTED` / `YANKED` | -| file_transfer_status | enum | `PENDING` / `COMPLETED` / `FAILED`,异步文件转正状态 | | reject_reason | varchar(512) | 拒绝原因 | | published_by | bigint | | | published_at | datetime | | @@ -98,7 +97,6 @@ - `status` 覆盖完整审核生命周期 - 状态机:`DRAFT → PENDING_REVIEW → PUBLISHED / REJECTED`,`PUBLISHED → YANKED` -- `DRAFT → PENDING_REVIEW` 前置条件:`file_transfer_status = COMPLETED` - 唯一约束:`(skill_id, version)` 防止重复发布 - `YANKED` 状态:已发布后撤回 @@ -142,6 +140,7 @@ - `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`,不允许 API 手动移动 - 自定义标签(如 `beta`、`stable-2026q1`)允许人工创建和移动 - 唯一约束:`(skill_id, tag_name)` +- `target_version_id` 必须指向 `status = PUBLISHED` 的版本,应用层校验 ### review_task @@ -161,6 +160,7 @@ - 仅用于普通发布审核,"提升到全局"使用独立的 `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 @@ -183,6 +183,7 @@ - 审批通过后填充 `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 @@ -349,7 +350,7 @@ | status | enum | | | updated_at | datetime | | -MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。 +PostgreSQL Full-Text Index:在 `skill_search_document` 表增加 `search_vector tsvector` 列,通过触发器或 `GENERATED ALWAYS AS` 自动维护,建立 GIN 索引。 ## 3.4 幂等记录表 @@ -366,7 +367,7 @@ MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。 | expires_at | datetime | 过期时间(默认 24h) | - 流程:收到请求 → 插入 record(PROCESSING)→ 业务处理 → 更新为 COMPLETED + resource_id → 重复请求时查 record 返回已有结果 -- Redis 做快速去重缓存(SETNX),MySQL 做持久化兜底 +- Redis 做快速去重缓存(SETNX),PostgreSQL 做持久化兜底 - 定时任务清理过期记录 ## 3.5 关键索引设计 diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 5e7b4eb8..76b30a5d 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -277,6 +277,8 @@ public class OAuthClaimsExtractor { - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` - 天然无状态,多 Pod 安全 +> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 + ## 6. RBAC 授权判定 ``` @@ -477,3 +479,72 @@ window.location.href = '/oauth2/authorization/github' - 前端权限控制是 UX 优化,不是安全边界 - 后端每个写操作接口独立校验权限,不信任前端判定 - 前端隐藏按钮 ≠ 安全,用户可以直接调 API,后端必须拦截 + +## 10. 权限矩阵(完整) + +以下矩阵列出每个 API 接口的权限判定来源,作为后端实现的唯一参考。 + +### 10.1 Public API(匿名可访问) + +| 接口 | 匿名 | 已登录 | 判定逻辑 | +|------|------|--------|---------| +| `GET /api/v1/skills`(搜索) | PUBLIC 技能 | PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin) | `SearchVisibilityScope` 投影 | +| `GET /api/v1/skills/{ns}/{slug}` | PUBLIC 技能 | 同上 | visibility + namespace 成员关系 | +| `GET /api/v1/skills/{ns}/{slug}/versions` | PUBLIC 技能 | 同上 | 同上 | +| `GET /api/v1/skills/{ns}/{slug}/download` | PUBLIC 技能 | 同上 | 同上 | +| `GET /api/v1/skills/{ns}/{slug}/resolve` | PUBLIC 技能 | 同上 | 同上 | +| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 | + +### 10.2 Authenticated API + +| 接口 | 所需权限 | 判定来源 | +|------|---------|---------| +| `POST /api/v1/skills/{ns}/{slug}/star` | 已登录 | Session/Token | +| `POST /api/v1/skills/{ns}/{slug}/rating` | 已登录 | Session/Token | +| `POST .../versions/{ver}/submit-review` | namespace MEMBER 以上 | `namespace_member.role` | +| `POST .../versions/{ver}/withdraw-review` | 提交人本人 或 namespace ADMIN | `review_task.submitted_by` 或 `namespace_member.role` | +| `PUT /api/v1/skills/{ns}/{slug}/tags/{tag}` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | +| `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | +| `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 DRAFT/REJECTED) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` | + +### 10.3 CLI API + +| 接口 | 所需 Token Scope | 额外判定 | +|------|-----------------|---------| +| `GET /api/v1/cli/whoami` | 任意有效 Token | 无 | +| `POST /api/v1/cli/publish` | `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 | + +### 10.4 Admin API + +| 接口 | 所需平台角色 | 判定来源 | +|------|------------|---------| +| `POST /api/v1/admin/reviews/{id}/approve` | SKILL_ADMIN / SUPER_ADMIN | `user_role_binding` → `role_permission` | +| `POST /api/v1/admin/reviews/{id}/reject` | SKILL_ADMIN / SUPER_ADMIN | 同上 | +| `POST /api/v1/admin/promotions/{id}/approve` | SKILL_ADMIN / SUPER_ADMIN | 同上 | +| `POST /api/v1/admin/promotions/{id}/reject` | SKILL_ADMIN / SUPER_ADMIN | 同上 | +| `PUT /api/v1/admin/users/{id}/roles` | USER_ADMIN / SUPER_ADMIN | 同上,且 USER_ADMIN 不可分配 SUPER_ADMIN | +| `POST /api/v1/admin/users/{id}/approve` | USER_ADMIN / SUPER_ADMIN | 同上 | +| `POST /api/v1/admin/users/{id}/ban` | USER_ADMIN / SUPER_ADMIN | 同上 | +| `GET /api/v1/admin/audit-logs` | AUDITOR / SUPER_ADMIN | 同上 | + +### 10.5 Namespace API + +| 接口 | 所需 namespace 角色 | 判定来源 | +|------|-------------------|---------| +| `POST /api/v1/namespaces/{slug}/reviews/{id}/approve` | 该空间 ADMIN 以上 | `namespace_member.role` | +| `POST /api/v1/namespaces/{slug}/reviews/{id}/reject` | 该空间 ADMIN 以上 | `namespace_member.role` | +| `POST /api/v1/namespaces/{slug}/members` | 该空间 ADMIN 以上 | `namespace_member.role` | +| `DELETE /api/v1/namespaces/{slug}/members/{userId}` | 该空间 ADMIN 以上 | `namespace_member.role` | +| `POST .../skills/{skillId}/promote` | 该空间 ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` | + +### 10.6 Compatibility API(Token 认证) + +| 接口 | 所需 Token Scope | 额外判定 | +|------|-----------------|---------| +| `GET /api/compat/v1/whoami` | 任意有效 Token | 无 | +| `GET /api/compat/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` | +| `GET /api/compat/v1/resolve` | 可选(匿名限 PUBLIC) | visibility | +| `GET /api/compat/v1/download` | 可选(匿名限 PUBLIC) | visibility | +| `POST /api/compat/v1/skills` | `skill:publish` | 用户是目标 namespace 的 MEMBER 以上(namespace 由 canonical slug 解析) | +| `DELETE /api/compat/v1/skills/{slug}` | `skill:delete` | namespace ADMIN 以上 或 owner | +| `POST /api/compat/v1/stars/{slug}` | 任意有效 Token | 无 | diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 60be3798..44ae581e 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -46,7 +46,7 @@ ACL 投影计算规则: - 匿名用户:`includeAllPublic=true`,其余为空集,`userId=null` - 已登录用户:`includeAllPublic=true`,`memberNamespaceIds` = 用户所属空间,`adminNamespaceIds` = 用户是 ADMIN 以上的空间,`userId` = 当前用户 ID -一期 MySQL 实现中,`SearchVisibilityScope` 转换为 WHERE 条件: +一期 PostgreSQL 实现中,`SearchVisibilityScope` 转换为 WHERE 条件: ```sql WHERE (visibility = 'PUBLIC') OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN (:memberNamespaceIds)) @@ -75,7 +75,7 @@ WHERE (visibility = 'PUBLIC') 唯一约束:`(skill_id)` -MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。 +PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列,基于 `title`、`summary`、`keywords`、`search_text` 自动维护,建立 GIN 索引。详见第 7 节。 ## 4 索引写入时机 @@ -95,11 +95,18 @@ MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。 这些场景不是简单换 provider 能解决的,需要改表结构和索引写入逻辑。 +**一期搜索能力边界(产品限制):** +- 搜索只基于 `latest_version_id` 对应版本的内容 +- 不支持按 version 或 tag 搜索内容 +- 搜索结果不区分 channel(`beta`、`stable` 等标签通道) +- 用户通过 tag 安装的技能内容可能与搜索结果展示的内容不一致(搜索展示 latest,安装的是 tag 指向的版本) +- 若要支持 channel-aware 搜索,必须升级到 version 级索引(二期 ES 实现) + ### 5.2 演进阶段 | 阶段 | 实现 | 索引粒度 | 切换方式 | |------|------|---------|---------| -| 一期 | MySQL Full-Text | 每 skill 一条(latest_version_id) | 默认 | +| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条(latest_version_id) | 默认 | | 二期 | ES / OpenSearch | 每 skill_version 一条 + skill 聚合文档 | 配置 `search.provider=elasticsearch` | | 三期 | 向量检索 | 每 skill_version 多条(chunk 级) | 配置 `search.provider=vector` | | 四期 | 混合排序 | 关键词 + 向量混合 | 配置 `search.provider=hybrid` | @@ -121,16 +128,28 @@ MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。 `rebuildAll()` / `rebuildByNamespace()` 执行前获取 Redis 分布式锁(key: `search:rebuild:{scope}`,TTL: 10min),获取失败则跳过。 -## 7 MySQL 全文搜索中文支持 +## 7 PostgreSQL 全文搜索中文支持 -MySQL Full-Text Index 必须使用 ngram parser: +PostgreSQL 全文搜索使用 `tsvector` + `tsquery` + GIN 索引: ```sql +-- 增加 tsvector 生成列 ALTER TABLE skill_search_document -ADD FULLTEXT INDEX ft_search (title, summary, keywords, search_text) -WITH PARSER ngram; +ADD COLUMN search_vector tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(summary, '')), 'B') || + setweight(to_tsvector('simple', coalesce(keywords, '')), 'B') || + setweight(to_tsvector('simple', coalesce(search_text, '')), 'C') +) STORED; + +-- 建立 GIN 索引 +CREATE INDEX idx_search_vector ON skill_search_document USING GIN (search_vector); ``` -配置 `ngram_token_size=2`(my.cnf)。 +中文支持方案: +- 一期使用 `simple` 分词配置(按空格和标点分词),对中文支持有限但零依赖 +- 如需更好的中文分词,可安装 `zhparser` 或 `pg_jieba` 扩展,替换为对应的 text search configuration +- PostgreSQL 的 `tsvector` 支持权重(A/B/C/D),可对 title 赋予更高权重,提升搜索相关性 -已知局限:ngram 分词精度不如专业搜索引擎,中文搜索体验有限。建议 Phase 2 完成后评估搜索效果,如不满足需求则在 Phase 3 提前引入 ES。 +已知局限:`simple` 分词对中文的精度不如专业搜索引擎。建议 Phase 2 完成后评估搜索效果,如不满足需求则在 Phase 3 提前引入 ES。 diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 2c73efa4..33f68e56 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -2,6 +2,10 @@ ## 1 发布流程 +一期采用同步发布模型:上传、校验、存储、持久化在一次请求中同步完成。前端通过异步上传(带进度条)提升用户体验,但后端处理是同步的。 + +> **设计决策**:一期暂不考虑异步发布(uploadId、publishId、状态轮询、异步转正等)。一期技能包为文本资源包,体积有限(上限 10MB),同步处理足以满足需求。如后续引入大文件或复杂校验流程,再考虑异步模型。 + ``` 用户提交发布 │ @@ -16,20 +20,20 @@ - [扩展点] PrePublishValidator 链(一期空实现) │ ▼ -③ 写入对象存储临时区(文件逐个上传到 `tmp/{uploadId}/{filePath}`,记录 SHA-256) +③ 同步写入对象存储 + - 文件逐个上传到正式路径 `skills/{skillId}/{versionId}/{filePath}`,记录 SHA-256 + - 生成预打包 zip 到 `packages/{skillId}/{versionId}/bundle.zip` │ ▼ -④ 持久化数据 - - 创建 skill_version (status=DRAFT, file_transfer_status=PENDING) +④ 持久化数据(与 ③ 在同一事务/请求中) + - 创建或关联 skill 记录(首次发布时创建 skill) + - 创建 skill_version (status=DRAFT) - 创建 skill_file 记录 - 解析 SKILL.md frontmatter → parsed_metadata_json - 生成 manifest_json - - 异步将文件从 `tmp/` 转正到 `skills/{skillId}/{versionId}/{filePath}` - - 转正成功 → file_transfer_status=COMPLETED - - 转正失败 → file_transfer_status=FAILED,记录失败原因 │ ▼ -⑤ 提交审核(前置检查:file_transfer_status 必须为 COMPLETED,否则拒绝提审) +⑤ 提交审核 - skill_version.status → PENDING_REVIEW - 创建 review_task (status=PENDING) │ @@ -49,26 +53,13 @@ ④ 和 ⑤ 分开,给用户一个检查草稿的机会。CLI 发布走同样的流程。 -### 对象存储临时区与 GC +### 对象存储写入策略 -- 上传阶段文件写入 `tmp/{uploadId}/{filePath}` -- 数据库事务提交成功后,异步将文件从 `tmp/` copy 到正式路径 `skills/{skillId}/{versionId}/{filePath}`,完成后删除 `tmp/` 副本 -- 定时 GC 任务:清理超过 24h 的 `tmp/` 前缀对象(覆盖事务失败、用户取消、流程中断等场景) -- 如果数据库事务失败,`tmp/` 中的文件由 GC 自动清理,不产生孤儿对象 - -### 文件转正补偿机制 - -`skill_version` 增加 `file_transfer_status` 字段(`PENDING` / `COMPLETED` / `FAILED`),用于追踪异步转正状态。 - -安全门控: -- 提交审核(DRAFT → PENDING_REVIEW):前置检查 `file_transfer_status = COMPLETED`,否则返回 400 -- 下载接口:前置检查 `file_transfer_status = COMPLETED`,否则返回 404 - -失败重试: -- 转正失败时标记 `file_transfer_status = FAILED` -- 定时任务每 5 分钟扫描 `file_transfer_status = PENDING` 且 `created_at > 5min ago` 或 `file_transfer_status = FAILED` 的记录,重试转正 -- 最多重试 3 次,超过后标记为 FAILED 并保留,用户可在草稿页看到"文件处理失败"提示,可手动触发重试或删除该版本重新上传 -- `tmp/` 中的源文件在转正成功前不删除,确保重试有源可用 +一期同步写入正式路径,不使用临时区: +- 文件直接写入 `skills/{skillId}/{versionId}/{filePath}` +- 如果数据库事务失败,对象存储中的文件成为孤儿对象 +- 定时 GC 任务:每天扫描对象存储中存在但数据库中无对应 `skill_file` 记录的文件,清理孤儿对象 +- 删除 DRAFT/REJECTED 版本时,同步清理对应的对象存储文件 ### CLI publish 请求规范 @@ -81,42 +72,28 @@ Parts: - auto_submit: boolean(可选,默认 false,为 true 时自动提交审核) ``` -CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核(`auto_submit=true`)。 +一期同步响应:服务端同步完成上传、校验、存储、持久化,返回 `200 OK` + skill_version 信息。 + +CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核(`auto_submit=true`),一次请求完成。 Web 端默认行为:上传 → 创建 DRAFT → 用户预览确认 → 手动提交审核。 -### CLI publish 异步协议 - -`auto_submit=true` 时,服务端在文件转正完成后自动提交审核,CLI 不需要额外调用 submit-review。 - -``` -CLI 调用 POST /api/v1/cli/publish (auto_submit=true) - │ - ▼ -服务端返回 202 Accepted + publishId + 初始状态 - │ - ▼ -CLI 轮询 GET /api/v1/cli/publish/{publishId}/status - │ - ├── TRANSFERRING → 文件转正中,继续轮询(建议间隔 2s) - ├── SUBMITTED → 文件转正完成 + 已自动提交审核,CLI 结束 - ├── DRAFT → auto_submit=false 时,文件转正完成但未提审,CLI 结束 - └── FAILED → 文件转正失败,返回错误原因,CLI 提示用户 -``` - -`/api/v1/cli/publish/{publishId}/status` 响应: +`/api/v1/cli/publish` 响应: ```json { "data": { - "publishId": "uuid", + "skillId": 456, "skillVersionId": 123, - "fileTransferStatus": "COMPLETED", - "versionStatus": "PENDING_REVIEW", - "error": null + "version": "1.2.0", + "status": "PENDING_REVIEW", + "namespace": "team-name", + "slug": "my-skill" } } ``` +`auto_submit=false` 时,`status` 为 `DRAFT`,用户需后续手动提交审核。 + ## 2 团队技能提升到全局空间(派生发布) 不直接修改原 skill 的 `namespace_id`,而是在全局空间创建新的 skill,保留来源追溯。原团队 skill 继续存在,安装坐标 `@team/skill` 不受影响。 @@ -177,8 +154,8 @@ CLI 轮询 GET /api/v1/cli/publish/{publishId}/status 一期使用原子 SQL 直接更新,可接受。如出现热点行瓶颈,切换为: 1. Redis `INCR` 做实时计数(key: `skill:downloads:{skillId}`) -2. 定时任务每 5 分钟批量回写 MySQL -3. 查询时合并 MySQL 存量 + Redis 增量 +2. 定时任务每 5 分钟批量回写 PostgreSQL +3. 查询时合并 PostgreSQL 存量 + Redis 增量 ## 4 搜索流程 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 80dfe023..696b0031 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -12,6 +12,10 @@ | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/file?path=...` | 读取单个文件(query param 避免路径中 / 的解析问题) | | GET | `/api/v1/skills/{namespace}/{slug}/download` | 下载默认安装版本(latest_version_id 指向的版本) | | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本包 | +| GET | `/api/v1/skills/{namespace}/{slug}/resolve` | 解析技能版本(支持 query param: `version`、`tag`、`hash`) | +| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) | +| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files` | 按标签查看文件清单 | +| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file?path=...` | 按标签读取单个文件 | | GET | `/api/v1/namespaces` | 公开命名空间列表 | | GET | `/api/v1/namespaces/{slug}` | 命名空间详情 | @@ -56,7 +60,7 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 DRAFT 版本提交审核(前置:file_transfer_status=COMPLETED) | +| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 DRAFT 版本提交审核 | | POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | 撤回提审(PENDING_REVIEW → DRAFT,同时删除关联的 PENDING review_task) | | GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/draft` | 查看草稿详情(owner 或 namespace ADMIN 以上) | @@ -182,7 +186,182 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | CLI `install @team/skill` | `latest_version_id` | 等同于 `@latest` | | CLI `install @team/skill@beta` | `skill_tag` 查询 | 自定义标签指向的版本 | -## 7.9 Rate Limiting +## 7.9 Resolve 接口说明 + +`GET /api/v1/skills/{namespace}/{slug}/resolve` 用于解析技能版本,支持以下 query param: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `version` | string | 精确版本号(如 `1.2.0`) | +| `tag` | string | 标签名(如 `beta`、`latest`) | +| `hash` | string | fingerprint 哈希,用于判断本地版本是否与 registry 同步 | + +解析优先级: +1. `version` 和 `tag` 不可同时传,同时传返回 `400 Bad Request` +2. 仅传 `version`:精确匹配版本号 +3. 仅传 `tag`:查询 `skill_tag` 表获取 `target_version_id` +4. 仅传 `hash`:遍历已发布版本,比对 fingerprint +5. 均不传:返回 `latest_version_id` 指向的版本 + +响应: + +```json +{ + "data": { + "skillId": 456, + "namespace": "team-name", + "slug": "my-skill", + "version": "1.2.0", + "versionId": 123, + "fingerprint": "sha256:abc123...", + "downloadUrl": "/api/v1/skills/team-name/my-skill/versions/1.2.0/download" + } +} +``` + +`hash` 匹配时额外返回 `"matched": true`,不匹配时返回最新版本信息 + `"matched": false`。 + +## 7.10 ClawHub CLI 兼容层 API + +兼容层 API 基地址为 `/api/compat/v1`,通过 `/.well-known/clawhub.json` 发现。兼容层使用 canonical slug(双连字符映射规则,详见 `00-product-direction.md` 1.1 节)。 + +认证方式:`Authorization: Bearer `,复用 skillhub API Token 体系。 + +### Well-known 发现 + +``` +GET /.well-known/clawhub.json + +响应: +{ + "apiBase": "/api/compat/v1" +} +``` + +### 兼容层端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/compat/v1/whoami` | 当前用户信息 | +| GET | `/api/compat/v1/search` | 搜索技能 | +| GET | `/api/compat/v1/resolve` | 通过 slug + fingerprint 解析版本 | +| GET | `/api/compat/v1/download` | 下载技能 zip 包 | +| GET | `/api/compat/v1/skills` | 列出技能(分页) | +| POST | `/api/compat/v1/skills` | 发布技能(multipart/form-data) | +| GET | `/api/compat/v1/skills/{slug}` | 获取技能详情 | +| DELETE | `/api/compat/v1/skills/{slug}` | 软删除技能 | +| GET | `/api/compat/v1/skills/{slug}/versions` | 列出版本 | +| GET | `/api/compat/v1/skills/{slug}/versions/{version}` | 版本详情 | +| GET | `/api/compat/v1/skills/{slug}/file` | 获取单个文件内容 | +| POST | `/api/compat/v1/stars/{slug}` | 收藏 | +| DELETE | `/api/compat/v1/stars/{slug}` | 取消收藏 | + +### 兼容层请求/响应格式 + +**GET `/api/compat/v1/whoami`** + +```json +{ + "handle": "username", + "displayName": "User Name", + "role": "user" +} +``` + +**GET `/api/compat/v1/search?q={keyword}&page={page}&limit={limit}`** + +```json +{ + "results": [ + { + "slug": "my-skill", + "name": "My Skill", + "description": "...", + "author": { "handle": "username", "displayName": "User Name" }, + "version": "1.2.0", + "downloadCount": 100, + "starCount": 50, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-03-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 20 +} +``` + +注意:兼容层返回的 `slug` 为 canonical slug 格式(全局空间直接返回 skill slug,团队空间返回 `namespace--skill`)。 + +**GET `/api/compat/v1/resolve?slug={slug}&hash={fingerprint}`** + +```json +{ + "slug": "my-skill", + "version": "1.2.0", + "fingerprint": "sha256:abc123...", + "matched": true +} +``` + +`hash` 不匹配时返回最新版本 + `"matched": false`。 + +**GET `/api/compat/v1/download?slug={slug}&version={version}`** + +返回 zip 文件流。`version` 可选,不传时下载最新已发布版本。 + +**POST `/api/compat/v1/skills`** + +``` +Content-Type: multipart/form-data +Parts: + - file: zip 包 +``` + +一期同步响应,返回发布结果: + +```json +{ + "slug": "my-skill", + "version": "1.0.0", + "status": "pending_review" +} +``` + +注意:ClawHub 原始协议使用两步发布(先获取 upload URL,再 JSON publish),skillhub 兼容层简化为单步 multipart 上传。如果 ClawHub CLI 的发布流程无法适配单步模式,需要额外实现 `/api/compat/v1/upload-url` + `/api/compat/v1/publish` 两步兼容端点。 + +**GET `/api/compat/v1/skills/{slug}`** + +```json +{ + "slug": "my-skill", + "name": "My Skill", + "description": "...", + "author": { "handle": "username", "displayName": "User Name" }, + "version": "1.2.0", + "versions": ["1.0.0", "1.1.0", "1.2.0"], + "license": "MIT-0", + "downloadCount": 100, + "starCount": 50, + "starred": false, + "files": [ + { "path": "SKILL.md", "size": 1024 } + ], + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-03-01T00:00:00Z" +} +``` + +### 兼容层适配说明 + +- 兼容层是独立的 Controller 层,内部调用与 native API 相同的领域服务 +- 请求进入时将 canonical slug 转换为 `(namespace_id, skill_slug)` 坐标 +- 响应返回时将内部坐标转换为 canonical slug +- 兼容层不暴露 namespace 概念,对 ClawHub CLI 透明 +- 发布时如果 canonical slug 包含 `--`,解析为团队空间发布;否则发布到全局空间 +- 兼容层的认证复用 skillhub API Token,ClawHub CLI 通过 `clawhub login` 获取 token 后即可使用 + +## 7.11 Rate Limiting 分两阶段实施: @@ -203,10 +382,24 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: 触发限流时返回 `429 Too Many Requests` + `Retry-After` Header。 -## 7.10 API 设计原则 +## 7.12 API 设计原则 -- 统一响应格式:`{ code, message, data, timestamp }` +### Native API(`/api/v1/*`) + +- 统一响应包裹:`{ code, message, data, timestamp }` - 分页格式:`{ items, total, page, size }` - 错误码体系:业务错误码 + HTTP 状态码配合 - 版本策略:URL path 版本 `/api/v1/` - 幂等性:写操作通过 `X-Request-Id` + Redis 去重(TTL 24h) + +### Compatibility API(`/api/compat/v1/*`) + +- 响应格式完全遵循 ClawHub 协议,不套统一响应包裹 +- 错误响应遵循 ClawHub 格式:`{ error: string, message: string }` +- 分页格式遵循 ClawHub 格式:`{ results, total, page, limit }` + +### OpenAPI 文档分离 + +生成两份独立的 OpenAPI spec: +- `openapi-native.json`:skillhub Native API,用于前端 SDK 生成 +- `openapi-compat-clawhub.json`:ClawHub 兼容层 API,用于兼容性测试和文档 diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index c00da72c..5f144449 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -113,9 +113,33 @@ CLI 安装后在本地写入 `.astron/metadata.json`: ## 8.7 版本解析规则 +skillhub 自有 CLI 支持完整 namespace 坐标: + ``` install @team/my-skill → 最新已发布版本(latest_version_id) install @team/my-skill@1.2.0 → 精确版本 install @team/my-skill@latest → 等同于不带版本号(系统保留标签,只读) install @team/my-skill@beta → beta 标签(自定义标签) +install my-skill → 等同于 @global/my-skill ``` + +ClawHub CLI 通过兼容层使用 canonical slug: + +``` +clawhub install my-skill → @global/my-skill 的最新版本 +clawhub install team-name--my-skill → @team-name/my-skill 的最新版本 +clawhub install my-skill@1.2.0 → @global/my-skill 的精确版本 +``` + +## 8.8 坐标映射与 ClawHub CLI 兼容 + +skillhub 内部使用 `@{namespace_slug}/{skill_slug}` 坐标,ClawHub CLI 使用单一 slug。映射规则详见 `00-product-direction.md` 1.1 节。 + +安装后的本地目录名始终使用 `skill.slug`(不含 namespace 前缀),确保与 OpenSkills/Claude 兼容客户端的互操作性。 + +| skillhub 坐标 | ClawHub canonical slug | 本地安装目录名 | +|---|---|---| +| `@global/my-skill` | `my-skill` | `my-skill/` | +| `@team-name/my-skill` | `team-name--my-skill` | `my-skill/` | + +注意:不同 namespace 下同名 skill 安装到本地时会产生目录冲突。skillhub CLI 应在安装时检测冲突并提示用户选择安装目录或使用别名。 diff --git a/docs/08-frontend-architecture.md b/docs/08-frontend-architecture.md index 6a13fe8b..6b243f82 100644 --- a/docs/08-frontend-architecture.md +++ b/docs/08-frontend-architecture.md @@ -139,8 +139,8 @@ window.location.href = "/oauth2/authorization/github" ## 7 关键交互 -**技能详情页**:SKILL.md Markdown 渲染、右侧信息栏(版本/下载量/评分/收藏/标签/空间)、版本切换、安装命令一键复制。匿名用户可浏览和下载,收藏/评分按钮提示登录。 +**技能详情页**:SKILL.md Markdown 渲染、右侧信息栏(版本/下载量/评分/收藏/标签/空间)、版本切换、安装命令一键复制(同时展示 skillhub CLI 格式 `install @namespace/slug` 和 ClawHub CLI 格式 `install canonical-slug`)。匿名用户可浏览和下载,收藏/评分按钮提示登录。 -**搜索页**:实时搜索(debounce 300ms)、技能卡片、排序(相关度/下载量/评分/最新)、命名空间过滤。匿名用户可搜索 PUBLIC 技能。 +**搜索页**:实时搜索(debounce 300ms)、技能卡片、排序(相关度/下载量/评分/最新)、命名空间过滤。匿名用户可搜索 PUBLIC 技能。注意:一期搜索仅基于 latest 版本内容,不支持按 tag/version 搜索(详见 `04-search-architecture.md` 5.1 节)。 **审核页面**:左侧列表 + 右侧内容预览(Markdown + 文件树)、通过/拒绝 + 意见输入。 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 6555c9df..66659ada 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -20,14 +20,14 @@ │ │ │ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌──────────────┐ -│ MySQL │ │ Redis │ │ S3 / MinIO │ -│ (主从) │ │ │ │ │ +│ PostgreSQL│ │ Redis │ │ S3 / MinIO │ +│ (主从) │ │ │ │ │ └────────┘ └────────┘ └──────────────┘ ``` ## 2 服务配置 -- 无状态设计,所有状态存储在 MySQL / Redis / S3 +- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3 - 健康检查:`/actuator/health`(liveness + readiness 分离) - 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s` - JVM:`-XX:MaxRAMPercentage=75.0` @@ -36,7 +36,7 @@ | Profile | 用途 | 特点 | |---------|------|------| -| `local` | 本地开发 | 本地 MySQL/MinIO,Mock OAuth(见下方说明) | +| `local` | 本地开发 | Docker Compose 一键启动(PostgreSQL/Redis/MinIO),Mock OAuth(见下方说明) | | `dev` | 开发环境 | 共享基础设施,GitHub OAuth 测试应用 | | `staging` | 预发布 | 与生产同构 | | `prod` | 生产 | 多 Pod,完整基础设施 | @@ -50,6 +50,354 @@ MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。 +### Docker Compose 一键启动 + +项目提供两套 Docker Compose 配置,分别用于本地开发和完整部署。 + +#### docker-compose.yml — 本地开发(仅依赖服务) + +本地开发时前后端在宿主机运行,Docker Compose 只拉起依赖服务: + +```yaml +# docker-compose.yml(项目根目录) +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: skillhub + POSTGRES_USER: skillhub + POSTGRES_PASSWORD: skillhub_dev + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + minio: + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" # MinIO Console + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + +volumes: + postgres_data: + minio_data: +``` + +#### docker-compose.prod.yml — 完整部署(前后端 + 依赖服务) + +开发完成后,通过 `docker compose -f docker-compose.prod.yml up -d` 一键打包并启动整个系统: + +```yaml +# docker-compose.prod.yml(项目根目录) +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: skillhub + POSTGRES_USER: skillhub + POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skillhub"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + server: + build: + context: ./server + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: prod + DATABASE_URL: jdbc:postgresql://postgres:5432/skillhub + DATABASE_USERNAME: skillhub + DATABASE_PASSWORD: ${DB_PASSWORD:-skillhub_prod} + REDIS_HOST: redis + REDIS_PORT: 6379 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} + S3_BUCKET: skillhub + OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID} + OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + web: + build: + context: ./web + dockerfile: Dockerfile + ports: + - "80:80" + depends_on: + server: + condition: service_healthy + +volumes: + postgres_data: + minio_data: +``` + +#### 前后端 Dockerfile + +后端 Dockerfile(`server/Dockerfile`): +```dockerfile +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /app +COPY pom.xml . +COPY skillhub-app/pom.xml skillhub-app/ +COPY skillhub-domain/pom.xml skillhub-domain/ +COPY skillhub-auth/pom.xml skillhub-auth/ +COPY skillhub-search/pom.xml skillhub-search/ +COPY skillhub-storage/pom.xml skillhub-storage/ +COPY skillhub-infra/pom.xml skillhub-infra/ +RUN mvn dependency:go-offline -B +COPY . . +RUN mvn package -DskipTests -B + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/skillhub-app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] +``` + +前端 Dockerfile(`web/Dockerfile`): +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +前端 Nginx 配置(`web/nginx.conf`): +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # SPA 路由回退 + location / { + try_files $uri $uri/ /index.html; + } + + # API 反向代理到后端 + location /api/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # OAuth2 回调反向代理 + location /oauth2/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /login/oauth2/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Well-known 发现端点 + location /.well-known/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + } +} +``` + +### Spring Boot 配置文件分层 + +``` +server/skillhub-app/src/main/resources/ +├── application.yml # 公共配置(所有 profile 共享) +├── application-local.yml # 本地开发(Docker Compose 服务地址) +├── application-dev.yml # 开发环境 +├── application-staging.yml # 预发布 +└── application-prod.yml # 生产 +``` + +`application.yml`(公共配置): +```yaml +spring: + application: + name: skillhub + jpa: + open-in-view: false + hibernate: + ddl-auto: validate # 由 Flyway 管理 schema,Hibernate 仅校验 + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + locations: classpath:db/migration + +server: + shutdown: graceful + +spring.lifecycle.timeout-per-shutdown-phase: 30s +``` + +`application-local.yml`(本地开发,对应 Docker Compose): +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/skillhub + username: skillhub + password: skillhub_dev + data: + redis: + host: localhost + port: 6379 + jpa: + show-sql: true + +skillhub: + storage: + type: s3 + endpoint: http://localhost:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: skillhub + region: us-east-1 + access-policy: + mode: OPEN # 本地开发默认开放准入 +``` + +`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入): +```yaml +spring: + datasource: + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + jpa: + show-sql: false + +skillhub: + storage: + type: s3 + endpoint: ${S3_ENDPOINT} + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + bucket: ${S3_BUCKET:skillhub} + region: ${S3_REGION:us-east-1} +``` + +### 本地开发启动流程 + +```bash +# 1. 启动依赖服务 +docker compose up -d + +# 2. 启动后端(自动执行 Flyway 迁移) +cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local + +# 3. 启动前端 +cd web && pnpm dev +``` + +### 完整部署(一键打包 + 启动) + +```bash +# 构建并启动所有服务(前后端 + 依赖) +docker compose -f docker-compose.prod.yml up -d --build + +# 仅重新构建并重启应用服务(依赖服务不重启) +docker compose -f docker-compose.prod.yml up -d --build server web + +# 停止所有服务 +docker compose -f docker-compose.prod.yml down + +# 停止并清除数据卷(慎用) +docker compose -f docker-compose.prod.yml down -v +``` + +### Makefile 命令 + +```bash +make dev # docker compose up -d + 后端 + 前端(本地开发) +make dev-down # docker compose down +make build # 构建后端 JAR + 前端 dist +make docker # 构建前后端 Docker 镜像 +make deploy # docker compose -f docker-compose.prod.yml up -d --build +make deploy-down # docker compose -f docker-compose.prod.yml down +make generate-api # 生成 OpenAPI 类型 +``` + ## 4 配置管理 - 敏感配置:K8s Secret(数据库/Redis/S3 凭证、OAuth2 Client ID/Secret) @@ -68,6 +416,8 @@ requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志 ## 6 构建与发布 +### CI Pipeline 构建 + ``` 代码提交 → CI Pipeline ├── server: mvn package → JAR @@ -75,14 +425,30 @@ requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志 │ ▼ Docker 多阶段构建 - ├── server → openjdk:21-jre-slim + ├── server → eclipse-temurin:21-jre-alpine └── web → nginx:alpine │ ▼ 推送镜像 → K8s 滚动更新 ``` -Makefile 顶层命令:`make dev-server`, `make dev-web`, `make build`, `make docker`, `make generate-api` +### Docker Compose 完整部署 + +``` +make deploy + │ + ▼ +docker compose -f docker-compose.prod.yml up -d --build + │ + ├── 构建 server 镜像(Maven 多阶段构建 → JRE 运行) + ├── 构建 web 镜像(pnpm build → Nginx 静态服务 + 反向代理) + ├── 拉起 PostgreSQL / Redis / MinIO + ├── 等待依赖服务健康检查通过 + ├── 启动 server(自动执行 Flyway 迁移) + └── 启动 web(Nginx 代理 API 到 server) +``` + +Makefile 顶层命令:`make dev`, `make dev-down`, `make build`, `make docker`, `make deploy`, `make deploy-down`, `make generate-api` ## 7 数据库迁移 diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index 3d1d9473..f7a1bf4f 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -4,6 +4,12 @@ 产出:架构设计文档、数据库 DDL、API OpenAPI spec 草案、前端线框图 +已冻结决策: +- 技能坐标体系:`@{namespace_slug}/{skill_slug}`,兼容层使用 `--` 双连字符映射(详见 `00-product-direction.md` 1.1 节) +- 一期同步发布模型,暂不考虑异步发布 +- API Token 一期继承用户全部权限(非最小权限),后续版本细化 +- ClawHub CLI 兼容层基地址 `/api/compat/v1`,通过 `/.well-known/clawhub.json` 发现 + ## Phase 1:工程骨架 + 认证打通 ### 后端 @@ -42,9 +48,9 @@ - 命名空间 CRUD + 成员管理 - 对象存储集成 -- 技能发布(上传 → 校验 → 存储 → draft) +- 技能发布(上传 → 校验 → 存储 → draft,一期同步处理) - 技能查询(详情、版本、文件)、下载(打包 + 可见性检查,PUBLIC 匿名可下载) -- 标签管理、搜索(MySQL Full-Text,匿名搜索限 PUBLIC) +- 标签管理、搜索(PostgreSQL Full-Text,匿名搜索限 PUBLIC) - 异步事件基础设施 - Rate Limiting 升级(应用层精细限流:按用户/端点分类,基于 Redis 滑动窗口) @@ -67,7 +73,9 @@ - 团队技能提升到全局(promotion_request 流程) - 评分 + 收藏 + 计数器(原子更新) - CLI API(whoami、publish、resolve、check) -- ClawHub CLI 协议兼容层(registry metadata、resolve、download、publish、check 等核心接口) +- ClawHub CLI 协议兼容层(`/api/compat/v1` 端点:search、resolve、download、publish、skills CRUD、stars) +- 兼容层 canonical slug 映射(`--` 双连字符规则) +- `/.well-known/clawhub.json` 发现端点 - 协议适配器与兼容性测试(针对 ClawHub CLI 的真实请求/响应样例) - 审计日志(同步落库)、幂等去重(idempotency_record + Redis) @@ -109,4 +117,4 @@ | 搜索效果不佳 | SPI 架构允许随时切换实现 | | 前后端接口频繁变更 | OpenAPI spec 先行,类型自动生成 | | 新增 OAuth Provider | Spring Security OAuth2 原生多 Provider 支持,只需配置 + 属性映射 | -| ClawHub CLI 协议细节与现有模型不完全一致 | 增加兼容适配层与协议回归测试,避免把 skillhub 内部模型直接暴露给兼容客户端 | +| ClawHub CLI 协议细节与现有模型不完全一致 | 兼容层使用 `--` 双连字符 canonical slug 映射,独立 Controller 层适配,协议回归测试覆盖 | diff --git a/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md b/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md new file mode 100644 index 00000000..d3f56c35 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md @@ -0,0 +1,4507 @@ +# Phase 1: 工程骨架 + 认证打通 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立可运行的前后端工程骨架,完成 GitHub OAuth 登录和 API Token 认证,满足 Phase 1 验收标准 + +**Architecture:** Maven 多模块后端(6 模块)+ React 前端 + Docker Compose 本地开发环境 + Spring Security OAuth2 + RBAC + +**Tech Stack:** +- Backend: Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Security OAuth2 Client + Spring Data JPA + Flyway +- Frontend: React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui + Tailwind CSS +- DevOps: Docker Compose + Maven Wrapper + Makefile + +--- + +## Chunk 1: 后端工程骨架 + 基础设施 + +本块建立 Maven 多模块项目结构、数据库迁移、基础配置、健康检查和 OpenAPI 文档,产出可启动的后端应用。 + +### 文件结构映射 + +``` +skillhub/ +├── server/ +│ ├── pom.xml # 父 POM +│ ├── .mvn/wrapper/ # Maven Wrapper +│ ├── mvnw, mvnw.cmd +│ ├── skillhub-app/ +│ │ ├── pom.xml +│ │ └── src/main/ +│ │ ├── java/com/skillhub/ +│ │ │ ├── SkillhubApplication.java +│ │ │ ├── config/ +│ │ │ │ ├── OpenApiConfig.java +│ │ │ │ └── WebMvcConfig.java +│ │ │ ├── controller/ +│ │ │ │ └── HealthController.java +│ │ │ └── filter/ +│ │ │ └── RequestIdFilter.java +│ │ └── resources/ +│ │ ├── application.yml +│ │ ├── application-local.yml +│ │ └── db/migration/ +│ │ └── V1__init_schema.sql +│ ├── skillhub-domain/ +│ │ ├── pom.xml +│ │ └── src/main/java/com/skillhub/domain/ +│ ├── skillhub-auth/ +│ │ ├── pom.xml +│ │ └── src/main/java/com/skillhub/auth/ +│ ├── skillhub-search/ +│ │ ├── pom.xml +│ │ └── src/main/java/com/skillhub/search/ +│ ├── skillhub-storage/ +│ │ ├── pom.xml +│ │ └── src/main/java/com/skillhub/storage/ +│ └── skillhub-infra/ +│ ├── pom.xml +│ └── src/main/java/com/skillhub/infra/ +├── docker-compose.yml +├── .gitignore +└── Makefile +``` + +### Task 1: 初始化 Monorepo 和 Maven 多模块项目 + +**Files:** +- Create: `server/pom.xml` +- Create: `server/skillhub-app/pom.xml` +- Create: `server/skillhub-domain/pom.xml` +- Create: `server/skillhub-auth/pom.xml` +- Create: `server/skillhub-search/pom.xml` +- Create: `server/skillhub-storage/pom.xml` +- Create: `server/skillhub-infra/pom.xml` +- Create: `.gitignore` + +- [ ] **Step 1: 创建根 .gitignore** + +```bash +cat > .gitignore << 'EOF' +# Maven +target/ +!.mvn/wrapper/maven-wrapper.jar +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties + +# IDE +.idea/ +*.iml +.vscode/ +.DS_Store + +# Logs +*.log + +# Environment +.env +.env.local + +# Node +node_modules/ +dist/ +.pnpm-store/ +EOF +``` + +- [ ] **Step 2: 创建父 POM (server/pom.xml)** + +```bash +mkdir -p server && cat > server/pom.xml << 'EOF' + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.3 + + + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + pom + + + 21 + 21 + 21 + UTF-8 + + + + skillhub-app + skillhub-domain + skillhub-auth + skillhub-search + skillhub-storage + skillhub-infra + + + + + + + com.skillhub + skillhub-domain + ${project.version} + + + com.skillhub + skillhub-auth + ${project.version} + + + com.skillhub + skillhub-search + ${project.version} + + + com.skillhub + skillhub-storage + ${project.version} + + + com.skillhub + skillhub-infra + ${project.version} + + + + +EOF +``` + +- [ ] **Step 3: 创建 skillhub-app 模块 POM** + +```bash +mkdir -p server/skillhub-app && cat > server/skillhub-app/pom.xml << 'EOF' + + + 4.0.0 + + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + + skillhub-app + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + com.skillhub + skillhub-domain + + + com.skillhub + skillhub-auth + + + com.skillhub + skillhub-infra + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + +EOF +``` + +- [ ] **Step 4: 创建其他模块的 POM(domain, auth, search, storage, infra)** + +```bash +# skillhub-domain +mkdir -p server/skillhub-domain/src/main/java/com/skillhub/domain +cat > server/skillhub-domain/pom.xml << 'EOF' + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-domain + +EOF + +# skillhub-auth +mkdir -p server/skillhub-auth/src/main/java/com/skillhub/auth +cat > server/skillhub-auth/pom.xml << 'EOF' + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-auth + + + com.skillhub + skillhub-domain + + + +EOF + +# skillhub-search +mkdir -p server/skillhub-search/src/main/java/com/skillhub/search +cat > server/skillhub-search/pom.xml << 'EOF' + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-search + + + com.skillhub + skillhub-domain + + + +EOF + +# skillhub-storage +mkdir -p server/skillhub-storage/src/main/java/com/skillhub/storage +cat > server/skillhub-storage/pom.xml << 'EOF' + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-storage + +EOF + +# skillhub-infra +mkdir -p server/skillhub-infra/src/main/java/com/skillhub/infra +cat > server/skillhub-infra/pom.xml << 'EOF' + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-infra + + + com.skillhub + skillhub-domain + + + +EOF +``` + +- [ ] **Step 5: 安装 Maven Wrapper** + +Run: `cd server && mvn wrapper:wrapper` + +Expected: Maven Wrapper 文件生成在 `server/.mvn/wrapper/` + +- [ ] **Step 6: 验证项目结构** + +Run: `cd server && ./mvnw clean compile` + +Expected: `BUILD SUCCESS`,所有模块编译通过 + +- [ ] **Step 7: Commit** + +```bash +git add .gitignore server/ +git commit -m "feat: initialize Maven multi-module project structure + +- Add parent POM with 6 modules (app, domain, auth, search, storage, infra) +- Configure Spring Boot 3.2.3 + JDK 21 +- Add Maven Wrapper for reproducible builds +- Set up module dependency graph (app depends on all, infra/auth/search depend on domain)" +``` + +### Task 2: 创建 Spring Boot 应用入口和基础配置 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java` +- Create: `server/skillhub-app/src/main/resources/application.yml` +- Create: `server/skillhub-app/src/main/resources/application-local.yml` +- Create: `server/skillhub-app/src/test/java/com/skillhub/ApplicationContextStartsTest.java` + +- [ ] **Step 1: 编写失败的 ApplicationContext 启动测试** + +```bash +mkdir -p server/skillhub-app/src/test/java/com/skillhub +cat > server/skillhub-app/src/test/java/com/skillhub/ApplicationContextStartsTest.java << 'EOF' +package com.skillhub; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationContextStartsTest { + + @Test + void contextLoads() { + // ApplicationContext should start successfully + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -Dtest=ApplicationContextStartsTest` + +Expected: FAIL - "Unable to find a @SpringBootConfiguration" + +- [ ] **Step 3: 创建 SkillhubApplication 主类** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/skillhub +cat > server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java << 'EOF' +package com.skillhub; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SkillhubApplication { + + public static void main(String[] args) { + SpringApplication.run(SkillhubApplication.java, args); + } +} +EOF +``` + +- [ ] **Step 4: 创建基础配置文件** + +```bash +mkdir -p server/skillhub-app/src/main/resources +cat > server/skillhub-app/src/main/resources/application.yml << 'EOF' +spring: + application: + name: skillhub + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + locations: classpath:db/migration + +server: + shutdown: graceful + +spring.lifecycle.timeout-per-shutdown-phase: 30s + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: when-authorized +EOF + +cat > server/skillhub-app/src/main/resources/application-local.yml << 'EOF' +spring: + datasource: + url: jdbc:postgresql://localhost:5432/skillhub + username: skillhub + password: skillhub_dev + data: + redis: + host: localhost + port: 6379 + jpa: + show-sql: true + +logging: + level: + com.skillhub: DEBUG +EOF +``` + +- [ ] **Step 5: 运行测试确认通过** + +Run: `cd server && ./mvnw test -Dtest=ApplicationContextStartsTest` + +Expected: FAIL - "Failed to configure a DataSource" (预期,因为还没有数据库) + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/ +git commit -m "feat: add Spring Boot application entry point and base configuration + +- Create SkillhubApplication main class +- Add application.yml with JPA, Flyway, graceful shutdown config +- Add application-local.yml for local development profile +- Add ApplicationContextStartsTest (will pass after DB setup)" +``` + +### Task 3: 添加 Docker Compose 本地开发环境 + +**Files:** +- Create: `docker-compose.yml` + +- [ ] **Step 1: 创建 docker-compose.yml** + +```bash +cat > docker-compose.yml << 'EOF' +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: skillhub + POSTGRES_USER: skillhub + POSTGRES_PASSWORD: skillhub_dev + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skillhub"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + minio_data: +EOF +``` + +- [ ] **Step 2: 启动依赖服务** + +Run: `docker compose up -d` + +Expected: PostgreSQL, Redis, MinIO 启动成功,健康检查通过 + +- [ ] **Step 3: 验证服务可访问** + +Run: `docker compose ps` + +Expected: 所有服务状态为 `healthy` + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat: add Docker Compose for local development dependencies + +- Add PostgreSQL 16, Redis 7, MinIO services +- Configure health checks for all services +- Use named volumes for data persistence" +``` + +### Task 4: 添加 Flyway 数据库迁移和 Phase 1 核心表 + +**Files:** +- Create: `server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql` +- Update: `server/skillhub-app/pom.xml` (添加 Flyway 和 PostgreSQL 驱动依赖) + +- [ ] **Step 1: 更新 skillhub-app POM 添加数据库依赖** + +```bash +# 在 skillhub-app/pom.xml 的 中添加: +cat >> server/skillhub-app/pom.xml.tmp << 'EOF' + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.springframework.boot + spring-boot-starter-data-redis + +EOF +# 手动编辑 server/skillhub-app/pom.xml,在 前插入上述依赖 +``` + +- [ ] **Step 2: 创建 Flyway 迁移脚本 V1__init_schema.sql** + +```bash +mkdir -p server/skillhub-app/src/main/resources/db/migration +cat > server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql << 'EOF' +-- Phase 1 核心表:认证与授权 + +-- 用户账号表 +CREATE TABLE user_account ( + id BIGSERIAL PRIMARY KEY, + display_name VARCHAR(128) NOT NULL, + email VARCHAR(256), + avatar_url VARCHAR(512), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + merged_to_user_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_user_account_email ON user_account(email); +CREATE INDEX idx_user_account_status ON user_account(status); + +-- OAuth 身份绑定表 +CREATE TABLE identity_binding ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_account(id), + provider_code VARCHAR(64) NOT NULL, + subject VARCHAR(256) NOT NULL, + login_name VARCHAR(128), + extra_json JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider_code, subject) +); + +CREATE INDEX idx_identity_binding_user_id ON identity_binding(user_id); + +-- API Token 表 +CREATE TABLE api_token ( + id BIGSERIAL PRIMARY KEY, + subject_type VARCHAR(32) NOT NULL DEFAULT 'USER', + subject_id BIGINT NOT NULL, + user_id BIGINT NOT NULL REFERENCES user_account(id), + name VARCHAR(128) NOT NULL, + token_prefix VARCHAR(16) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + scope_json JSONB NOT NULL, + expires_at TIMESTAMP, + last_used_at TIMESTAMP, + revoked_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_api_token_user_id ON api_token(user_id); +CREATE INDEX idx_api_token_hash ON api_token(token_hash); + +-- 角色表 +CREATE TABLE role ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description VARCHAR(512), + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 权限表 +CREATE TABLE permission ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + group_code VARCHAR(64) +); + +-- 角色权限关联表 +CREATE TABLE role_permission ( + role_id BIGINT NOT NULL REFERENCES role(id), + permission_id BIGINT NOT NULL REFERENCES permission(id), + PRIMARY KEY (role_id, permission_id) +); + +-- 用户角色绑定表 +CREATE TABLE user_role_binding ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_account(id), + role_id BIGINT NOT NULL REFERENCES role(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, role_id) +); + +CREATE INDEX idx_user_role_binding_user_id ON user_role_binding(user_id); + +-- 命名空间表 +CREATE TABLE namespace ( + id BIGSERIAL PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, + display_name VARCHAR(128) NOT NULL, + type VARCHAR(32) NOT NULL, + description TEXT, + avatar_url VARCHAR(512), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_by BIGINT REFERENCES user_account(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 命名空间成员表 +CREATE TABLE namespace_member ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + user_id BIGINT NOT NULL REFERENCES user_account(id), + role VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(namespace_id, user_id) +); + +CREATE INDEX idx_namespace_member_user_id ON namespace_member(user_id); +CREATE INDEX idx_namespace_member_namespace_id ON namespace_member(namespace_id); + +-- 审计日志表 +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT REFERENCES user_account(id), + action VARCHAR(64) NOT NULL, + target_type VARCHAR(64), + target_id BIGINT, + request_id VARCHAR(64), + client_ip VARCHAR(64), + user_agent VARCHAR(512), + detail_json JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_audit_log_actor ON audit_log(actor_user_id); +CREATE INDEX idx_audit_log_created_at ON audit_log(created_at); +CREATE INDEX idx_audit_log_request_id ON audit_log(request_id); + +-- 插入系统内置角色 +INSERT INTO role (code, name, description, is_system) VALUES +('SUPER_ADMIN', '超级管理员', '拥有所有权限', TRUE), +('SKILL_ADMIN', '技能管理员', '全局空间审核、提升审核、隐藏/撤回', TRUE), +('USER_ADMIN', '用户管理员', '准入审批、封禁/解封、角色分配', TRUE), +('AUDITOR', '审计员', '查看审计日志', TRUE); + +-- 插入系统权限 +INSERT INTO permission (code, name, group_code) VALUES +('skill:publish', '发布技能', 'skill'), +('skill:manage', '管理技能', 'skill'), +('skill:promote', '提升到全局', 'skill'), +('review:approve', '审核技能', 'review'), +('promotion:approve', '审核提升申请', 'promotion'), +('user:manage', '管理用户', 'user'), +('user:approve', '审批用户准入', 'user'), +('audit:read', '查看审计日志', 'audit'); + +-- 绑定角色权限 +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'SKILL_ADMIN' AND p.code IN ('review:approve', 'skill:manage', 'promotion:approve'); + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'USER_ADMIN' AND p.code IN ('user:manage', 'user:approve'); + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'AUDITOR' AND p.code = 'audit:read'; + +-- 插入系统内置 @global 命名空间 +INSERT INTO namespace (slug, display_name, type, description, status) +VALUES ('global', 'Global', 'GLOBAL', 'Platform-level public namespace', 'ACTIVE'); +EOF +``` + +- [ ] **Step 3: 运行 Flyway 迁移** + +Run: `cd server && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://localhost:5432/skillhub -Dflyway.user=skillhub -Dflyway.password=skillhub_dev` + +Expected: `Successfully applied 1 migration to schema "public"` + +- [ ] **Step 4: 验证表创建成功** + +Run: `docker compose exec postgres psql -U skillhub -d skillhub -c "\dt"` + +Expected: 列出所有表(user_account, identity_binding, api_token, role, permission, role_permission, user_role_binding, namespace, namespace_member, audit_log, flyway_schema_history) + +- [ ] **Step 5: 运行 ApplicationContextStartsTest 确认通过** + +Run: `cd server && ./mvnw test -Dtest=ApplicationContextStartsTest -Dspring.profiles.active=local` + +Expected: PASS - ApplicationContext 启动成功 + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/pom.xml server/skillhub-app/src/main/resources/db/migration/ +git commit -m "feat: add Flyway migration with Phase 1 core schema + +- Add PostgreSQL driver, Flyway, Spring Data JPA, Redis dependencies +- Create V1__init_schema.sql with auth tables (user_account, identity_binding, api_token) +- Create RBAC tables (role, permission, role_permission, user_role_binding) +- Create namespace tables (namespace, namespace_member) +- Create audit_log table +- Insert system roles (SUPER_ADMIN, SKILL_ADMIN, USER_ADMIN, AUDITOR) and permissions +- Insert @global namespace" +``` + +### Task 5: 添加 RequestId Filter 和全局异常处理 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/skillhub/filter/RequestIdFilter.java` +- Create: `server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java` +- Create: `server/skillhub-app/src/main/java/com/skillhub/dto/ErrorResponse.java` +- Test: `server/skillhub-app/src/test/java/com/skillhub/filter/RequestIdFilterTest.java` + +- [ ] **Step 1: 编写 RequestIdFilter 测试** + +```bash +mkdir -p server/skillhub-app/src/test/java/com/skillhub/filter +cat > server/skillhub-app/src/test/java/com/skillhub/filter/RequestIdFilterTest.java << 'EOF' +package com.skillhub.filter; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class RequestIdFilterTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldGenerateRequestIdWhenNotProvided() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")); + } + + @Test + void shouldPreserveProvidedRequestId() throws Exception { + String requestId = "test-request-123"; + mockMvc.perform(get("/actuator/health") + .header("X-Request-Id", requestId)) + .andExpect(status().isOk()) + .andExpect(header().string("X-Request-Id", requestId)); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -Dtest=RequestIdFilterTest -Dspring.profiles.active=local` + +Expected: FAIL - "Expected header X-Request-Id does not exist" + +- [ ] **Step 3: 实现 RequestIdFilter** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/skillhub/filter +cat > server/skillhub-app/src/main/java/com/skillhub/filter/RequestIdFilter.java << 'EOF' +package com.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestIdFilter extends OncePerRequestFilter { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String REQUEST_ID_MDC_KEY = "requestId"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + requestId = UUID.randomUUID().toString(); + } + + MDC.put(REQUEST_ID_MDC_KEY, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); + + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(REQUEST_ID_MDC_KEY); + } + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -Dtest=RequestIdFilterTest -Dspring.profiles.active=local` + +Expected: PASS + +- [ ] **Step 5: 创建全局异常处理器和 DTO** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/skillhub/exception +cat > server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java << 'EOF' +package com.skillhub.exception; + +import com.skillhub.dto.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGlobalException(Exception ex, WebRequest request) { + logger.error("Unhandled exception", ex); + ErrorResponse error = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Internal server error", + ex.getMessage() + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } +} +EOF + +mkdir -p server/skillhub-app/src/main/java/com/skillhub/dto +cat > server/skillhub-app/src/main/java/com/skillhub/dto/ErrorResponse.java << 'EOF' +package com.skillhub.dto; + +public record ErrorResponse( + int status, + String error, + String message +) {} +EOF +``` + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/skillhub/filter/ \ + server/skillhub-app/src/main/java/com/skillhub/exception/ \ + server/skillhub-app/src/main/java/com/skillhub/dto/ \ + server/skillhub-app/src/test/java/com/skillhub/filter/ +git commit -m "feat: add RequestId filter and global exception handler + +- Implement RequestIdFilter to generate/preserve X-Request-Id header +- Add MDC support for request tracing in logs +- Create GlobalExceptionHandler for unified error responses +- Add ErrorResponse DTO +- Add RequestIdFilterTest with MockMvc" +``` + +### Task 6: 添加 OpenAPI 配置和健康检查端点 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/skillhub/config/OpenApiConfig.java` +- Create: `server/skillhub-app/src/main/java/com/skillhub/controller/HealthController.java` +- Test: `server/skillhub-app/src/test/java/com/skillhub/controller/HealthControllerTest.java` + +- [ ] **Step 1: 编写健康检查端点测试** + +```bash +mkdir -p server/skillhub-app/src/test/java/com/skillhub/controller +cat > server/skillhub-app/src/test/java/com/skillhub/controller/HealthControllerTest.java << 'EOF' +package com.skillhub.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldReturnHealthStatus() throws Exception { + mockMvc.perform(get("/api/v1/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -Dtest=HealthControllerTest -Dspring.profiles.active=local` + +Expected: FAIL - 404 Not Found + +- [ ] **Step 3: 实现 HealthController** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/skillhub/controller +cat > server/skillhub-app/src/main/java/com/skillhub/controller/HealthController.java << 'EOF' +package com.skillhub.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1") +public class HealthController { + + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP"); + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -Dtest=HealthControllerTest -Dspring.profiles.active=local` + +Expected: PASS + +- [ ] **Step 5: 创建 OpenAPI 配置** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/skillhub/config +cat > server/skillhub-app/src/main/java/com/skillhub/config/OpenApiConfig.java << 'EOF' +package com.skillhub.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI skillhubOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("SkillHub API") + .description("Skills Registry Platform") + .version("0.1.0")) + .servers(List.of( + new Server().url("http://localhost:8080").description("Local development") + )); + } +} +EOF +``` + +- [ ] **Step 6: 验证 OpenAPI 文档可访问** + +Run: `cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local` (在另一个终端) + +Run: `curl -s http://localhost:8080/v3/api-docs | jq '.info.title'` + +Expected: `"SkillHub API"` + +- [ ] **Step 7: 停止应用并 Commit** + +```bash +# Ctrl+C 停止应用 +git add server/skillhub-app/src/main/java/com/skillhub/config/ \ + server/skillhub-app/src/main/java/com/skillhub/controller/ \ + server/skillhub-app/src/test/java/com/skillhub/controller/ +git commit -m "feat: add OpenAPI configuration and health check endpoint + +- Configure Springdoc OpenAPI with API info and server URL +- Add /api/v1/health endpoint for basic health check +- Add HealthControllerTest +- OpenAPI docs available at /v3/api-docs and /swagger-ui.html" +``` + +### Task 7: 添加 Makefile 顶层编排 + +**Files:** +- Create: `Makefile` + +- [ ] **Step 1: 创建 Makefile** + +```bash +cat > Makefile << 'EOF' +.PHONY: dev dev-down build test clean + +# 启动本地开发环境(仅依赖服务) +dev: + docker compose up -d + @echo "Waiting for services to be healthy..." + @sleep 5 + @echo "Services ready. Start backend with: cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local" + +# 停止本地开发环境 +dev-down: + docker compose down + +# 构建后端 +build: + cd server && ./mvnw clean package -DskipTests + +# 运行测试 +test: + cd server && ./mvnw test + +# 清理构建产物 +clean: + cd server && ./mvnw clean + docker compose down -v + +# 生成 OpenAPI 类型(前端用,Phase 1 暂不实现) +generate-api: + @echo "Frontend not yet implemented" +EOF +``` + +- [ ] **Step 2: 测试 Makefile 命令** + +Run: `make dev` + +Expected: Docker Compose 服务启动,提示信息显示 + +Run: `make test` + +Expected: 所有测试通过 + +Run: `make dev-down` + +Expected: Docker Compose 服务停止 + +- [ ] **Step 3: Commit** + +```bash +git add Makefile +git commit -m "feat: add Makefile for top-level orchestration + +- Add 'make dev' to start Docker Compose dependencies +- Add 'make dev-down' to stop services +- Add 'make build' to build backend JAR +- Add 'make test' to run all tests +- Add 'make clean' to clean build artifacts and volumes" +``` + +--- + +## Chunk 1 验收标准 + +运行以下命令验证 Chunk 1 完成: + +```bash +# 1. 启动依赖服务 +make dev + +# 2. 运行所有测试 +make test +# Expected: BUILD SUCCESS, all tests pass + +# 3. 启动后端应用 +cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local + +# 4. 验证健康检查 +curl http://localhost:8080/api/v1/health +# Expected: {"status":"UP"} + +# 5. 验证 Actuator +curl http://localhost:8080/actuator/health +# Expected: {"status":"UP"} + +# 6. 验证 OpenAPI 文档 +curl http://localhost:8080/v3/api-docs | jq '.info.title' +# Expected: "SkillHub API" + +# 7. 验证 RequestId +curl -v http://localhost:8080/api/v1/health 2>&1 | grep X-Request-Id +# Expected: X-Request-Id header present + +# 8. 验证数据库表 +docker compose exec postgres psql -U skillhub -d skillhub -c "\dt" +# Expected: 列出所有 Phase 1 表 + +# 9. 停止服务 +make dev-down +``` + +Chunk 1 产出:可启动的后端应用 + 数据库 schema + Docker Compose 本地环境 + Makefile 编排。 + +## Chunk 2: 后端认证与授权体系 + +本块实现完整的认证链路:Spring Security OAuth2 GitHub 登录、AccessPolicy 准入策略、身份绑定、Spring Session Redis、API Token 认证、RBAC 授权、MockAuthFilter 本地开发、CSRF 防护。 + +### 文件结构映射 + +``` +server/ +├── skillhub-domain/src/main/java/com/skillhub/domain/ +│ ├── user/ +│ │ ├── UserAccount.java # 用户实体 +│ │ ├── UserStatus.java # 用户状态枚举 +│ │ └── UserAccountRepository.java # Repository 接口 +│ └── namespace/ +│ ├── Namespace.java # 命名空间实体 +│ ├── NamespaceStatus.java # 命名空间状态枚举 +│ ├── NamespaceMember.java # 成员实体 +│ ├── NamespaceRole.java # 命名空间角色枚举 +│ ├── NamespaceRepository.java +│ └── NamespaceMemberRepository.java +├── skillhub-auth/src/main/java/com/skillhub/auth/ +│ ├── entity/ +│ │ ├── IdentityBinding.java +│ │ ├── ApiToken.java +│ │ ├── Role.java +│ │ ├── Permission.java +│ │ ├── RolePermission.java +│ │ └── UserRoleBinding.java +│ ├── repository/ +│ │ ├── IdentityBindingRepository.java +│ │ ├── ApiTokenRepository.java +│ │ ├── RoleRepository.java +│ │ ├── PermissionRepository.java +│ │ └── UserRoleBindingRepository.java +│ ├── oauth/ +│ │ ├── OAuthClaims.java +│ │ ├── OAuthClaimsExtractor.java +│ │ ├── GitHubClaimsExtractor.java +│ │ ├── CustomOAuth2UserService.java +│ │ └── OAuth2LoginSuccessHandler.java +│ ├── policy/ +│ │ ├── AccessPolicy.java +│ │ ├── AccessDecision.java +│ │ ├── OpenAccessPolicy.java +│ │ ├── EmailDomainAccessPolicy.java +│ │ └── AccessPolicyFactory.java +│ ├── identity/ +│ │ └── IdentityBindingService.java +│ ├── token/ +│ │ ├── ApiTokenService.java +│ │ └── ApiTokenAuthenticationFilter.java +│ ├── rbac/ +│ │ ├── RbacService.java +│ │ └── PlatformPrincipal.java +│ ├── config/ +│ │ └── SecurityConfig.java +│ └── mock/ +│ └── MockAuthFilter.java +├── skillhub-app/src/main/java/com/skillhub/ +│ ├── controller/ +│ │ └── AuthController.java +│ └── exception/ +│ ├── GlobalExceptionHandler.java +│ └── ErrorResponse.java +└── skillhub-infra/src/main/java/com/skillhub/infra/ + └── jpa/ + ├── UserAccountJpaRepository.java + ├── NamespaceJpaRepository.java + └── NamespaceMemberJpaRepository.java +``` + +### Task 8: Domain 层用户与命名空间实体 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccount.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserStatus.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccountRepository.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/Namespace.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceStatus.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceMember.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceRole.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceRepository.java` +- Create: `server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceMemberRepository.java` + +- [ ] **Step 1: 创建 UserStatus 枚举** + +```java +// server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserStatus.java +package com.skillhub.domain.user; + +public enum UserStatus { + ACTIVE, + PENDING, + DISABLED, + MERGED +} +``` + +- [ ] **Step 2: 创建 UserAccount 实体** + +```java +// server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccount.java +package com.skillhub.domain.user; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_account") +public class UserAccount { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Column(length = 256) + private String email; + + @Column(name = "avatar_url", length = 512) + private String avatarUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private UserStatus status = UserStatus.ACTIVE; + + @Column(name = "merged_to_user_id") + private Long mergedToUserId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected UserAccount() {} + + public UserAccount(String displayName, String email, String avatarUrl) { + this.displayName = displayName; + this.email = email; + this.avatarUrl = avatarUrl; + this.status = UserStatus.ACTIVE; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Getters and setters + public Long getId() { return id; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getAvatarUrl() { return avatarUrl; } + public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } + public UserStatus getStatus() { return status; } + public void setStatus(UserStatus status) { this.status = status; } + public Long getMergedToUserId() { return mergedToUserId; } + public void setMergedToUserId(Long mergedToUserId) { this.mergedToUserId = mergedToUserId; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + + public boolean isActive() { return this.status == UserStatus.ACTIVE; } +} +``` + +- [ ] **Step 3: 创建 UserAccountRepository 接口** + +```java +// server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccountRepository.java +package com.skillhub.domain.user; + +import java.util.Optional; + +public interface UserAccountRepository { + Optional findById(Long id); + UserAccount save(UserAccount user); +} +``` + +- [ ] **Step 4: 创建命名空间相关实体** + +```java +// NamespaceStatus.java +package com.skillhub.domain.namespace; + +public enum NamespaceStatus { + ACTIVE, FROZEN, ARCHIVED +} + +// NamespaceRole.java +package com.skillhub.domain.namespace; + +public enum NamespaceRole { + OWNER, ADMIN, MEMBER +} + +// Namespace.java +package com.skillhub.domain.namespace; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "namespace") +public class Namespace { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String slug; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Column(length = 512) + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NamespaceStatus status = NamespaceStatus.ACTIVE; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected Namespace() {} + + public Namespace(String slug, String displayName, Long createdBy) { + this.slug = slug; + this.displayName = displayName; + this.createdBy = createdBy; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public String getSlug() { return slug; } + public String getDisplayName() { return displayName; } + public NamespaceStatus getStatus() { return status; } + public Long getCreatedBy() { return createdBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} + +// NamespaceMember.java +package com.skillhub.domain.namespace; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "namespace_member", + uniqueConstraints = @UniqueConstraint(columnNames = {"namespace_id", "user_id"})) +public class NamespaceMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NamespaceRole role; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected NamespaceMember() {} + + public NamespaceMember(Long namespaceId, Long userId, NamespaceRole role) { + this.namespaceId = namespaceId; + this.userId = userId; + this.role = role; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public Long getUserId() { return userId; } + public NamespaceRole getRole() { return role; } + public void setRole(NamespaceRole role) { this.role = role; } + public LocalDateTime getCreatedAt() { return createdAt; } +} +``` + +- [ ] **Step 5: 创建 Repository 接口** + +```java +// NamespaceRepository.java +package com.skillhub.domain.namespace; + +import java.util.Optional; + +public interface NamespaceRepository { + Optional findById(Long id); + Optional findBySlug(String slug); + Namespace save(Namespace namespace); +} + +// NamespaceMemberRepository.java +package com.skillhub.domain.namespace; + +import java.util.List; +import java.util.Optional; + +public interface NamespaceMemberRepository { + Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); + List findByUserId(Long userId); + NamespaceMember save(NamespaceMember member); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-domain/ +git commit -m "feat(domain): add UserAccount and Namespace entities with repository interfaces + +- UserAccount with status lifecycle (ACTIVE/PENDING/DISABLED/MERGED) +- Namespace, NamespaceMember with role-based membership +- Repository interfaces (implementation in infra module)" +``` + +### Task 9: Infra 层 JPA Repository 实现 + +**Files:** +- Create: `server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/UserAccountJpaRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/NamespaceJpaRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/NamespaceMemberJpaRepository.java` + +- [ ] **Step 1: 创建 UserAccountJpaRepository** + +```java +// server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/UserAccountJpaRepository.java +package com.skillhub.infra.jpa; + +import com.skillhub.domain.user.UserAccount; +import com.skillhub.domain.user.UserAccountRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserAccountJpaRepository + extends JpaRepository, UserAccountRepository { +} +``` + +- [ ] **Step 2: 创建 NamespaceJpaRepository 和 NamespaceMemberJpaRepository** + +```java +// NamespaceJpaRepository.java +package com.skillhub.infra.jpa; + +import com.skillhub.domain.namespace.Namespace; +import com.skillhub.domain.namespace.NamespaceRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NamespaceJpaRepository + extends JpaRepository, NamespaceRepository { + Optional findBySlug(String slug); +} + +// NamespaceMemberJpaRepository.java +package com.skillhub.infra.jpa; + +import com.skillhub.domain.namespace.NamespaceMember; +import com.skillhub.domain.namespace.NamespaceMemberRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface NamespaceMemberJpaRepository + extends JpaRepository, NamespaceMemberRepository { + Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); + List findByUserId(Long userId); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/skillhub-infra/ +git commit -m "feat(infra): add JPA repository implementations for UserAccount and Namespace" +``` + +### Task 10: Auth 模块实体与 Repository + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/entity/IdentityBinding.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/entity/ApiToken.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/entity/Role.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/entity/Permission.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/entity/RolePermission.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/entity/UserRoleBinding.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/repository/*.java` + +- [ ] **Step 1: 创建 IdentityBinding 实体** + +```java +package com.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "identity_binding", + uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"})) +public class IdentityBinding { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "provider_code", nullable = false, length = 64) + private String providerCode; + + @Column(nullable = false, length = 256) + private String subject; + + @Column(name = "login_name", length = 128) + private String loginName; + + @Column(name = "extra_json", columnDefinition = "jsonb") + private String extraJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected IdentityBinding() {} + + public IdentityBinding(Long userId, String providerCode, String subject, String loginName) { + this.userId = userId; + this.providerCode = providerCode; + this.subject = subject; + this.loginName = loginName; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public Long getUserId() { return userId; } + public String getProviderCode() { return providerCode; } + public String getSubject() { return subject; } + public String getLoginName() { return loginName; } + public void setLoginName(String loginName) { this.loginName = loginName; } + public String getExtraJson() { return extraJson; } + public void setExtraJson(String extraJson) { this.extraJson = extraJson; } +} +``` + +- [ ] **Step 2: 创建 ApiToken 实体** + +```java +package com.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_token") +public class ApiToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "subject_type", nullable = false, length = 32) + private String subjectType = "USER"; + + @Column(name = "subject_id", nullable = false) + private Long subjectId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(name = "token_prefix", nullable = false, length = 16) + private String tokenPrefix; + + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + @Column(name = "scope_json", nullable = false, columnDefinition = "jsonb") + private String scopeJson; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ApiToken() {} + + public ApiToken(Long userId, String name, String tokenPrefix, String tokenHash, String scopeJson) { + this.subjectType = "USER"; + this.subjectId = userId; + this.userId = userId; + this.name = name; + this.tokenPrefix = tokenPrefix; + this.tokenHash = tokenHash; + this.scopeJson = scopeJson; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public Long getUserId() { return userId; } + public String getName() { return name; } + public String getTokenPrefix() { return tokenPrefix; } + public String getTokenHash() { return tokenHash; } + public String getScopeJson() { return scopeJson; } + public LocalDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + public LocalDateTime getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; } + public LocalDateTime getRevokedAt() { return revokedAt; } + public void setRevokedAt(LocalDateTime revokedAt) { this.revokedAt = revokedAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + + public boolean isRevoked() { return revokedAt != null; } + public boolean isExpired() { return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); } + public boolean isValid() { return !isRevoked() && !isExpired(); } +} +``` + +- [ ] **Step 3: 创建 RBAC 实体(Role, Permission, RolePermission, UserRoleBinding)** + +```java +// Role.java +package com.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "role") +public class Role { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + @Column(name = "is_system", nullable = false) + private boolean system; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public String getCode() { return code; } + public String getName() { return name; } + public boolean isSystem() { return system; } +} + +// Permission.java +package com.skillhub.auth.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "permission") +public class Permission { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 128) + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(name = "group_code", length = 64) + private String groupCode; + + public Long getId() { return id; } + public String getCode() { return code; } + public String getName() { return name; } +} + +// RolePermission.java +package com.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.io.Serializable; + +@Entity +@Table(name = "role_permission") +@IdClass(RolePermission.RolePermissionId.class) +public class RolePermission { + @Id + @Column(name = "role_id") + private Long roleId; + + @Id + @Column(name = "permission_id") + private Long permissionId; + + public Long getRoleId() { return roleId; } + public Long getPermissionId() { return permissionId; } + + public static class RolePermissionId implements Serializable { + private Long roleId; + private Long permissionId; + // equals and hashCode omitted for brevity — implement in code + } +} + +// UserRoleBinding.java +package com.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_role_binding", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id"})) +public class UserRoleBinding { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "role_id", nullable = false) + private Long roleId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected UserRoleBinding() {} + + public UserRoleBinding(Long userId, Long roleId) { + this.userId = userId; + this.roleId = roleId; + } + + @PrePersist + void prePersist() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public Long getUserId() { return userId; } + public Long getRoleId() { return roleId; } +} +``` + +- [ ] **Step 4: 创建 Auth Repository 接口** + +```java +// IdentityBindingRepository.java +package com.skillhub.auth.repository; + +import com.skillhub.auth.entity.IdentityBinding; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface IdentityBindingRepository extends JpaRepository { + Optional findByProviderCodeAndSubject(String providerCode, String subject); +} + +// ApiTokenRepository.java +package com.skillhub.auth.repository; + +import com.skillhub.auth.entity.ApiToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ApiTokenRepository extends JpaRepository { + Optional findByTokenHash(String tokenHash); + List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(Long userId); +} + +// RoleRepository.java +package com.skillhub.auth.repository; + +import com.skillhub.auth.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface RoleRepository extends JpaRepository { + Optional findByCode(String code); +} + +// UserRoleBindingRepository.java +package com.skillhub.auth.repository; + +import com.skillhub.auth.entity.UserRoleBinding; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface UserRoleBindingRepository extends JpaRepository { + List findByUserId(Long userId); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/ +git commit -m "feat(auth): add auth entities and JPA repositories + +- IdentityBinding, ApiToken, Role, Permission, RolePermission, UserRoleBinding +- JPA repositories for all auth entities" +``` + +### Task 10: OAuth2 Claims 提取与准入策略 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuthClaims.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuthClaimsExtractor.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/GitHubClaimsExtractor.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/policy/AccessDecision.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/policy/AccessPolicy.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/policy/OpenAccessPolicy.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/policy/EmailDomainAccessPolicy.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/policy/AccessPolicyFactory.java` +- Test: `server/skillhub-auth/src/test/java/com/skillhub/auth/policy/AccessPolicyTest.java` + +- [ ] **Step 1: 创建 OAuthClaims record** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuthClaims.java +package com.skillhub.auth.oauth; + +import java.util.Map; + +public record OAuthClaims( + String provider, + String subject, + String email, + boolean emailVerified, + String providerLogin, + Map extra +) {} +``` + +- [ ] **Step 2: 创建 OAuthClaimsExtractor 接口和 GitHub 实现** + +```java +// OAuthClaimsExtractor.java +package com.skillhub.auth.oauth; + +import org.springframework.security.oauth2.core.user.OAuth2User; + +public interface OAuthClaimsExtractor { + String getProvider(); + OAuthClaims extract(OAuth2User oAuth2User); +} +``` + +```java +// GitHubClaimsExtractor.java +package com.skillhub.auth.oauth; + +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import java.util.Map; + +@Component +public class GitHubClaimsExtractor implements OAuthClaimsExtractor { + + @Override + public String getProvider() { return "github"; } + + @Override + public OAuthClaims extract(OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + return new OAuthClaims( + "github", + String.valueOf(attrs.get("id")), + (String) attrs.get("email"), + attrs.get("email") != null, + (String) attrs.get("login"), + attrs + ); + } +} +``` + +- [ ] **Step 3: 创建 AccessDecision 和 AccessPolicy** + +```java +// AccessDecision.java +package com.skillhub.auth.policy; + +public enum AccessDecision { + ALLOW, + DENY, + PENDING_APPROVAL +} +``` + +```java +// AccessPolicy.java +package com.skillhub.auth.policy; + +import com.skillhub.auth.oauth.OAuthClaims; + +public interface AccessPolicy { + AccessDecision evaluate(OAuthClaims claims); +} +``` + +- [ ] **Step 4: 创建 OpenAccessPolicy 和 EmailDomainAccessPolicy** + +```java +// OpenAccessPolicy.java +package com.skillhub.auth.policy; + +import com.skillhub.auth.oauth.OAuthClaims; + +public class OpenAccessPolicy implements AccessPolicy { + @Override + public AccessDecision evaluate(OAuthClaims claims) { + return AccessDecision.ALLOW; + } +} +``` + +```java +// EmailDomainAccessPolicy.java +package com.skillhub.auth.policy; + +import com.skillhub.auth.oauth.OAuthClaims; +import java.util.Set; + +public class EmailDomainAccessPolicy implements AccessPolicy { + private final Set allowedDomains; + + public EmailDomainAccessPolicy(Set allowedDomains) { + this.allowedDomains = allowedDomains; + } + + @Override + public AccessDecision evaluate(OAuthClaims claims) { + if (claims.email() == null) return AccessDecision.DENY; + String domain = claims.email().substring(claims.email().indexOf('@') + 1); + return allowedDomains.contains(domain.toLowerCase()) + ? AccessDecision.ALLOW : AccessDecision.DENY; + } +} +``` + +```java +// ProviderAllowlistAccessPolicy.java +package com.skillhub.auth.policy; + +import com.skillhub.auth.oauth.OAuthClaims; +import java.util.Set; + +public class ProviderAllowlistAccessPolicy implements AccessPolicy { + private final Set allowedProviders; + + public ProviderAllowlistAccessPolicy(Set allowedProviders) { + this.allowedProviders = allowedProviders; + } + + @Override + public AccessDecision evaluate(OAuthClaims claims) { + return allowedProviders.contains(claims.provider()) + ? AccessDecision.ALLOW : AccessDecision.DENY; + } +} +``` + +```java +// SubjectWhitelistAccessPolicy.java +package com.skillhub.auth.policy; + +import com.skillhub.auth.oauth.OAuthClaims; +import java.util.Set; + +public class SubjectWhitelistAccessPolicy implements AccessPolicy { + private final Set whitelistedSubjects; // "provider:subject" 格式 + + public SubjectWhitelistAccessPolicy(Set whitelistedSubjects) { + this.whitelistedSubjects = whitelistedSubjects; + } + + @Override + public AccessDecision evaluate(OAuthClaims claims) { + String key = claims.provider() + ":" + claims.subject(); + return whitelistedSubjects.contains(key) + ? AccessDecision.ALLOW : AccessDecision.DENY; + } +} +``` + +- [ ] **Step 5: 创建 AccessPolicyFactory** + +```java +// AccessPolicyFactory.java +package com.skillhub.auth.policy; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.util.List; +import java.util.Set; + +@Configuration +@ConfigurationProperties(prefix = "skillhub.access-policy") +public class AccessPolicyFactory { + private String mode = "OPEN"; + private List allowedEmailDomains = List.of(); + private List allowedProviders = List.of(); + private List whitelistedSubjects = List.of(); + + @Bean + public AccessPolicy accessPolicy() { + return switch (mode.toUpperCase()) { + case "EMAIL_DOMAIN" -> new EmailDomainAccessPolicy(Set.copyOf(allowedEmailDomains)); + case "PROVIDER_ALLOWLIST" -> new ProviderAllowlistAccessPolicy(Set.copyOf(allowedProviders)); + case "SUBJECT_WHITELIST" -> new SubjectWhitelistAccessPolicy(Set.copyOf(whitelistedSubjects)); + default -> new OpenAccessPolicy(); + }; + } + + public void setMode(String mode) { this.mode = mode; } + public void setAllowedEmailDomains(List d) { this.allowedEmailDomains = d; } + public void setAllowedProviders(List p) { this.allowedProviders = p; } + public void setWhitelistedSubjects(List s) { this.whitelistedSubjects = s; } +} +``` + +- [ ] **Step 6: 编写 AccessPolicy 单元测试** + +```java +// server/skillhub-auth/src/test/java/com/skillhub/auth/policy/AccessPolicyTest.java +package com.skillhub.auth.policy; + +import com.skillhub.auth.oauth.OAuthClaims; +import org.junit.jupiter.api.Test; +import java.util.Map; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; + +class AccessPolicyTest { + + @Test + void openPolicy_alwaysAllows() { + var policy = new OpenAccessPolicy(); + var claims = new OAuthClaims("github", "123", "user@any.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void emailDomainPolicy_allowsMatchingDomain() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", "user@company.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void emailDomainPolicy_deniesNonMatchingDomain() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", "user@other.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + + @Test + void emailDomainPolicy_deniesNullEmail() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", null, false, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + + @Test + void providerAllowlistPolicy_allowsMatchingProvider() { + var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); + var claims = new OAuthClaims("github", "123", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void providerAllowlistPolicy_deniesNonMatchingProvider() { + var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); + var claims = new OAuthClaims("google", "123", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + + @Test + void subjectWhitelistPolicy_allowsMatchingSubject() { + var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345")); + var claims = new OAuthClaims("github", "12345", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void subjectWhitelistPolicy_deniesNonMatchingSubject() { + var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345")); + var claims = new OAuthClaims("github", "99999", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } +} +``` + +- [ ] **Step 7: 运行测试验证** + +Run: `cd server && ./mvnw test -pl skillhub-auth -Dtest=AccessPolicyTest -am` + +Expected: 8 tests PASS + +- [ ] **Step 8: Commit** + +```bash +git add server/skillhub-auth/ +git commit -m "feat(auth): add OAuth claims extraction and access policy + +- OAuthClaims record, OAuthClaimsExtractor SPI, GitHubClaimsExtractor +- AccessPolicy SPI with Open and EmailDomain implementations +- AccessPolicyFactory with config-driven strategy selection +- Unit tests for access policies" +``` + +### Task 11: IdentityBindingService + CustomOAuth2UserService + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/identity/IdentityBindingService.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/CustomOAuth2UserService.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/PlatformPrincipal.java` + +- [ ] **Step 1: 创建 PlatformPrincipal** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/PlatformPrincipal.java +package com.skillhub.auth.rbac; + +import java.io.Serializable; +import java.util.Set; + +public record PlatformPrincipal( + Long userId, + String displayName, + String email, + String avatarUrl, + String oauthProvider, + Set platformRoles +) implements Serializable {} +``` + +- [ ] **Step 2: 创建 IdentityBindingService** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/identity/IdentityBindingService.java +package com.skillhub.auth.identity; + +import com.skillhub.auth.entity.IdentityBinding; +import com.skillhub.auth.entity.UserRoleBinding; +import com.skillhub.auth.oauth.OAuthClaims; +import com.skillhub.auth.rbac.PlatformPrincipal; +import com.skillhub.auth.repository.IdentityBindingRepository; +import com.skillhub.auth.repository.UserRoleBindingRepository; +import com.skillhub.domain.user.UserAccount; +import com.skillhub.domain.user.UserAccountRepository; +import com.skillhub.domain.user.UserStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class IdentityBindingService { + + private final IdentityBindingRepository bindingRepo; + private final UserAccountRepository userRepo; + private final UserRoleBindingRepository roleBindingRepo; + + public IdentityBindingService(IdentityBindingRepository bindingRepo, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo) { + this.bindingRepo = bindingRepo; + this.userRepo = userRepo; + this.roleBindingRepo = roleBindingRepo; + } + + @Transactional + public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStatus) { + IdentityBinding binding = bindingRepo + .findByProviderCodeAndSubject(claims.provider(), claims.subject()) + .orElse(null); + + UserAccount user; + if (binding != null) { + user = userRepo.findById(binding.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for binding")); + // 同步最新信息 + user.setDisplayName(claims.providerLogin()); + if (claims.email() != null) user.setEmail(claims.email()); + if (claims.extra().get("avatar_url") != null) { + user.setAvatarUrl((String) claims.extra().get("avatar_url")); + } + user = userRepo.save(user); + } else { + user = new UserAccount( + claims.providerLogin(), + claims.email(), + (String) claims.extra().get("avatar_url") + ); + user.setStatus(initialStatus); + user = userRepo.save(user); + + binding = new IdentityBinding(); + binding.setUserId(user.getId()); + binding.setProviderCode(claims.provider()); + binding.setSubject(claims.subject()); + binding.setLoginName(claims.providerLogin()); + bindingRepo.save(binding); + } + + Set roles = roleBindingRepo.findByUserId(user.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + + return new PlatformPrincipal( + user.getId(), user.getDisplayName(), user.getEmail(), + user.getAvatarUrl(), claims.provider(), roles + ); + } +} +``` + +- [ ] **Step 3: 创建 CustomOAuth2UserService** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/CustomOAuth2UserService.java +package com.skillhub.auth.oauth; + +import com.skillhub.auth.identity.IdentityBindingService; +import com.skillhub.auth.policy.AccessDecision; +import com.skillhub.auth.policy.AccessPolicy; +import com.skillhub.auth.rbac.PlatformPrincipal; +import com.skillhub.domain.user.UserStatus; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class CustomOAuth2UserService implements OAuth2UserService { + + private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final Map extractors; + private final AccessPolicy accessPolicy; + private final IdentityBindingService identityBindingService; + + public CustomOAuth2UserService(List extractorList, + AccessPolicy accessPolicy, + IdentityBindingService identityBindingService) { + this.extractors = extractorList.stream() + .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); + this.accessPolicy = accessPolicy; + this.identityBindingService = identityBindingService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = delegate.loadUser(request); + String registrationId = request.getClientRegistration().getRegistrationId(); + + OAuthClaimsExtractor extractor = extractors.get(registrationId); + if (extractor == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null)); + } + + OAuthClaims claims = extractor.extract(oAuth2User); + AccessDecision decision = accessPolicy.evaluate(claims); + + UserStatus initialStatus = switch (decision) { + case ALLOW -> UserStatus.ACTIVE; + case PENDING_APPROVAL -> UserStatus.PENDING; + case DENY -> throw new OAuth2AuthenticationException( + new OAuth2Error("access_denied", "Access denied by policy", null)); + }; + + PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, initialStatus); + + // 将 principal 存入 OAuth2User attributes 供后续使用 + var attrs = new java.util.HashMap<>(oAuth2User.getAttributes()); + attrs.put("platformPrincipal", principal); + + return new org.springframework.security.oauth2.core.user.DefaultOAuth2User( + oAuth2User.getAuthorities(), attrs, "login" + ); + } +} +``` + +- [ ] **Step 4: 创建 OAuth2LoginSuccessHandler** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +package com.skillhub.auth.oauth; + +import com.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import java.io.IOException; + +@Component +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + public OAuth2LoginSuccessHandler() { + setDefaultTargetUrl("/?login=success"); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, jakarta.servlet.ServletException { + if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) { + PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal"); + if (principal != null) { + request.getSession().setAttribute("platformPrincipal", principal); + } + } + super.onAuthenticationSuccess(request, response, authentication); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/ server/skillhub-domain/ +git commit -m "feat(auth): add identity binding and OAuth2 user service + +- PlatformPrincipal session record +- IdentityBindingService: bind or create user from OAuth claims +- CustomOAuth2UserService: delegate → extract → policy → bind +- OAuth2LoginSuccessHandler: store principal in session" +``` + +### Task 12: API Token 签发与认证 Filter + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenService.java` +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenAuthenticationFilter.java` +- Test: `server/skillhub-auth/src/test/java/com/skillhub/auth/token/ApiTokenServiceTest.java` + +- [ ] **Step 1: 创建 ApiTokenService** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenService.java +package com.skillhub.auth.token; + +import com.skillhub.auth.entity.ApiToken; +import com.skillhub.auth.rbac.PlatformPrincipal; +import com.skillhub.auth.repository.ApiTokenRepository; +import com.skillhub.auth.repository.UserRoleBindingRepository; +import com.skillhub.domain.user.UserAccount; +import com.skillhub.domain.user.UserAccountRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ApiTokenService { + + private static final String TOKEN_PREFIX = "ask_"; + private static final SecureRandom RANDOM = new SecureRandom(); + private final ApiTokenRepository tokenRepo; + private final UserAccountRepository userRepo; + private final UserRoleBindingRepository roleBindingRepo; + + public ApiTokenService(ApiTokenRepository tokenRepo, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo) { + this.tokenRepo = tokenRepo; + this.userRepo = userRepo; + this.roleBindingRepo = roleBindingRepo; + } + + /** 创建 Token,返回明文(仅此一次) */ + @Transactional + public String createToken(Long userId, String name, List scopes, + LocalDateTime expiresAt) { + byte[] randomBytes = new byte[32]; + RANDOM.nextBytes(randomBytes); + String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString(randomBytes); + String hash = sha256(rawToken); + + ApiToken token = new ApiToken(); + token.setSubjectType("USER"); + token.setSubjectId(userId); + token.setUserId(userId); + token.setName(name); + token.setTokenPrefix(TOKEN_PREFIX); + token.setTokenHash(hash); + token.setScopeJson(scopes); + token.setExpiresAt(expiresAt); + tokenRepo.save(token); + + return rawToken; + } + + /** 通过明文 Token 认证,返回 PlatformPrincipal */ + public Optional authenticate(String rawToken) { + if (rawToken == null || !rawToken.startsWith(TOKEN_PREFIX)) { + return Optional.empty(); + } + String hash = sha256(rawToken); + return tokenRepo.findByTokenHash(hash) + .filter(t -> t.getRevokedAt() == null) + .filter(t -> t.getExpiresAt() == null || t.getExpiresAt().isAfter(LocalDateTime.now())) + .flatMap(t -> { + t.setLastUsedAt(LocalDateTime.now()); + tokenRepo.save(t); + return userRepo.findById(t.getUserId()); + }) + .filter(UserAccount::isActive) + .map(user -> { + Set roles = roleBindingRepo.findByUserId(user.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + return new PlatformPrincipal( + user.getId(), user.getDisplayName(), user.getEmail(), + user.getAvatarUrl(), "api_token", roles + ); + }); + } + + public List listByUser(Long userId) { + return tokenRepo.findByUserIdAndRevokedAtIsNull(userId); + } + + @Transactional + public void revoke(Long tokenId, Long userId) { + tokenRepo.findById(tokenId) + .filter(t -> t.getUserId().equals(userId)) + .ifPresent(t -> { + t.setRevokedAt(LocalDateTime.now()); + tokenRepo.save(t); + }); + } + + static String sha256(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} +``` + +- [ ] **Step 2: 创建 ApiTokenAuthenticationFilter** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenAuthenticationFilter.java +package com.skillhub.auth.token; + +import com.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.util.List; + +public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { + + private final ApiTokenService apiTokenService; + + public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService) { + this.apiTokenService = apiTokenService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ask_")) { + String token = authHeader.substring("Bearer ".length()); + apiTokenService.authenticate(token).ifPresent(principal -> { + var authorities = principal.platformRoles().stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .toList(); + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + }); + } + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 仅对 CLI 和 Token API 路径生效 + String path = request.getRequestURI(); + return !(path.startsWith("/api/v1/cli/") || path.startsWith("/api/v1/tokens") + || path.startsWith("/api/compat/")); + } +} +``` + +- [ ] **Step 3: 编写 ApiTokenService 单元测试** + +```java +// server/skillhub-auth/src/test/java/com/skillhub/auth/token/ApiTokenServiceTest.java +package com.skillhub.auth.token; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class ApiTokenServiceTest { + + @Test + void sha256_producesConsistentHash() { + String hash1 = ApiTokenService.sha256("ask_test123"); + String hash2 = ApiTokenService.sha256("ask_test123"); + assertThat(hash1).isEqualTo(hash2); + assertThat(hash1).hasSize(64); // SHA-256 hex = 64 chars + } + + @Test + void sha256_differentInputsDifferentHashes() { + String hash1 = ApiTokenService.sha256("ask_token1"); + String hash2 = ApiTokenService.sha256("ask_token2"); + assertThat(hash1).isNotEqualTo(hash2); + } +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cd server && ./mvnw test -pl skillhub-auth -Dtest=ApiTokenServiceTest -am` + +Expected: 2 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/ +git commit -m "feat(auth): add API Token service and authentication filter + +- ApiTokenService: create, authenticate, list, revoke tokens +- ask_ prefix + SHA-256 hash storage +- ApiTokenAuthenticationFilter for Bearer token auth on CLI/compat paths +- Unit tests for SHA-256 hashing" +``` + +### Task 13: RBAC 授权服务 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/RbacService.java` +- Test: `server/skillhub-auth/src/test/java/com/skillhub/auth/rbac/RbacServiceTest.java` + +- [ ] **Step 1: 创建 RbacService** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/RbacService.java +package com.skillhub.auth.rbac; + +import com.skillhub.auth.repository.UserRoleBindingRepository; +import com.skillhub.domain.namespace.NamespaceMember; +import com.skillhub.domain.namespace.NamespaceMemberRepository; +import com.skillhub.domain.namespace.NamespaceRole; +import org.springframework.stereotype.Service; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class RbacService { + + private final UserRoleBindingRepository roleBindingRepo; + private final NamespaceMemberRepository namespaceMemberRepo; + + public RbacService(UserRoleBindingRepository roleBindingRepo, + NamespaceMemberRepository namespaceMemberRepo) { + this.roleBindingRepo = roleBindingRepo; + this.namespaceMemberRepo = namespaceMemberRepo; + } + + /** 检查用户是否拥有指定平台权限 */ + public boolean hasPlatformRole(PlatformPrincipal principal, String roleCode) { + if (principal.platformRoles().contains("SUPER_ADMIN")) return true; + return principal.platformRoles().contains(roleCode); + } + + /** 检查用户在指定命名空间的角色是否 >= 要求的最低角色 */ + public boolean hasNamespaceRole(Long userId, Long namespaceId, NamespaceRole minRole) { + Optional member = namespaceMemberRepo + .findByNamespaceIdAndUserId(namespaceId, userId); + return member.map(m -> m.getRole().ordinal() <= minRole.ordinal()).orElse(false); + } + + /** 获取用户在指定命名空间的角色 */ + public Optional getNamespaceRole(Long userId, Long namespaceId) { + return namespaceMemberRepo.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole); + } + + /** 获取用户所有平台角色码 */ + public Set getPlatformRoleCodes(Long userId) { + return roleBindingRepo.findByUserId(userId).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + } +} +``` + +- [ ] **Step 2: 编写 RbacService 单元测试** + +```java +// server/skillhub-auth/src/test/java/com/skillhub/auth/rbac/RbacServiceTest.java +package com.skillhub.auth.rbac; + +import org.junit.jupiter.api.Test; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; + +class RbacServiceTest { + + @Test + void superAdmin_hasAnyPlatformRole() { + var principal = new PlatformPrincipal(1L, "admin", "a@b.com", null, "github", + Set.of("SUPER_ADMIN")); + // SUPER_ADMIN 短路判定 + assertThat(principal.platformRoles().contains("SUPER_ADMIN")).isTrue(); + } + + @Test + void regularUser_doesNotHaveAdminRole() { + var principal = new PlatformPrincipal(2L, "user", "u@b.com", null, "github", + Set.of()); + assertThat(principal.platformRoles().contains("SKILL_ADMIN")).isFalse(); + } + + @Test + void platformPrincipal_isSerializable() { + var principal = new PlatformPrincipal(1L, "test", "t@t.com", null, "github", + Set.of("AUDITOR")); + // record 自动实现 Serializable + assertThat(principal).isInstanceOf(java.io.Serializable.class); + } +} +``` + +- [ ] **Step 3: 运行测试** + +Run: `cd server && ./mvnw test -pl skillhub-auth -Dtest=RbacServiceTest -am` + +Expected: 3 tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-auth/ +git commit -m "feat(auth): add RBAC authorization service + +- RbacService: platform role check with SUPER_ADMIN short-circuit +- Namespace role check with ordinal comparison +- Unit tests for role checks" +``` + +### Task 14: Spring Security 配置 + CSRF + Session + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/config/SecurityConfig.java` +- Modify: `server/skillhub-app/src/main/resources/application.yml` (添加 OAuth2 和 Session 配置) +- Modify: `server/skillhub-app/src/main/resources/application-local.yml` (添加 OAuth2 占位配置) + +- [ ] **Step 1: 创建 SecurityConfig** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/config/SecurityConfig.java +package com.skillhub.auth.config; + +import com.skillhub.auth.oauth.CustomOAuth2UserService; +import com.skillhub.auth.oauth.OAuth2LoginSuccessHandler; +import com.skillhub.auth.token.ApiTokenAuthenticationFilter; +import com.skillhub.auth.token.ApiTokenService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler successHandler; + private final ApiTokenService apiTokenService; + + public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + OAuth2LoginSuccessHandler successHandler, + ApiTokenService apiTokenService) { + this.customOAuth2UserService = customOAuth2UserService; + this.successHandler = successHandler; + this.apiTokenService = apiTokenService; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // CSRF: Cookie-to-Header 模式,CLI/compat API 豁免 + var csrfHandler = new CsrfTokenRequestAttributeHandler(); + csrfHandler.setCsrfRequestAttributeName(null); + + http + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(csrfHandler) + .ignoringRequestMatchers("/api/v1/cli/**", "/api/compat/**") + ) + .authorizeHttpRequests(auth -> auth + // 公开端点 + .requestMatchers( + "/api/v1/health", + "/api/v1/auth/providers", + "/api/v1/skills/**", + "/api/v1/namespaces/**", + "/actuator/health", + "/v3/api-docs/**", + "/swagger-ui/**", + "/.well-known/**" + ).permitAll() + // Admin API + .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR") + // 其余需认证 + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(successHandler) + ) + .logout(logout -> logout + .logoutUrl("/api/v1/auth/logout") + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .deleteCookies("SESSION") + ) + .addFilterBefore( + new ApiTokenAuthenticationFilter(apiTokenService), + UsernamePasswordAuthenticationFilter.class + ); + + return http.build(); + } +} +``` + +- [ ] **Step 2: 更新 application.yml 添加 OAuth2 和 Session 配置** + +在 `server/skillhub-app/src/main/resources/application.yml` 追加: + +```yaml +# 追加到 application.yml +spring: + session: + store-type: redis + redis: + namespace: skillhub:session + security: + oauth2: + client: + registration: + github: + client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder} + scope: read:user,user:email + provider: + github: + user-info-uri: https://api.github.com/user + +skillhub: + access-policy: + mode: OPEN +``` + +- [ ] **Step 3: 更新 application-local.yml** + +在 `server/skillhub-app/src/main/resources/application-local.yml` 追加: + +```yaml +# 追加到 application-local.yml +spring: + session: + store-type: redis + security: + oauth2: + client: + registration: + github: + client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder} + client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/ +git commit -m "feat(auth): add Spring Security config with OAuth2 + CSRF + Session + +- SecurityConfig: OAuth2 login, CSRF Cookie-to-Header, CLI API exempt +- API Token filter before UsernamePasswordAuthenticationFilter +- Spring Session Redis configuration +- Public endpoints permit all, admin requires roles" +``` + +### Task 15: MockAuthFilter 本地开发 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/skillhub/auth/mock/MockAuthFilter.java` + +- [ ] **Step 1: 创建 MockAuthFilter** + +```java +// server/skillhub-auth/src/main/java/com/skillhub/auth/mock/MockAuthFilter.java +package com.skillhub.auth.mock; + +import com.skillhub.auth.rbac.PlatformPrincipal; +import com.skillhub.auth.repository.UserRoleBindingRepository; +import com.skillhub.domain.user.UserAccount; +import com.skillhub.domain.user.UserAccountRepository; +import com.skillhub.domain.user.UserStatus; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@Profile("local") +@Order(-100) +public class MockAuthFilter extends OncePerRequestFilter { + + private final UserAccountRepository userRepo; + private final UserRoleBindingRepository roleBindingRepo; + + public MockAuthFilter(UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo) { + this.userRepo = userRepo; + this.roleBindingRepo = roleBindingRepo; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String mockUserId = request.getHeader("X-Mock-User-Id"); + if (mockUserId != null && SecurityContextHolder.getContext().getAuthentication() == null) { + Long userId = Long.parseLong(mockUserId); + userRepo.findById(userId) + .filter(UserAccount::isActive) + .ifPresent(user -> { + Set roles = roleBindingRepo.findByUserId(userId).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + var principal = new PlatformPrincipal( + user.getId(), user.getDisplayName(), user.getEmail(), + user.getAvatarUrl(), "mock", roles + ); + var authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .toList(); + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + request.getSession().setAttribute("platformPrincipal", principal); + }); + } + filterChain.doFilter(request, response); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/skillhub-auth/ +git commit -m "feat(auth): add MockAuthFilter for local development + +- Activated only under 'local' profile via @Profile +- Reads X-Mock-User-Id header to simulate authenticated user +- Creates PlatformPrincipal and sets SecurityContext" +``` + +### Task 16: AuthController + Token API + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/skillhub/controller/AuthController.java` +- Create: `server/skillhub-app/src/main/java/com/skillhub/controller/TokenController.java` + +- [ ] **Step 1: 创建 AuthController** + +```java +// server/skillhub-app/src/main/java/com/skillhub/controller/AuthController.java +package com.skillhub.controller; + +import com.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpSession; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final ClientRegistrationRepository clientRegistrationRepository; + + public AuthController(ClientRegistrationRepository clientRegistrationRepository) { + this.clientRegistrationRepository = clientRegistrationRepository; + } + + @GetMapping("/me") + public ResponseEntity> me(HttpSession session) { + PlatformPrincipal principal = (PlatformPrincipal) session.getAttribute("platformPrincipal"); + if (principal == null) { + return ResponseEntity.status(401).build(); + } + return ResponseEntity.ok(Map.of( + "userId", principal.userId(), + "displayName", principal.displayName(), + "email", principal.email() != null ? principal.email() : "", + "avatarUrl", principal.avatarUrl() != null ? principal.avatarUrl() : "", + "oauthProvider", principal.oauthProvider(), + "platformRoles", principal.platformRoles() + )); + } + + @GetMapping("/providers") + public ResponseEntity> providers() { + // 一期只有 GitHub,后续可动态读取 ClientRegistrationRepository + var github = Map.of( + "id", "github", + "name", "GitHub", + "authorizationUrl", "/oauth2/authorization/github" + ); + return ResponseEntity.ok(Map.of("data", List.of(github))); + } +} +``` + +- [ ] **Step 2: 创建 TokenController** + +```java +// server/skillhub-app/src/main/java/com/skillhub/controller/TokenController.java +package com.skillhub.controller; + +import com.skillhub.auth.rbac.PlatformPrincipal; +import com.skillhub.auth.token.ApiTokenService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/tokens") +public class TokenController { + + private final ApiTokenService apiTokenService; + + public TokenController(ApiTokenService apiTokenService) { + this.apiTokenService = apiTokenService; + } + + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestBody Map body) { + String name = (String) body.get("name"); + @SuppressWarnings("unchecked") + List scopes = (List) body.getOrDefault("scopes", + List.of("skill:read", "skill:publish")); + Integer expiryDays = (Integer) body.get("expiryDays"); + LocalDateTime expiresAt = expiryDays != null + ? LocalDateTime.now().plusDays(expiryDays) : null; + + String rawToken = apiTokenService.createToken( + principal.userId(), name, scopes, expiresAt); + + return ResponseEntity.ok(Map.of("token", rawToken)); + } + + @GetMapping + public ResponseEntity list(@AuthenticationPrincipal PlatformPrincipal principal) { + var tokens = apiTokenService.listByUser(principal.userId()); + var result = tokens.stream().map(t -> Map.of( + "id", t.getId(), + "name", t.getName(), + "tokenPrefix", t.getTokenPrefix(), + "createdAt", t.getCreatedAt().toString(), + "expiresAt", t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", + "lastUsedAt", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" + )).toList(); + return ResponseEntity.ok(Map.of("data", result)); + } + + @DeleteMapping("/{id}") + public ResponseEntity revoke( + @AuthenticationPrincipal PlatformPrincipal principal, + @PathVariable Long id) { + apiTokenService.revoke(id, principal.userId()); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/skillhub-app/ +git commit -m "feat: add AuthController and TokenController + +- GET /api/v1/auth/me: return current user info from session +- GET /api/v1/auth/providers: return available OAuth providers +- POST/GET/DELETE /api/v1/tokens: create, list, revoke API tokens" +``` + +### Task 17: 全局异常处理 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/skillhub/exception/ErrorResponse.java` +- Create: `server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java` + +- [ ] **Step 1: 创建 ErrorResponse** + +```java +// server/skillhub-app/src/main/java/com/skillhub/exception/ErrorResponse.java +package com.skillhub.exception; + +import java.time.Instant; + +public record ErrorResponse( + int status, + String error, + String message, + String requestId, + Instant timestamp +) { + public ErrorResponse(int status, String error, String message, String requestId) { + this(status, error, message, requestId, Instant.now()); + } +} +``` + +- [ ] **Step 2: 创建 GlobalExceptionHandler** + +```java +// server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java +package com.skillhub.exception; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException ex, + HttpServletRequest request) { + String requestId = (String) request.getAttribute("requestId"); + return ResponseEntity.badRequest().body( + new ErrorResponse(400, "Bad Request", ex.getMessage(), requestId)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex, + HttpServletRequest request) { + String requestId = (String) request.getAttribute("requestId"); + log.error("Unhandled exception [requestId={}]", requestId, ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + new ErrorResponse(500, "Internal Server Error", + "An unexpected error occurred", requestId)); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/skillhub-app/ +git commit -m "feat: add global exception handler + +- ErrorResponse record with status, error, message, requestId, timestamp +- GlobalExceptionHandler: 400 for IllegalArgumentException, 500 catch-all +- Logs unhandled exceptions with requestId" +``` + +### Task 18: Flyway 种子数据(RBAC 预置角色和权限) + +**Files:** +- Create: `server/skillhub-app/src/main/resources/db/migration/V2__seed_rbac.sql` + +- [ ] **Step 1: 创建种子数据迁移脚本** + +```sql +-- server/skillhub-app/src/main/resources/db/migration/V2__seed_rbac.sql + +-- 预置平台角色 +INSERT INTO role (code, name, description, is_system) VALUES +('SUPER_ADMIN', '平台超管', '拥有所有权限', TRUE), +('SKILL_ADMIN', '技能治理', '全局空间审核、提升审核、隐藏/撤回', TRUE), +('USER_ADMIN', '用户治理', '准入审批、封禁/解封、角色分配', TRUE), +('AUDITOR', '审计员', '查看审计日志', TRUE); + +-- 预置权限 +INSERT INTO permission (code, name, group_code) VALUES +('review:approve', '审核通过', 'review'), +('review:reject', '审核拒绝', 'review'), +('skill:manage', '技能管理', 'skill'), +('skill:publish', '技能发布', 'skill'), +('skill:delete', '技能删除', 'skill'), +('promotion:approve', '提升审核', 'promotion'), +('user:manage', '用户管理', 'user'), +('user:approve', '用户审批', 'user'), +('audit:read', '审计查看', 'audit'); + +-- 角色-权限绑定 +-- SKILL_ADMIN +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p +WHERE r.code = 'SKILL_ADMIN' AND p.code IN ('review:approve', 'review:reject', 'skill:manage', 'promotion:approve'); + +-- USER_ADMIN +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p +WHERE r.code = 'USER_ADMIN' AND p.code IN ('user:manage', 'user:approve'); + +-- AUDITOR +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p +WHERE r.code = 'AUDITOR' AND p.code = 'audit:read'; + +-- 预置 @global 命名空间 +INSERT INTO namespace (slug, display_name, description, visibility, status) +VALUES ('global', 'Global', '平台级公共空间', 'PUBLIC', 'ACTIVE'); + +-- 预置种子用户(本地开发用,SUPER_ADMIN) +INSERT INTO user_account (display_name, email, status) +VALUES ('Admin', 'admin@skillhub.dev', 'ACTIVE'); + +INSERT INTO user_role_binding (user_id, role_id) +SELECT u.id, r.id FROM user_account u, role r +WHERE u.email = 'admin@skillhub.dev' AND r.code = 'SUPER_ADMIN'; +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/skillhub-app/src/main/resources/db/migration/V2__seed_rbac.sql +git commit -m "feat: add RBAC seed data migration + +- Preset 4 platform roles: SUPER_ADMIN, SKILL_ADMIN, USER_ADMIN, AUDITOR +- Preset 9 permissions with role-permission bindings +- Create @global namespace +- Create seed admin user for local development" +``` + +### Chunk 2 验收检查 + +运行以下命令验证 Chunk 2 完成: + +```bash +# 1. 确保依赖服务运行 +make dev + +# 2. 运行所有测试 +cd server && ./mvnw test +# Expected: BUILD SUCCESS, AccessPolicyTest + ApiTokenServiceTest + RbacServiceTest 全部 PASS + +# 3. 启动应用 +./mvnw spring-boot:run -Dspring-boot.run.profiles=local + +# 4. 验证 MockAuth + /api/v1/auth/me +curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/auth/me +# Expected: {"userId":1,"displayName":"Admin","email":"admin@skillhub.dev",...} + +# 5. 验证未登录返回 401 +curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/me +# Expected: 401 + +# 6. 验证 /api/v1/auth/providers +curl http://localhost:8080/api/v1/auth/providers +# Expected: {"data":[{"id":"github","name":"GitHub","authorizationUrl":"/oauth2/authorization/github"}]} + +# 7. 验证 Token 创建 +curl -X POST -H "X-Mock-User-Id: 1" -H "Content-Type: application/json" \ + -d '{"name":"test-token","scopes":["skill:read"]}' \ + http://localhost:8080/api/v1/tokens +# Expected: {"token":"ask_..."} + +# 8. 验证 Token 列表 +curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/tokens +# Expected: {"data":[...]} + +# 9. 验证 RBAC 种子数据 +docker compose exec postgres psql -U skillhub -d skillhub \ + -c "SELECT r.code, array_agg(p.code) FROM role r JOIN role_permission rp ON r.id=rp.role_id JOIN permission p ON p.id=rp.permission_id GROUP BY r.code;" +# Expected: 4 roles with their permissions + +# 10. 停止应用和服务 +make dev-down +``` + +Chunk 2 产出:完整认证链路(OAuth2 + AccessPolicy + IdentityBinding + Session + Token + RBAC + MockAuth + CSRF)。 + +## Chunk 3: 前端骨架 + 登录集成 + +本块建立 React 前端工程,集成 TanStack Router/Query、shadcn/ui、openapi-fetch 类型生成管线,实现 OAuth 登录流程和路由守卫。 + +### 文件结构映射 + +``` +web/ +├── package.json +├── tsconfig.json +├── vite.config.ts +├── tailwind.config.ts +├── postcss.config.js +├── components.json # shadcn/ui 配置 +├── index.html +├── src/ +│ ├── main.tsx +│ ├── app/ +│ │ ├── router.tsx # TanStack Router 配置 +│ │ ├── providers.tsx # QueryClient + Router Provider +│ │ └── layout.tsx # 全局布局(Header + Main) +│ ├── pages/ +│ │ ├── home.tsx # 首页 +│ │ ├── login.tsx # 登录页 +│ │ └── dashboard.tsx # Dashboard(需登录) +│ ├── features/ +│ │ └── auth/ +│ │ ├── use-auth.ts # 登录态 hook +│ │ ├── auth-guard.tsx # 路由守卫 +│ │ └── login-button.tsx # OAuth 登录按钮 +│ ├── shared/ +│ │ └── ui/ # shadcn/ui 组件 +│ └── api/ +│ ├── client.ts # openapi-fetch 客户端 +│ └── generated/ # openapi-typescript 生成的类型 +│ └── schema.d.ts +├── Dockerfile +└── nginx.conf +``` + +### Task 19: 初始化前端工程 + +**Files:** +- Create: `web/package.json` +- Create: `web/tsconfig.json` +- Create: `web/vite.config.ts` +- Create: `web/index.html` +- Create: `web/src/main.tsx` + +- [ ] **Step 1: 初始化 Vite + React + TypeScript 项目** + +```bash +cd web # 如果 web/ 不存在则先 mkdir web && cd web +pnpm create vite . --template react-ts +``` + +或手动创建 `package.json`: + +```json +{ + "name": "skillhub-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-query": "^5.64.0", + "openapi-fetch": "^0.13.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.7.0", + "vite": "^6.1.0", + "tailwindcss": "^3.4.0", + "postcss": "^8.4.0", + "autoprefixer": "^10.4.0", + "openapi-typescript": "^7.6.0" + } +} +``` + +- [ ] **Step 2: 安装依赖** + +Run: `cd web && pnpm install` + +Expected: 依赖安装成功 + +- [ ] **Step 3: 创建 tsconfig.json** + +```json +// web/tsconfig.json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} +``` + +- [ ] **Step 4: 创建 vite.config.ts** + +```typescript +// web/vite.config.ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/login': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +}) +``` + +- [ ] **Step 4: 创建 index.html 和 main.tsx** + +```html + + + + + + + SkillHub + + +
+ + + +``` + +```tsx +// web/src/main.tsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './app/providers' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) +``` + +- [ ] **Step 5: Commit** + +```bash +git add web/ +git commit -m "feat(web): initialize Vite + React + TypeScript frontend + +- package.json with React 19, TanStack Router/Query, openapi-fetch +- Vite config with API proxy to backend +- index.html and main.tsx entry point" +``` + +### Task 20: Tailwind CSS + shadcn/ui 配置 + +**Files:** +- Create: `web/tailwind.config.ts` +- Create: `web/postcss.config.js` +- Create: `web/src/index.css` +- Create: `web/components.json` + +- [ ] **Step 1: 配置 Tailwind CSS** + +```typescript +// web/tailwind.config.ts +import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['class'], + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} +export default config +``` + +```javascript +// web/postcss.config.js +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} +``` + +```css +/* web/src/index.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +- [ ] **Step 2: 初始化 shadcn/ui** + +Run: `cd web && pnpm dlx shadcn@latest init` + +选择默认配置,或手动创建 `components.json`: + +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/shared/ui", + "utils": "@/shared/ui/lib/utils" + } +} +``` + +- [ ] **Step 3: 添加 Button 组件(验证 shadcn/ui 工作)** + +Run: `cd web && pnpm dlx shadcn@latest add button` + +Expected: `src/shared/ui/button.tsx` 生成成功 + +- [ ] **Step 4: Commit** + +```bash +git add web/ +git commit -m "feat(web): add Tailwind CSS and shadcn/ui configuration + +- Tailwind config with dark mode support +- PostCSS config +- shadcn/ui initialized with Button component" +``` + +### Task 21: TanStack Router 路由骨架 + +**Files:** +- Create: `web/src/app/router.tsx` +- Create: `web/src/app/providers.tsx` +- Create: `web/src/app/layout.tsx` +- Create: `web/src/pages/home.tsx` +- Create: `web/src/pages/login.tsx` +- Create: `web/src/pages/dashboard.tsx` + +- [ ] **Step 1: 创建路由配置** + +```tsx +// web/src/app/router.tsx +import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router' +import { Layout } from './layout' +import { HomePage } from '../pages/home' +import { LoginPage } from '../pages/login' +import { DashboardPage } from '../pages/dashboard' + +const rootRoute = createRootRoute({ + component: Layout, +}) + +const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomePage, +}) + +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}) + +const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: DashboardPage, +}) + +const routeTree = rootRoute.addChildren([homeRoute, loginRoute, dashboardRoute]) + +export const router = createRouter({ routeTree }) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} +``` + +- [ ] **Step 2: 创建 Providers** + +```tsx +// web/src/app/providers.tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from '@tanstack/react-router' +import { router } from './router' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + retry: 1, + }, + }, +}) + +export function App() { + return ( + + + + ) +} +``` + +- [ ] **Step 3: 创建 Layout** + +```tsx +// web/src/app/layout.tsx +import { Outlet, Link } from '@tanstack/react-router' +import { useAuth } from '../features/auth/use-auth' + +export function Layout() { + const { user, isLoading } = useAuth() + + return ( +
+
+
+ SkillHub + +
+
+
+ +
+
+ ) +} +``` + +- [ ] **Step 4: 创建页面组件** + +```tsx +// web/src/pages/home.tsx +export function HomePage() { + return ( +
+

SkillHub

+

技能注册中心

+
+ ) +} +``` + +```tsx +// web/src/pages/login.tsx +import { LoginButton } from '../features/auth/login-button' + +export function LoginPage() { + return ( +
+
+

登录 SkillHub

+ +
+
+ ) +} +``` + +```tsx +// web/src/pages/dashboard.tsx +import { useAuth } from '../features/auth/use-auth' +import { AuthGuard } from '../features/auth/auth-guard' + +export function DashboardPage() { + const { user } = useAuth() + + return ( + +
+

Dashboard

+

欢迎, {user?.displayName}

+
+
+ ) +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add web/src/ +git commit -m "feat(web): add TanStack Router with page skeleton + +- Root layout with header navigation +- Home, Login, Dashboard pages +- Router config with type-safe routes" +``` + +### Task 22: Auth Hook + 登录按钮 + 路由守卫 + +**Files:** +- Create: `web/src/features/auth/use-auth.ts` +- Create: `web/src/features/auth/login-button.tsx` +- Create: `web/src/features/auth/auth-guard.tsx` + +- [ ] **Step 1: 创建 useAuth hook** + +```tsx +// web/src/features/auth/use-auth.ts +import { useQuery } from '@tanstack/react-query' + +interface User { + userId: number + displayName: string + email: string + avatarUrl: string + oauthProvider: string + platformRoles: string[] +} + +export function useAuth() { + const { data: user, isLoading, error } = useQuery({ + queryKey: ['auth', 'me'], + queryFn: async () => { + const res = await fetch('/api/v1/auth/me') + if (res.status === 401) return null + if (!res.ok) throw new Error('Failed to fetch user') + return res.json() + }, + retry: false, + staleTime: 5 * 60 * 1000, + }) + + return { + user: user ?? null, + isLoading, + isAuthenticated: !!user, + hasRole: (role: string) => user?.platformRoles?.includes(role) ?? false, + } +} +``` + +- [ ] **Step 2: 创建 LoginButton** + +```tsx +// web/src/features/auth/login-button.tsx +import { useQuery } from '@tanstack/react-query' +import { Button } from '../../shared/ui/button' + +interface Provider { + id: string + name: string + authorizationUrl: string +} + +export function LoginButton() { + const { data } = useQuery<{ data: Provider[] }>({ + queryKey: ['auth', 'providers'], + queryFn: async () => { + const res = await fetch('/api/v1/auth/providers') + if (!res.ok) throw new Error('Failed to fetch providers') + return res.json() + }, + }) + + const providers = data?.data ?? [] + + return ( +
+ {providers.map((p) => ( + + ))} +
+ ) +} +``` + +- [ ] **Step 3: 创建 AuthGuard** + +```tsx +// web/src/features/auth/auth-guard.tsx +import { useNavigate } from '@tanstack/react-router' +import { useAuth } from './use-auth' +import { useEffect } from 'react' + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + navigate({ to: '/login' }) + } + }, [isLoading, isAuthenticated, navigate]) + + if (isLoading) { + return
加载中...
+ } + + if (!isAuthenticated) return null + + return <>{children} +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add web/src/features/ +git commit -m "feat(web): add auth hook, login button, and route guard + +- useAuth: fetch /api/v1/auth/me with TanStack Query +- LoginButton: dynamic OAuth provider buttons from /api/v1/auth/providers +- AuthGuard: redirect to /login if not authenticated" +``` + +### Task 23: openapi-fetch 客户端生成管线 + +**Files:** +- Create: `web/src/api/client.ts` + +- [ ] **Step 1: 创建 API 客户端** + +```typescript +// web/src/api/client.ts +import createClient from 'openapi-fetch' + +// 一期先用手动类型,后续通过 openapi-typescript 自动生成 +export const api = createClient({ baseUrl: '/' }) + +// 便捷方法 +export async function fetchJson(url: string, options?: RequestInit): Promise { + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + if (!res.ok) { + throw new Error(`API error: ${res.status}`) + } + return res.json() +} +``` + +- [ ] **Step 2: 验证 generate-api 脚本可用** + +在后端运行时执行: + +Run: `cd web && pnpm run generate-api` + +Expected: 如果后端运行中,生成 `src/api/generated/schema.d.ts`;如果后端未运行,报连接错误(预期行为) + +- [ ] **Step 3: Commit** + +```bash +git add web/src/api/ +git commit -m "feat(web): add openapi-fetch API client + +- createClient wrapper for type-safe API calls +- generate-api script for openapi-typescript code generation" +``` + +### Task 24: 前端 Dockerfile + nginx.conf + +**Files:** +- Create: `web/Dockerfile` +- Create: `web/nginx.conf` + +- [ ] **Step 1: 创建 nginx.conf** + +```nginx +# web/nginx.conf +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip 压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + gzip_min_length 1000; + + # SPA 路由:所有非文件请求回退到 index.html + location / { + try_files $uri $uri/ /index.html; + } + + # API 反向代理 + location /api/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # OAuth2 反向代理 + location /oauth2/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /login/oauth2/ { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Well-known + location /.well-known/ { + proxy_pass http://server:8080; + } + + # 静态资源缓存 + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 健康检查 + location /nginx-health { + return 200 'ok'; + add_header Content-Type text/plain; + } +} +``` + +- [ ] **Step 2: 创建 Dockerfile** + +```dockerfile +# web/Dockerfile +FROM node:22-alpine AS build +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +HEALTHCHECK --interval=10s --timeout=3s \ + CMD wget -qO- http://localhost/nginx-health || exit 1 +``` + +- [ ] **Step 3: Commit** + +```bash +git add web/Dockerfile web/nginx.conf +git commit -m "feat(web): add Dockerfile and nginx config + +- Multi-stage build: pnpm build → nginx:alpine +- Nginx SPA routing with API reverse proxy +- Static asset caching, health check endpoint" +``` + +### Task 25: 更新 Makefile 添加前端命令 + +**Files:** +- Modify: `Makefile` + +- [ ] **Step 1: 追加前端相关 target** + +在 Makefile 末尾追加: + +```makefile +# --- Frontend --- +.PHONY: web-install web-dev web-build generate-api + +web-install: + cd web && pnpm install + +web-dev: + @echo "Run manually: cd web && pnpm dev" + +web-build: + cd web && pnpm build + +generate-api: + cd web && pnpm run generate-api +``` + +- [ ] **Step 2: Commit** + +```bash +git add Makefile +git commit -m "feat: add frontend targets to Makefile + +- web-install, web-build, generate-api targets +- web-dev prints manual run instruction (long-running process)" +``` + +### Task 26: docker-compose.prod.yml 完整部署 + +**Files:** +- Create: `docker-compose.prod.yml` + +- [ ] **Step 1: 创建生产部署 compose 文件** + +```yaml +# docker-compose.prod.yml +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: skillhub + POSTGRES_USER: skillhub + POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skillhub"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + environment: + MINIO_ROOT_USER: ${MINIO_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + server: + build: + context: ./server + dockerfile: Dockerfile + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub + SPRING_DATASOURCE_USERNAME: skillhub + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod} + SPRING_DATA_REDIS_HOST: redis + OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID} + OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + web: + build: + context: ./web + dockerfile: Dockerfile + ports: + - "80:80" + depends_on: + server: + condition: service_healthy + +volumes: + postgres_data: + minio_data: +``` + +- [ ] **Step 2: 创建后端 Dockerfile** + +```dockerfile +# server/Dockerfile +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /app +COPY . . +RUN ./mvnw package -DskipTests -B + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/skillhub-app/target/*.jar app.jar +RUN addgroup -S app && adduser -S app -G app +USER app +EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=3s \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] +``` + +- [ ] **Step 3: 更新 Makefile 添加 deploy 命令** + +在 Makefile 追加: + +```makefile +# --- Deploy --- +.PHONY: deploy deploy-down + +deploy: + docker compose -f docker-compose.prod.yml up -d --build + +deploy-down: + docker compose -f docker-compose.prod.yml down +``` + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.prod.yml server/Dockerfile Makefile +git commit -m "feat: add production Docker Compose and backend Dockerfile + +- docker-compose.prod.yml: full stack deployment +- server/Dockerfile: Maven multi-stage build → JRE alpine +- Makefile deploy/deploy-down targets" +``` + +### Chunk 3 验收检查 + +运行以下命令验证 Chunk 3 完成: + +```bash +# 1. 确保后端依赖运行 +make dev + +# 2. 安装前端依赖 +make web-install +# Expected: 依赖安装成功 + +# 3. 构建前端 +make web-build +# Expected: dist/ 目录生成 + +# 4. 启动后端 +cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local & + +# 5. 启动前端开发服务器(手动) +cd web && pnpm dev +# Expected: http://localhost:3000 可访问 + +# 6. 验证首页 +# 浏览器打开 http://localhost:3000 +# Expected: 显示 "SkillHub" 标题和 "登录" 链接 + +# 7. 验证登录页 +# 浏览器打开 http://localhost:3000/login +# Expected: 显示 "使用 GitHub 登录" 按钮 + +# 8. 验证 Dashboard 路由守卫 +# 浏览器打开 http://localhost:3000/dashboard +# Expected: 未登录时重定向到 /login + +# 9. 验证 MockAuth + Dashboard +curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/auth/me +# Expected: 返回用户信息(前端通过 proxy 也可访问) + +# 10. 验证 OpenAPI 类型生成 +make generate-api +# Expected: web/src/api/generated/schema.d.ts 生成(需后端运行中) + +# 11. 停止所有服务 +make dev-down +``` + +Chunk 3 产出:可运行的前端应用 + OAuth 登录流程 + 路由守卫 + API 类型生成管线 + 生产部署配置。 + +--- + +## Phase 1 整体验收检查 + +对照 `10-delivery-roadmap.md` Phase 1 验收标准: + +| 验收项 | 验证方式 | 对应 Task | +|--------|---------|-----------| +| 前后端能跑 | `make dev` + 后端启动 + `pnpm dev` 前端启动 | Task 1-7, 19-24 | +| GitHub OAuth 登录可用 | 配置真实 GitHub OAuth App 后完整登录流程 | Task 10-11, 14, 22 | +| AccessPolicy 准入策略生效 | 切换 `skillhub.access-policy.mode` 验证不同策略 | Task 10 | +| `/api/v1/auth/me` 可用 | `curl` 验证已登录/未登录响应 | Task 16 | +| Token 可用 | 创建 Token → Bearer 认证 → `/api/v1/cli/whoami` | Task 12, 16 | +| OpenAPI spec 可访问 | `curl http://localhost:8080/v3/api-docs` | Task 6 | +| CSRF 防护 | Cookie-to-Header 模式,CLI API 豁免 | Task 14 | +| MockAuthFilter | `X-Mock-User-Id` Header 模拟登录 | Task 15 | +| RBAC 基础 | 种子数据 4 角色 + 9 权限 + 角色判定 | Task 13, 18 | +| 全局异常处理 + requestId | 错误响应包含 requestId | Task 5, 17 | +| 前端登录流程 | 登录页 → GitHub 按钮 → 回调 → Dashboard | Task 21, 22 | +| 路由守卫 | 未登录访问 Dashboard 重定向到 /login | Task 22 | +| openapi-fetch 管线 | `make generate-api` 生成类型文件 | Task 23 | +| Docker 完整部署 | `make deploy` 构建并启动全栈 | Task 24, 26 | + +### 完整端到端验证流程 + +```bash +# 1. 启动本地开发环境 +make dev +cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local & +cd web && pnpm dev & + +# 2. MockAuth 验证 +curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/auth/me +# → 200, 返回 Admin 用户信息 + +# 3. Token 全流程 +TOKEN=$(curl -s -X POST -H "X-Mock-User-Id: 1" \ + -H "Content-Type: application/json" \ + -d '{"name":"e2e-test"}' \ + http://localhost:8080/api/v1/tokens | jq -r '.token') +echo $TOKEN +# → ask_... + +curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/auth/me +# → 200, 返回用户信息 + +# 4. 未认证访问 +curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/me +# → 401 + +# 5. OpenAPI +curl -s http://localhost:8080/v3/api-docs | jq '.info.title' +# → "SkillHub API" + +# 6. 前端页面 +# 浏览器 http://localhost:3000 → 首页 +# 浏览器 http://localhost:3000/login → 登录页 +# 浏览器 http://localhost:3000/dashboard → 重定向到 /login + +# 7. 清理 +make dev-down +``` + +--- + +**Plan complete.** 共 26 个 Task,3 个 Chunk,覆盖 Phase 1 全部验收标准。 diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 00000000..aa44ca3f --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.3 + + + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + pom + + + 21 + 21 + 21 + UTF-8 + + + + skillhub-app + skillhub-domain + skillhub-auth + skillhub-search + skillhub-storage + skillhub-infra + + + + + + com.skillhub + skillhub-domain + ${project.version} + + + com.skillhub + skillhub-auth + ${project.version} + + + com.skillhub + skillhub-search + ${project.version} + + + com.skillhub + skillhub-storage + ${project.version} + + + com.skillhub + skillhub-infra + ${project.version} + + + + diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml new file mode 100644 index 00000000..f770ce57 --- /dev/null +++ b/server/skillhub-app/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + + skillhub-app + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + com.skillhub + skillhub-domain + + + com.skillhub + skillhub-auth + + + com.skillhub + skillhub-infra + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml new file mode 100644 index 00000000..851ace69 --- /dev/null +++ b/server/skillhub-auth/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-auth + + + com.skillhub + skillhub-domain + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml new file mode 100644 index 00000000..4629a580 --- /dev/null +++ b/server/skillhub-domain/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-domain + + + jakarta.persistence + jakarta.persistence-api + + + diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml new file mode 100644 index 00000000..b000156a --- /dev/null +++ b/server/skillhub-infra/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-infra + + + com.skillhub + skillhub-domain + + + diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml new file mode 100644 index 00000000..0ded25aa --- /dev/null +++ b/server/skillhub-search/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-search + + + com.skillhub + skillhub-domain + + + diff --git a/server/skillhub-storage/pom.xml b/server/skillhub-storage/pom.xml new file mode 100644 index 00000000..0d3d9762 --- /dev/null +++ b/server/skillhub-storage/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + + com.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-storage +