From 9d7282221c7be64950a0dbce4bfdc9178eb452c6 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 17:46:41 +0800 Subject: [PATCH] docs: update specs, plans, and design documents for Phase 2-4 --- docs/00-product-direction.md | 7 + docs/02-domain-model.md | 49 +- docs/03-authentication-design.md | 19 +- docs/04-search-architecture.md | 4 +- docs/06-api-design.md | 93 +- docs/10-delivery-roadmap.md | 4 + .../2026-03-11-phase1-foundation-auth.md | 62 +- .../2026-03-12-phase2-namespace-skill-core.md | 90 +- .../2026-03-12-phase3-review-cli-social.md | 215 +- .../plans/2026-03-12-phase4-ops-polish.md | 3204 +++++++++++++++++ ...3-12-phase2-namespace-skill-core-design.md | 23 +- ...6-03-12-phase3-review-cli-social-design.md | 39 +- .../2026-03-12-phase4-ops-polish-design.md | 15 +- 13 files changed, 3582 insertions(+), 242 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-12-phase4-ops-polish.md diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index 2c2e26dd..ce6d4f44 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -14,6 +14,13 @@ 同时,一期必须提供 ClawHub CLI 协议兼容层:服务端需要暴露一组与 ClawHub CLI 兼容的 registry API,使现有 ClawHub CLI 在不修改或仅最小配置修改的前提下可完成 registry 侧查询、解析、下载、发布、校验等核心操作。 +## 1.2 身份主键约束(已冻结) + +- 用户身份主键全链路统一使用 `string`,不得使用 `int` / `long` / `bigint` 作为平台用户标识的正式契约类型。 +- 该约束覆盖认证主体、API 入参/出参、权限判定、审计、资源 owner、creator、updater、reviewer、actor、submittedBy 等全部用户关联字段。 +- 原因:平台需要兼容外部 SSO / OAuth / OIDC / SCIM 等身份源,外部 UID 通常是稳定字符串,不应先压缩为本地自增整数再作为系统主契约继续传播。 +- 旧版草案中任何“整型用户标识”写法都已失效,当前唯一有效约束是“平台用户标识全链路使用字符串主键”。 + ### 1.1 技能坐标体系(已冻结) skillhub 内部使用 namespace 坐标模型:`@{namespace_slug}/{skill_slug}`。 diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 7e0aa16f..dddf9414 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -1,5 +1,12 @@ # skillhub 领域模型与数据模型 +## 0. 用户标识约束 + +- 用户身份主键全链路统一为 `string`。 +- 本约束覆盖 `user_id`、`owner_id`、`created_by`、`updated_by`、`published_by`、`reviewed_by`、`actor_user_id` 及所有等价语义字段。 +- 历史文档里写成 `bigint` / `BIGINT` 的用户关联字段均应按字符串重新解释;这些旧类型描述不再作为实现依据。 +- 若未来数据库为了索引或存储效率引入内部 surrogate key,也只能作为内部实现细节,不能替代字符串 `userId` 成为认证、授权、审计和 API 契约的主键。 + ## 3.1 核心实体 ### namespace @@ -13,7 +20,7 @@ | description | text | 描述 | | avatar_url | varchar(512) | 头像 | | status | enum | `ACTIVE` / `FROZEN` / `ARCHIVED` | -| created_by | bigint | 创建人 | +| created_by | varchar(128) | 创建人 | | created_at | datetime | | | updated_at | datetime | | @@ -35,7 +42,7 @@ |------|------|------| | id | bigint | | | namespace_id | bigint | | -| user_id | bigint | | +| user_id | varchar(128) | | | role | enum | `OWNER` / `ADMIN` / `MEMBER` | | created_at | datetime | | | updated_at | datetime | | @@ -54,7 +61,7 @@ | slug | varchar(128) | URL 友好标识 | | display_name | varchar(256) | | | summary | varchar(512) | | -| owner_id | bigint | 主要维护人(可转让) | +| owner_id | varchar(128) | 主要维护人(可转让) | | source_skill_id | bigint | 派生来源(团队技能提升到全局时记录原 skill ID),nullable | | visibility | enum | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` | | status | enum | `ACTIVE` / `HIDDEN` / `ARCHIVED` | @@ -63,9 +70,9 @@ | star_count | int | | | rating_avg | decimal(3,2) | 平均评分 | | rating_count | int | 评分人数 | -| created_by | bigint | | +| created_by | varchar(128) | | | created_at | datetime | | -| updated_by | bigint | | +| updated_by | varchar(128) | | | updated_at | datetime | | - 唯一约束:`(namespace_id, slug)` @@ -91,7 +98,7 @@ | parsed_metadata_json | json | SKILL.md frontmatter 解析结果 | | status | enum | `DRAFT` / `PENDING_REVIEW` / `PUBLISHED` / `REJECTED` / `YANKED` | | reject_reason | varchar(512) | 拒绝原因 | -| published_by | bigint | | +| published_by | varchar(128) | | | published_at | datetime | | | created_at | datetime | | @@ -132,9 +139,9 @@ | skill_id | bigint | | | tag_name | varchar(64) | | | target_version_id | bigint | | -| created_by | bigint | | +| created_by | varchar(128) | | | created_at | datetime | | -| updated_by | bigint | | +| updated_by | varchar(128) | | | updated_at | datetime | | - `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`,不允许 API 手动移动 @@ -151,8 +158,8 @@ | namespace_id | bigint | 所属空间(决定谁能审核) | | status | enum | `PENDING` / `APPROVED` / `REJECTED` | | version | int | 乐观锁版本号,默认 1 | -| submitted_by | bigint | 提交人 | -| reviewed_by | bigint | 审核人 | +| submitted_by | varchar(128) | 提交人 | +| reviewed_by | varchar(128) | 审核人 | | review_comment | text | 审核意见 | | submitted_at | datetime | | | reviewed_at | datetime | | @@ -173,8 +180,8 @@ | target_skill_id | bigint | 审批通过后生成的全局 skill ID,nullable | | status | enum | `PENDING` / `APPROVED` / `REJECTED` | | version | int | 乐观锁版本号,默认 1 | -| submitted_by | bigint | 提交人 | -| reviewed_by | bigint | 审核人 | +| submitted_by | varchar(128) | 提交人 | +| reviewed_by | varchar(128) | 审核人 | | review_comment | text | 审核意见 | | submitted_at | datetime | | | reviewed_at | datetime | | @@ -191,7 +198,7 @@ |------|------|------| | id | bigint | | | skill_id | bigint | | -| user_id | bigint | | +| user_id | varchar(128) | | | created_at | datetime | | 唯一约束:`(skill_id, user_id)` @@ -202,7 +209,7 @@ |------|------|------| | id | bigint | | | skill_id | bigint | | -| user_id | bigint | | +| user_id | varchar(128) | | | score | tinyint | 1-5 | | created_at | datetime | | | updated_at | datetime | | @@ -218,7 +225,7 @@ | email | varchar(256) | | | avatar_url | varchar(512) | | | status | enum | `ACTIVE` / `PENDING` / `DISABLED` / `MERGED` | -| merged_to_user_id | bigint | 合并目标用户 ID,仅 MERGED 状态有值 | +| merged_to_user_id | varchar(128) | 合并目标用户 ID,仅 MERGED 状态有值 | | created_at | datetime | | | updated_at | datetime | | @@ -234,7 +241,7 @@ | 字段 | 类型 | 说明 | |------|------|------| | id | bigint | | -| user_id | bigint | | +| user_id | varchar(128) | | | provider_code | varchar(64) | 如 `github` | | subject | varchar(256) | OAuth Provider 返回的唯一用户标识 | | login_name | varchar(128) | 如 GitHub login | @@ -251,8 +258,8 @@ |------|------|------| | id | bigint | | | subject_type | varchar(32) | `USER`(一期)/ `SERVICE_ACCOUNT`(预留) | -| subject_id | bigint | 关联主体 ID(一期等同于 user_id) | -| user_id | bigint | 兼容字段,一期与 subject_id 相同 | +| subject_id | varchar(128) | 关联主体 ID(一期等同于 user_id) | +| user_id | varchar(128) | 兼容字段,一期与 subject_id 相同 | | name | varchar(128) | Token 名称(必填),如"CI/CD"、"本地开发" | | token_prefix | varchar(16) | | | token_hash | varchar(64) | | @@ -267,7 +274,7 @@ | 字段 | 类型 | 说明 | |------|------|------| | id | bigint | | -| actor_user_id | bigint | | +| actor_user_id | varchar(128) | | | action | varchar(64) | | | target_type | varchar(64) | | | target_id | bigint | | @@ -326,7 +333,7 @@ | 字段 | 类型 | 说明 | |------|------|------| | id | bigint | | -| user_id | bigint | | +| user_id | varchar(128) | | | role_id | bigint | | | created_at | datetime | | @@ -341,7 +348,7 @@ | id | bigint | | | skill_id | bigint | 唯一,一 skill 一条 | | namespace_id | bigint | 用于空间过滤 | -| owner_id | bigint | 用于 PRIVATE 可见性判定 | +| owner_id | varchar(128) | 用于 PRIVATE 可见性判定 | | title | varchar(256) | | | summary | varchar(512) | | | keywords | varchar(512) | | diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 7e2ee6d6..4c2edd13 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -1,5 +1,12 @@ # skillhub 认证与授权设计 +## 0. 身份标识约束 + +- `PlatformPrincipal.userId` 必须是稳定的字符串标识,而不是 `Long`。 +- 用户身份在系统内的主契约是字符串 `userId`;认证、授权、审计、资源 owner 判定都基于该字符串进行。 +- 外部身份源的 `subject`、企业 SSO UID、工号型字符串等都必须可以原样或经确定性映射后进入系统,禁止先压缩成自增整数再作为正式用户主键在全链路传播。 +- 历史草案里的整型用户主键描述全部废弃,当前认证与授权设计只承认字符串身份主键。 + ## 1. 认证架构 ``` @@ -387,6 +394,8 @@ Session 中存储以下字段: ```json { + "code": 0, + "msg": "获取成功", "data": { "userId": 42, "displayName": "zhangsan", @@ -398,12 +407,20 @@ Session 中存储以下字段: { "slug": "ai-team", "role": "ADMIN" }, { "slug": "global", "role": "MEMBER" } ] - } + }, + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" } ``` 前端权限判定基于 `platformRoles` + `namespaces[].role`,后端通过 `role_permission` 表查询权限码。 +统一约束: +- `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。 +- `msg` 必须走 Spring Boot 标准 `MessageSource` i18n 机制。 +- locale 必须通过请求上下文自动获取,不在 controller 中显式传递。 +- 认证失败返回 `401`,但 JSON 外层结构仍保持一致,例如 `{"code":401,"msg":"需要先登录","data":null,...}`。 + ### 9.2 usePermission() Hook ```typescript diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 44ae581e..5dfe3b90 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -38,7 +38,7 @@ public record SearchVisibilityScope( boolean includeAllPublic, // 是否包含所有 PUBLIC 技能 Set memberNamespaceIds, // 用户是 MEMBER 的 namespace(可见 NAMESPACE_ONLY) Set adminNamespaceIds, // 用户是 ADMIN 的 namespace(可见 PRIVATE) - Long userId // 当前用户 ID(可见自己的 PRIVATE skill),匿名为 null + String userId // 当前用户 ID(可见自己的 PRIVATE skill),匿名为 null ) {} ``` @@ -64,7 +64,7 @@ WHERE (visibility = 'PUBLIC') | id | bigint | | | skill_id | bigint | 唯一,一 skill 一条 | | namespace_id | bigint | 用于空间过滤 | -| owner_id | bigint | 用于 PRIVATE 可见性判定 | +| owner_id | VARCHAR(128) | 用于 PRIVATE 可见性判定 | | title | varchar(256) | | | summary | varchar(512) | | | keywords | varchar(512) | | diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 33680c87..7c2a8ed5 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -1,5 +1,71 @@ # skillhub API 设计 +## 0. 标识类型约束 + +- 所有 API 中出现的用户标识一律为 `string`。 +- 该约束覆盖路径参数、query 参数、请求体字段、响应 DTO 字段,以及统一响应结构中的业务数据内容。 +- 任何旧草案中的整型用户标识写法都已失效,前后端正式契约只允许字符串用户标识。 + +## 1. 响应结构规范 + +除文件下载、文件内容读取这类二进制流接口外,所有 JSON API 必须统一使用以下成功响应结构: + +```json +{ + "code": 0, + "msg": "成功", + "data": {}, + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" +} +``` + +约束如下: + +- `code`:成功时固定为 `0`;失败时固定为 HTTP 状态码,例如 `400`、`401`、`403`、`500`。 +- `msg`:返回给调用方的用户可读提示文案,必须通过 Spring Boot `MessageSource` + i18n 机制生成,禁止在 controller 中硬编码。 +- `msg` 的 locale 必须在响应封装层或全局异常处理层通过 `LocaleContextHolder` 从请求上下文自动获取,禁止在 controller/service 中显式传递 `Locale`。 +- `data` 承载实际业务数据;列表、分页对象、详情对象、操作结果对象都必须放在 `data` 下。 +- 分页响应统一使用 `{ items, total, page, size }`,禁止直接暴露 Spring `Page` 的 `content/pageable/sort/first/last` 等内部结构。 +- `timestamp`:响应创建时间戳,由后端统一自动生成。 +- `requestId`:请求链路 ID,由后端统一注入,便于日志追踪。 +- Controller 层禁止直接返回 `Map`、裸 DTO、裸 `Page`、裸 `List` 作为 JSON 成功响应。 +- 普通 JSON 接口应直接返回统一响应 DTO;仅文件下载、文件预览等需要自定义状态码或 header 的二进制接口保留 `ResponseEntity`。 +- 删除、撤销、移动标签等操作也必须返回统一 JSON 结构;如无实体数据,返回 `data.message` 或 `data=null`,但外层结构不得变化。 +- 错误响应与成功响应使用同一外层结构,不再使用单独的异常 JSON 结构。 +- 异常链路中的 `msg` 也必须通过 Spring Boot 标准 i18n 机制生成;参数校验异常、领域异常、认证鉴权异常都必须进入统一的 `@RestControllerAdvice` 出口。 +- 二进制流接口保持原始 HTTP 语义,不套 `code/data` 包装: + - `/download` + - `/file` + - 其他返回 `application/octet-stream`、`application/zip` 等内容类型的接口 + +成功响应示例: + +```json +{ + "code": 0, + "msg": "发布成功", + "data": { + "skillId": 123, + "version": "1.0.0" + }, + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" +} +``` + +错误响应示例: + +```json +{ + "code": 403, + "msg": "需要命名空间管理员或所有者权限", + "data": null, + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" +} +``` + ## 7.1 Public API(匿名可访问) | 方法 | 路径 | 说明 | @@ -24,6 +90,11 @@ Public API 的可见性规则: - `NAMESPACE_ONLY` 技能:仅该命名空间成员可访问(需登录) - `PRIVATE` 技能:owner 本人 + 该 namespace 的 ADMIN 以上可访问(需登录) +`GET /api/v1/skills/{namespace}/{slug}/versions/{version}` 的 `data` 字段除版本基础信息外,还必须包含: + +- `parsedMetadataJson`:`SKILL.md` frontmatter 的完整 JSON 序列化结果 +- `manifestJson`:版本文件清单摘要 JSON + ## 7.2 Auth API(OAuth2 登录相关) | 方法 | 路径 | 说明 | @@ -38,9 +109,13 @@ Public API 的可见性规则: ```json { + "code": 0, + "msg": "获取成功", "data": [ { "id": "github", "name": "GitHub", "authorizationUrl": "/oauth2/authorization/github" } - ] + ], + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" } ``` @@ -80,6 +155,16 @@ Public API 的可见性规则: | POST | `/api/v1/skills/{namespace}/{slug}/unarchive` | 恢复归档(namespace ADMIN 或 owner) | | DELETE | `/api/v1/skills/{namespace}/{slug}/versions/{version}` | 删除 DRAFT/REJECTED 版本 | +发布成功响应中的 `data` 至少包含以下字段: + +- `skillId` +- `namespace` +- `slug` +- `version` +- `status` +- `fileCount` +- `totalSize` + ## 7.4 Token API(需登录) | 方法 | 路径 | 说明 | @@ -205,6 +290,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: ```json { + "code": 0, + "msg": "获取成功", "data": { "skillId": 456, "namespace": "team-name", @@ -213,7 +300,9 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: "versionId": 123, "fingerprint": "sha256:abc123...", "downloadUrl": "/api/v1/skills/team-name/my-skill/versions/1.2.0/download" - } + }, + "timestamp": "2026-03-12T06:00:00Z", + "requestId": "req-123" } ``` diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index 86dd3797..553f2949 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -87,6 +87,8 @@ - 评分组件 + 收藏按钮(匿名用户点击提示登录)、我的收藏页 - Token 管理页 - 管理后台(用户管理、角色分配、准入审批、封禁/解封) +- 前端 API 层收口:统一迁移到 OpenAPI 生成类型 + `openapi-fetch` 客户端,淘汰业务页面里的手写 `fetch` +- 建立 API 变更后的前端同步机制:后端 OpenAPI 更新后执行 `generate-api`,禁止生成类型与真实返回长期漂移 ### 验收 @@ -112,6 +114,7 @@ - 技能隐藏/撤回操作(管理员可见) - 前端代码分割(TanStack Router lazy routes) - rehype-sanitize XSS 防护 +- OpenAPI SDK 工程化:生成文件纳入 CI 校验,避免新增接口回退到手写调用 ### 部署 & 开源 @@ -147,4 +150,5 @@ - 当前阶段(Phase 2 验证优先):本地通过 `docker-compose.yml` 启动 PostgreSQL、Redis、MinIO,后端与集成测试直接连接真实依赖,优先验证发布、搜索、下载、限流等基础设施相关链路 - 后续阶段(工程化收口):逐步把后端集成测试迁移到 Testcontainers,由测试代码按需拉起 PostgreSQL、Redis、MinIO,减少对手工启动本地依赖的要求,并纳入 CI +- 前端阶段性要求:后端 API 契约稳定后,前端必须同步刷新 OpenAPI 生成类型并校验关键页面;统一响应结构变更不允许只改后端不改前端 - 原则:单元测试可继续使用 mock/in-memory 替身,但 Phase 2/3 的核心验收必须保留一组基于真实中间件的集成测试,避免 Redis Lua、对象存储、Flyway、搜索 SQL 等问题被假实现掩盖 diff --git a/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md b/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md index d3f56c35..84b3914e 100644 --- a/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md +++ b/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md @@ -638,7 +638,7 @@ CREATE TABLE user_account ( email VARCHAR(256), avatar_url VARCHAR(512), status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', - merged_to_user_id BIGINT, + merged_to_user_id VARCHAR(128), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -649,7 +649,7 @@ 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), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), provider_code VARCHAR(64) NOT NULL, subject VARCHAR(256) NOT NULL, login_name VARCHAR(128), @@ -665,8 +665,8 @@ CREATE INDEX idx_identity_binding_user_id ON identity_binding(user_id); 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), + subject_id VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), name VARCHAR(128) NOT NULL, token_prefix VARCHAR(16) NOT NULL, token_hash VARCHAR(64) NOT NULL UNIQUE, @@ -708,7 +708,7 @@ CREATE TABLE role_permission ( -- 用户角色绑定表 CREATE TABLE user_role_binding ( id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) 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) @@ -725,7 +725,7 @@ CREATE TABLE namespace ( description TEXT, avatar_url VARCHAR(512), status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -734,7 +734,7 @@ CREATE TABLE namespace ( 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), + user_id VARCHAR(128) 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, @@ -747,7 +747,7 @@ 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), + actor_user_id VARCHAR(128) REFERENCES user_account(id), action VARCHAR(64) NOT NULL, target_type VARCHAR(64), target_id BIGINT, @@ -1537,7 +1537,7 @@ public class NamespaceMember { private Long namespaceId; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 32) @@ -1548,7 +1548,7 @@ public class NamespaceMember { protected NamespaceMember() {} - public NamespaceMember(Long namespaceId, Long userId, NamespaceRole role) { + public NamespaceMember(Long namespaceId, String userId, NamespaceRole role) { this.namespaceId = namespaceId; this.userId = userId; this.role = role; @@ -1589,8 +1589,8 @@ import java.util.List; import java.util.Optional; public interface NamespaceMemberRepository { - Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); - List findByUserId(Long userId); + Optional findByNamespaceIdAndUserId(Long namespaceId, String userId); + List findByUserId(String userId); NamespaceMember save(NamespaceMember member); } ``` @@ -1663,8 +1663,8 @@ import java.util.Optional; @Repository public interface NamespaceMemberJpaRepository extends JpaRepository, NamespaceMemberRepository { - Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); - List findByUserId(Long userId); + Optional findByNamespaceIdAndUserId(Long namespaceId, String userId); + List findByUserId(String userId); } ``` @@ -1704,7 +1704,7 @@ public class IdentityBinding { private Long id; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Column(name = "provider_code", nullable = false, length = 64) private String providerCode; @@ -1726,7 +1726,7 @@ public class IdentityBinding { protected IdentityBinding() {} - public IdentityBinding(Long userId, String providerCode, String subject, String loginName) { + public IdentityBinding(String userId, String providerCode, String subject, String loginName) { this.userId = userId; this.providerCode = providerCode; this.subject = subject; @@ -1778,7 +1778,7 @@ public class ApiToken { private Long subjectId; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Column(nullable = false, length = 128) private String name; @@ -1806,7 +1806,7 @@ public class ApiToken { protected ApiToken() {} - public ApiToken(Long userId, String name, String tokenPrefix, String tokenHash, String scopeJson) { + public ApiToken(String userId, String name, String tokenPrefix, String tokenHash, String scopeJson) { this.subjectType = "USER"; this.subjectId = userId; this.userId = userId; @@ -1950,7 +1950,7 @@ public class UserRoleBinding { private Long id; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Column(name = "role_id", nullable = false) private Long roleId; @@ -1960,7 +1960,7 @@ public class UserRoleBinding { protected UserRoleBinding() {} - public UserRoleBinding(Long userId, Long roleId) { + public UserRoleBinding(String userId, Long roleId) { this.userId = userId; this.roleId = roleId; } @@ -2002,7 +2002,7 @@ import java.util.Optional; @Repository public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); - List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(Long userId); + List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); } // RoleRepository.java @@ -2028,7 +2028,7 @@ import java.util.List; @Repository public interface UserRoleBindingRepository extends JpaRepository { - List findByUserId(Long userId); + List findByUserId(String userId); } ``` @@ -2370,7 +2370,7 @@ import java.io.Serializable; import java.util.Set; public record PlatformPrincipal( - Long userId, + String userId, String displayName, String email, String avatarUrl, @@ -2634,7 +2634,7 @@ public class ApiTokenService { /** 创建 Token,返回明文(仅此一次) */ @Transactional - public String createToken(Long userId, String name, List scopes, + public String createToken(String userId, String name, List scopes, LocalDateTime expiresAt) { byte[] randomBytes = new byte[32]; RANDOM.nextBytes(randomBytes); @@ -2682,12 +2682,12 @@ public class ApiTokenService { }); } - public List listByUser(Long userId) { + public List listByUser(String userId) { return tokenRepo.findByUserIdAndRevokedAtIsNull(userId); } @Transactional - public void revoke(Long tokenId, Long userId) { + public void revoke(Long tokenId, String userId) { tokenRepo.findById(tokenId) .filter(t -> t.getUserId().equals(userId)) .ifPresent(t -> { @@ -2847,20 +2847,20 @@ public class RbacService { } /** 检查用户在指定命名空间的角色是否 >= 要求的最低角色 */ - public boolean hasNamespaceRole(Long userId, Long namespaceId, NamespaceRole minRole) { + public boolean hasNamespaceRole(String 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) { + public Optional getNamespaceRole(String userId, Long namespaceId) { return namespaceMemberRepo.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole); } /** 获取用户所有平台角色码 */ - public Set getPlatformRoleCodes(Long userId) { + public Set getPlatformRoleCodes(String userId) { return roleBindingRepo.findByUserId(userId).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); @@ -3121,7 +3121,7 @@ public class MockAuthFilter extends OncePerRequestFilter { 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); + String userId = mockUserId; userRepo.findById(userId) .filter(UserAccount::isActive) .ifPresent(user -> { @@ -3967,7 +3967,7 @@ git commit -m "feat(web): add TanStack Router with page skeleton import { useQuery } from '@tanstack/react-query' interface User { - userId: number + userId: string displayName: string email: string avatarUrl: string diff --git a/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md b/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md index 3457d580..f49935be 100644 --- a/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md +++ b/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md @@ -6,6 +6,8 @@ **Architecture:** Maven 多模块后端(6 模块)扩展 + React 前端页面。后端采用领域服务集中式架构,domain 模块包含领域服务和应用服务。对象存储 SPI 双实现(LocalFile + S3),搜索 SPI PostgreSQL Full-Text 实现。发布流程 Phase 2 跳过审核直接到 PUBLISHED。 +**身份主键约束:** 用户身份主键全链路统一使用 `string`。本计划中所有 `userId` / `ownerId` / `createdBy` / `updatedBy` / `reviewedBy` 等用户标识字段均按字符串实现;旧的 `Long` / `BIGINT` 表述仅代表历史残留,不得继续照抄到代码或数据库设计。 + **Tech Stack:** - Backend: Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Data JPA + Flyway + AWS SDK v2 (S3) + SnakeYAML - Frontend: React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui + Tailwind CSS + react-markdown + react-dropzone @@ -192,7 +194,7 @@ CREATE TABLE skill ( slug VARCHAR(128) NOT NULL, display_name VARCHAR(256), summary VARCHAR(512), - owner_id BIGINT NOT NULL REFERENCES user_account(id), + owner_id VARCHAR(128) NOT NULL REFERENCES user_account(id), source_skill_id BIGINT, visibility VARCHAR(32) NOT NULL DEFAULT 'PUBLIC', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', @@ -201,9 +203,9 @@ CREATE TABLE skill ( star_count INT NOT NULL DEFAULT 0, rating_avg DECIMAL(3,2) NOT NULL DEFAULT 0.00, rating_count INT NOT NULL DEFAULT 0, - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_by BIGINT REFERENCES user_account(id), + updated_by VARCHAR(128) REFERENCES user_account(id), updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(namespace_id, slug) ); @@ -222,7 +224,7 @@ CREATE TABLE skill_version ( file_count INT NOT NULL DEFAULT 0, total_size BIGINT NOT NULL DEFAULT 0, published_at TIMESTAMP, - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, version) ); @@ -251,7 +253,7 @@ CREATE TABLE skill_tag ( skill_id BIGINT NOT NULL REFERENCES skill(id), tag_name VARCHAR(64) NOT NULL, version_id BIGINT NOT NULL REFERENCES skill_version(id), - created_by BIGINT REFERENCES user_account(id), + created_by VARCHAR(128) REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, tag_name) @@ -263,7 +265,7 @@ CREATE TABLE skill_search_document ( skill_id BIGINT NOT NULL UNIQUE REFERENCES skill(id), namespace_id BIGINT NOT NULL, namespace_slug VARCHAR(64) NOT NULL, - owner_id BIGINT NOT NULL, + owner_id VARCHAR(128) NOT NULL, title VARCHAR(256), summary VARCHAR(512), keywords VARCHAR(512), @@ -409,7 +411,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; Page findByNamespaceId(Long namespaceId, Pageable pageable); -void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId); +void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); ``` - [ ] **Step 6: 更新 NamespaceJpaRepository 实现** @@ -433,7 +435,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; Page findByNamespaceId(Long namespaceId, Pageable pageable); -void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId); +void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); ``` - [ ] **Step 8: 编译验证** @@ -2156,7 +2158,7 @@ public interface PrePublishValidator { record SkillPackageContext( List entries, SkillMetadata metadata, - Long publisherId, + String publisherId, Long namespaceId ) {} } @@ -2530,7 +2532,7 @@ import java.util.Map; public class VisibilityChecker { - public boolean canAccess(Skill skill, Long currentUserId, + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { return switch (skill.getVisibility()) { case PUBLIC -> true; @@ -2540,7 +2542,7 @@ public class VisibilityChecker { }; } - private boolean isOwner(Skill skill, Long currentUserId) { + private boolean isOwner(Skill skill, String currentUserId) { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } @@ -2894,7 +2896,7 @@ public class NamespaceMemberService { } @Transactional - public NamespaceMember addMember(Long namespaceId, Long userId, NamespaceRole role) { + public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role) { if (role == NamespaceRole.OWNER) { throw new IllegalArgumentException("Cannot directly add member as OWNER, use transferOwnership"); } @@ -2910,7 +2912,7 @@ public class NamespaceMemberService { } @Transactional - public void removeMember(Long namespaceId, Long userId) { + public void removeMember(Long namespaceId, String userId) { NamespaceMember member = memberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .orElseThrow(() -> new IllegalArgumentException("Member not found")); if (member.getRole() == NamespaceRole.OWNER) { @@ -2920,7 +2922,7 @@ public class NamespaceMemberService { } @Transactional - public void updateMemberRole(Long namespaceId, Long userId, NamespaceRole newRole) { + public void updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole) { if (newRole == NamespaceRole.OWNER) { throw new IllegalArgumentException("Cannot set OWNER via updateMemberRole, use transferOwnership"); } @@ -2931,7 +2933,7 @@ public class NamespaceMemberService { } @Transactional - public void transferOwnership(Long namespaceId, Long currentOwnerId, Long newOwnerId) { + public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { NamespaceMember currentOwner = memberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId) .orElseThrow(() -> new IllegalArgumentException("Current owner not found")); NamespaceMember newOwner = memberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId) @@ -2943,7 +2945,7 @@ public class NamespaceMemberService { memberRepository.save(newOwner); } - public Optional getMemberRole(Long namespaceId, Long userId) { + public Optional getMemberRole(Long namespaceId, String userId) { return memberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole); } @@ -3035,7 +3037,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record MemberRequest( - @NotNull Long userId, + @NotNull String userId, @NotBlank String role ) {} EOF @@ -3050,7 +3052,7 @@ import java.time.LocalDateTime; public record MemberResponse( Long id, Long namespaceId, - Long userId, + String userId, String role, LocalDateTime createdAt ) { @@ -3124,7 +3126,7 @@ public class NamespaceController { @PostMapping public ResponseEntity createNamespace( @Valid @RequestBody NamespaceRequest request, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { Namespace ns = namespaceService.createNamespace( request.slug(), request.displayName(), request.description(), userId); return ResponseEntity.ok(Map.of("code", 0, "data", NamespaceResponse.from(ns))); @@ -3134,7 +3136,7 @@ public class NamespaceController { public ResponseEntity updateNamespace( @PathVariable String slug, @RequestBody Map body, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { Namespace ns = namespaceService.getNamespaceBySlug(slug); Namespace updated = namespaceService.updateNamespace( ns.getId(), @@ -3175,7 +3177,7 @@ public class NamespaceController { @DeleteMapping("/{slug}/members/{userId}") public ResponseEntity removeMember( @PathVariable String slug, - @PathVariable Long userId) { + @PathVariable String userId) { Namespace ns = namespaceService.getNamespaceBySlug(slug); memberService.removeMember(ns.getId(), userId); return ResponseEntity.ok(Map.of("code", 0, "message", "Member removed")); @@ -3184,7 +3186,7 @@ public class NamespaceController { @PutMapping("/{slug}/members/{userId}/role") public ResponseEntity updateMemberRole( @PathVariable String slug, - @PathVariable Long userId, + @PathVariable String userId, @RequestBody Map body) { Namespace ns = namespaceService.getNamespaceBySlug(slug); memberService.updateMemberRole(ns.getId(), userId, @@ -3243,7 +3245,7 @@ mkdir -p server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java << 'EOF' package com.iflytek.skillhub.domain.event; -public record SkillPublishedEvent(Long skillId, Long versionId, Long publisherId) {} +public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {} EOF cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java << 'EOF' @@ -3488,7 +3490,7 @@ cat >> server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/se @Transactional public SkillVersion publishFromEntries(String namespaceSlug, List entries, - Long publisherId, + String publisherId, SkillVisibility visibility) { // ① 解析 namespace Namespace ns = namespaceRepository.findBySlug(namespaceSlug) @@ -3802,7 +3804,7 @@ public class SkillQueryService { ) {} public SkillDetailDTO getSkillDetail(String namespaceSlug, String skillSlug, - Long currentUserId, + String currentUserId, Map userNsRoles) { Namespace ns = findNamespace(namespaceSlug); Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) @@ -3826,7 +3828,7 @@ public class SkillQueryService { } public Page listSkillsByNamespace(String namespaceSlug, - Long currentUserId, + String currentUserId, Map userNsRoles, Pageable pageable) { Namespace ns = findNamespace(namespaceSlug); @@ -4047,7 +4049,7 @@ public class SkillDownloadService { } public DownloadResult downloadLatest(String namespaceSlug, String skillSlug, - Long currentUserId, + String currentUserId, Map userNsRoles) { Skill skill = findAndCheckAccess(namespaceSlug, skillSlug, currentUserId, userNsRoles); if (skill.getLatestVersionId() == null) { @@ -4059,7 +4061,7 @@ public class SkillDownloadService { } public DownloadResult downloadVersion(String namespaceSlug, String skillSlug, - String versionStr, Long currentUserId, + String versionStr, String currentUserId, Map userNsRoles) { Skill skill = findAndCheckAccess(namespaceSlug, skillSlug, currentUserId, userNsRoles); SkillVersion version = versionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) @@ -4068,7 +4070,7 @@ public class SkillDownloadService { } public DownloadResult downloadByTag(String namespaceSlug, String skillSlug, - String tagName, Long currentUserId, + String tagName, String currentUserId, Map userNsRoles) { Skill skill = findAndCheckAccess(namespaceSlug, skillSlug, currentUserId, userNsRoles); SkillTag tag = tagRepository.findBySkillIdAndTagName(skill.getId(), tagName) @@ -4079,7 +4081,7 @@ public class SkillDownloadService { } private Skill findAndCheckAccess(String namespaceSlug, String skillSlug, - Long currentUserId, + String currentUserId, Map userNsRoles) { Namespace ns = namespaceRepository.findBySlug(namespaceSlug) .orElseThrow(() -> new IllegalArgumentException("Namespace not found")); @@ -4493,7 +4495,7 @@ public class CliPublishController { @RequestParam("file") MultipartFile file, @RequestParam("namespace") String namespace, @RequestParam(value = "visibility", defaultValue = "PUBLIC") String visibility, - @AuthenticationPrincipal Long userId) throws IOException { + @AuthenticationPrincipal String userId) throws IOException { List entries = extractZip(file); SkillVisibility vis = SkillVisibility.valueOf(visibility); @@ -4563,7 +4565,7 @@ public class SkillPublishController { @PathVariable String namespace, @RequestParam("file") MultipartFile file, @RequestParam(value = "visibility", defaultValue = "PUBLIC") String visibility, - @AuthenticationPrincipal Long userId) throws IOException { + @AuthenticationPrincipal String userId) throws IOException { List entries = extractZip(file); SkillVisibility vis = SkillVisibility.valueOf(visibility); @@ -4637,7 +4639,7 @@ public class SkillController { public ResponseEntity getSkillDetail( @PathVariable String namespace, @PathVariable String slug, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { var detail = queryService.getSkillDetail(namespace, slug, userId, Map.of()); return ResponseEntity.ok(Map.of("code", 0, "data", detail)); } @@ -4688,7 +4690,7 @@ public class SkillController { public ResponseEntity downloadLatest( @PathVariable String namespace, @PathVariable String slug, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { var result = downloadService.downloadLatest(namespace, slug, userId, Map.of()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, @@ -4703,7 +4705,7 @@ public class SkillController { @PathVariable String namespace, @PathVariable String slug, @PathVariable String version, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { var result = downloadService.downloadVersion( namespace, slug, version, userId, Map.of()); return ResponseEntity.ok() @@ -4759,7 +4761,7 @@ public class SkillTagController { @PathVariable String slug, @PathVariable String tagName, @Valid @RequestBody TagRequest request, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { SkillTag tag = tagService.createOrMoveTag( namespace, slug, tagName, request.targetVersion(), userId); return ResponseEntity.ok(Map.of("code", 0, "data", TagResponse.from(tag))); @@ -4770,7 +4772,7 @@ public class SkillTagController { @PathVariable String namespace, @PathVariable String slug, @PathVariable String tagName, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { tagService.deleteTag(namespace, slug, tagName, userId); return ResponseEntity.ok(Map.of("code", 0, "message", "Tag deleted")); } @@ -4846,7 +4848,7 @@ package com.iflytek.skillhub.search; import java.util.Set; public record SearchVisibilityScope( - Long userId, + String userId, Set memberNamespaceIds, Set adminNamespaceIds ) { @@ -5317,7 +5319,7 @@ public class SkillSearchAppService { public SearchResultDTO searchSkills(String keyword, String namespaceSlug, String sortBy, int page, int size, - Long currentUserId) { + String currentUserId) { Long namespaceId = null; if (namespaceSlug != null && !namespaceSlug.isBlank()) { namespaceId = namespaceRepository.findBySlug(namespaceSlug) @@ -5341,7 +5343,7 @@ public class SkillSearchAppService { return new SearchResultDTO(items, result.total(), result.page(), result.size()); } - private SearchVisibilityScope buildScope(Long userId) { + private SearchVisibilityScope buildScope(String userId) { // Simplified: in production, load user's namespace memberships return new SearchVisibilityScope(userId, Set.of(), Set.of()); } @@ -5396,7 +5398,7 @@ public class SkillSearchController { @RequestParam(value = "sort", defaultValue = "relevance") String sort, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { var result = searchAppService.searchSkills(keyword, namespace, sort, page, size, userId); return ResponseEntity.ok(Map.of("code", 0, "data", Map.of( "items", result.items(), @@ -6178,7 +6180,7 @@ export interface Namespace { export interface NamespaceMember { id: number; namespaceId: number; - userId: number; + userId: string; role: string; createdAt: string; } @@ -7253,8 +7255,8 @@ import { Button } from '@/shared/ui/button'; export function MemberTable({ members, onRemove, onRoleChange }: { members: NamespaceMember[]; - onRemove?: (userId: number) => void; - onRoleChange?: (userId: number, role: string) => void; + onRemove?: (userId: string) => void; + onRoleChange?: (userId: string, role: string) => void; }) { return (
diff --git a/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md b/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md index 40802972..2a47bd6f 100644 --- a/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md +++ b/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md @@ -11,6 +11,8 @@ - 兼容层:Canonical slug 映射实现 ClawHub CLI 协议兼容 - 幂等去重:Redis SETNX + PostgreSQL 双层防护 +**身份主键约束:** 用户身份主键全链路统一使用 `string`。本计划里所有 `userId`、`submittedBy`、`reviewedBy`、`ownerId`、`actorUserId` 等用户标识字段均按字符串实现;历史 `Long` / `BIGINT` 描述不再有效。 + **Tech Stack:** - 后端:Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Security + Flyway - 前端:React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui @@ -53,8 +55,8 @@ CREATE TABLE review_task ( namespace_id BIGINT NOT NULL REFERENCES namespace(id), status VARCHAR(32) NOT NULL DEFAULT 'PENDING', version INT NOT NULL DEFAULT 1, - submitted_by BIGINT NOT NULL REFERENCES user_account(id), - reviewed_by BIGINT REFERENCES user_account(id), + submitted_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + reviewed_by VARCHAR(128) REFERENCES user_account(id), review_comment TEXT, submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, reviewed_at TIMESTAMP @@ -73,8 +75,8 @@ CREATE TABLE promotion_request ( target_skill_id BIGINT REFERENCES skill(id), status VARCHAR(32) NOT NULL DEFAULT 'PENDING', version INT NOT NULL DEFAULT 1, - submitted_by BIGINT NOT NULL REFERENCES user_account(id), - reviewed_by BIGINT REFERENCES user_account(id), + submitted_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + reviewed_by VARCHAR(128) REFERENCES user_account(id), review_comment TEXT, submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, reviewed_at TIMESTAMP @@ -88,7 +90,7 @@ CREATE UNIQUE INDEX idx_promotion_request_version_pending ON promotion_request(s CREATE TABLE skill_star ( id BIGSERIAL PRIMARY KEY, skill_id BIGINT NOT NULL REFERENCES skill(id), - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, user_id) ); @@ -100,7 +102,7 @@ CREATE INDEX idx_skill_star_skill_id ON skill_star(skill_id); CREATE TABLE skill_rating ( id BIGSERIAL PRIMARY KEY, skill_id BIGINT NOT NULL REFERENCES skill(id), - user_id BIGINT NOT NULL REFERENCES user_account(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), score SMALLINT NOT NULL CHECK (score >= 1 AND score <= 5), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -205,10 +207,10 @@ public class ReviewTask { private Integer version = 1; @Column(name = "submitted_by", nullable = false) - private Long submittedBy; + private String submittedBy; @Column(name = "reviewed_by") - private Long reviewedBy; + private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @@ -222,7 +224,7 @@ public class ReviewTask { // Constructors protected ReviewTask() {} - public ReviewTask(Long skillVersionId, Long namespaceId, Long submittedBy) { + public ReviewTask(Long skillVersionId, Long namespaceId, String submittedBy) { this.skillVersionId = skillVersionId; this.namespaceId = namespaceId; this.submittedBy = submittedBy; @@ -237,7 +239,7 @@ public class ReviewTask { public Integer getVersion() { return version; } public Long getSubmittedBy() { return submittedBy; } public Long getReviewedBy() { return reviewedBy; } - public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } public String getReviewComment() { return reviewComment; } public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } public Instant getSubmittedAt() { return submittedAt; } @@ -285,10 +287,10 @@ public class PromotionRequest { private Integer version = 1; @Column(name = "submitted_by", nullable = false) - private Long submittedBy; + private String submittedBy; @Column(name = "reviewed_by") - private Long reviewedBy; + private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @@ -303,7 +305,7 @@ public class PromotionRequest { protected PromotionRequest() {} public PromotionRequest(Long sourceSkillId, Long sourceVersionId, - Long targetNamespaceId, Long submittedBy) { + Long targetNamespaceId, String submittedBy) { this.sourceSkillId = sourceSkillId; this.sourceVersionId = sourceVersionId; this.targetNamespaceId = targetNamespaceId; @@ -322,7 +324,7 @@ public class PromotionRequest { public Integer getVersion() { return version; } public Long getSubmittedBy() { return submittedBy; } public Long getReviewedBy() { return reviewedBy; } - public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } public String getReviewComment() { return reviewComment; } public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } public Instant getSubmittedAt() { return submittedAt; } @@ -371,9 +373,9 @@ public interface ReviewTaskRepository { Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); - Page findBySubmittedByAndStatus(Long submittedBy, ReviewTaskStatus status, Pageable pageable); + Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); - int updateStatusWithVersion(Long id, ReviewTaskStatus status, Long reviewedBy, + int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Integer expectedVersion); } ``` @@ -405,7 +407,7 @@ public interface ReviewTaskJpaRepository extends JpaRepository Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); - Page findBySubmittedByAndStatus(Long submittedBy, ReviewTaskStatus status, Pageable pageable); + Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); @Modifying @Query(""" @@ -419,7 +421,7 @@ public interface ReviewTaskJpaRepository extends JpaRepository """) int updateStatusWithVersion(@Param("id") Long id, @Param("status") ReviewTaskStatus status, - @Param("reviewedBy") Long reviewedBy, + @Param("reviewedBy") String reviewedBy, @Param("reviewComment") String reviewComment, @Param("expectedVersion") Integer expectedVersion); } @@ -441,7 +443,7 @@ public interface PromotionRequestRepository { Optional findById(Long id); Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); - int updateStatusWithVersion(Long id, ReviewTaskStatus status, Long reviewedBy, + int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Long targetSkillId, Integer expectedVersion); } ``` @@ -484,7 +486,7 @@ public interface PromotionRequestJpaRepository extends JpaRepository userNamespaceRoles, Set platformRoles) { // Cannot review own submission @@ -622,7 +624,7 @@ public class ReviewPermissionChecker { return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; } - public boolean canReviewPromotion(PromotionRequest request, Long userId, + public boolean canReviewPromotion(PromotionRequest request, String userId, Set platformRoles) { // Only SKILL_ADMIN or SUPER_ADMIN can review promotion return platformRoles.contains("SKILL_ADMIN") @@ -669,10 +671,10 @@ git commit -m "feat(review): add permission checker with tests package com.iflytek.skillhub.domain.review; public interface ReviewService { - ReviewTask submitReview(Long skillVersionId, Long namespaceId, Long userId); - void approveReview(Long reviewTaskId, Long reviewerId, String comment); - void rejectReview(Long reviewTaskId, Long reviewerId, String comment); - void withdrawReview(Long skillVersionId, Long userId); + ReviewTask submitReview(Long skillVersionId, Long namespaceId, String userId); + void approveReview(Long reviewTaskId, String reviewerId, String comment); + void rejectReview(Long reviewTaskId, String reviewerId, String comment); + void withdrawReview(Long skillVersionId, String userId); } ``` @@ -758,10 +760,10 @@ public class PromotionRequest { private Integer version = 1; @Column(name = "submitted_by", nullable = false) - private Long submittedBy; + private String submittedBy; @Column(name = "reviewed_by") - private Long reviewedBy; + private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @@ -775,7 +777,7 @@ public class PromotionRequest { // Constructors public PromotionRequest() {} - public PromotionRequest(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, Long submittedBy) { + public PromotionRequest(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String submittedBy) { this.sourceSkillId = sourceSkillId; this.sourceVersionId = sourceVersionId; this.targetNamespaceId = targetNamespaceId; @@ -844,7 +846,7 @@ public class PromotionRequest { return submittedBy; } - public void setSubmittedBy(Long submittedBy) { + public void setSubmittedBy(String submittedBy) { this.submittedBy = submittedBy; } @@ -852,7 +854,7 @@ public class PromotionRequest { return reviewedBy; } - public void setReviewedBy(Long reviewedBy) { + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } @@ -907,7 +909,7 @@ public interface PromotionRequestRepository extends JpaRepository findByTargetNamespaceAndStatus(Long targetNamespaceId, PromotionStatus status, Pageable pageable); @Query("SELECT pr FROM PromotionRequest pr WHERE pr.submittedBy = :userId") - Page findBySubmittedBy(Long userId, Pageable pageable); + Page findBySubmittedBy(String userId, Pageable pageable); } ``` @@ -961,7 +963,7 @@ public class PromotionService { } @Transactional - public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, Long userId) { + public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String userId) { // 1. Check if source skill and version exist Skill sourceSkill = skillRepository.findById(sourceSkillId) .orElseThrow(() -> new IllegalArgumentException("Source skill not found")); @@ -998,7 +1000,7 @@ public class PromotionService { } @Transactional - public PromotionRequest approvePromotion(Long promotionId, Long reviewerId, String comment) { + public PromotionRequest approvePromotion(Long promotionId, String reviewerId, String comment) { // 1. Load promotion request with optimistic lock PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found")); @@ -1089,7 +1091,7 @@ public class PromotionService { } @Transactional - public PromotionRequest rejectPromotion(Long promotionId, Long reviewerId, String comment) { + public PromotionRequest rejectPromotion(Long promotionId, String reviewerId, String comment) { // 1. Load promotion request with optimistic lock PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found")); @@ -1113,7 +1115,7 @@ public class PromotionService { } @Transactional - public PromotionRequest withdrawPromotion(Long promotionId, Long userId) { + public PromotionRequest withdrawPromotion(Long promotionId, String userId) { // 1. Load promotion request PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found")); @@ -1146,7 +1148,7 @@ public class PromotionService { } @Transactional(readOnly = true) - public Page listMyPromotions(Long userId, Pageable pageable) { + public Page listMyPromotions(String userId, Pageable pageable) { return promotionRequestRepository.findBySubmittedBy(userId, pageable); } @@ -1169,7 +1171,7 @@ public record PromotionApprovedEvent( Long promotionId, Long targetSkillId, Long targetVersionId, - Long reviewerId + String reviewerId ) {} ``` @@ -1223,7 +1225,7 @@ class PromotionServiceTest { private Namespace globalNamespace; private Skill teamSkill; private SkillVersion publishedVersion; - private Long userId = 1L; + private String userId = 1L; @BeforeEach void setUp() { @@ -1350,9 +1352,9 @@ public record ReviewTaskResponse( String skillSlug, String version, String status, - Long submittedBy, + String submittedBy, String submittedByUsername, - Long reviewedBy, + String reviewedBy, String reviewedByUsername, String reviewComment, LocalDateTime submittedAt, @@ -1400,9 +1402,9 @@ public record PromotionResponseDto( String targetNamespace, Long targetSkillId, String status, - Long submittedBy, + String submittedBy, String submittedByUsername, - Long reviewedBy, + String reviewedBy, String reviewedByUsername, String reviewComment, LocalDateTime submittedAt, @@ -1484,7 +1486,7 @@ public class ReviewController { @PostMapping public ResponseEntity submitReview( @RequestBody ReviewTaskRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { ReviewTask task = reviewService.submitReview(request.skillVersionId(), userId); return ResponseEntity.ok(toResponse(task)); @@ -1494,7 +1496,7 @@ public class ReviewController { public ResponseEntity approveReview( @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { String comment = request != null ? request.comment() : null; ReviewTask task = reviewService.approveReview(id, userId, comment); @@ -1505,7 +1507,7 @@ public class ReviewController { public ResponseEntity rejectReview( @PathVariable Long id, @RequestBody ReviewActionRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { ReviewTask task = reviewService.rejectReview(id, userId, request.comment()); return ResponseEntity.ok(toResponse(task)); @@ -1514,7 +1516,7 @@ public class ReviewController { @PostMapping("/{id}/withdraw") public ResponseEntity withdrawReview( @PathVariable Long id, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { ReviewTask task = reviewService.withdrawReview(id, userId); return ResponseEntity.ok(toResponse(task)); @@ -1525,7 +1527,7 @@ public class ReviewController { @RequestParam(required = false) String namespace, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { Page tasks; if (namespace != null) { @@ -1551,7 +1553,7 @@ public class ReviewController { public ResponseEntity> listMySubmissions( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedBy(userId, PageRequest.of(page, size)); return ResponseEntity.ok(tasks.map(this::toResponse)); @@ -1560,7 +1562,7 @@ public class ReviewController { @GetMapping("/{id}") public ResponseEntity getReviewDetail( @PathVariable Long id, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { ReviewTask task = reviewTaskRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Review task not found: " + id)); @@ -1665,7 +1667,7 @@ public class PromotionController { @PostMapping public ResponseEntity submitPromotion( @RequestBody PromotionRequestDto request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), @@ -1681,7 +1683,7 @@ public class PromotionController { public ResponseEntity approvePromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { String comment = request != null ? request.comment() : null; PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment); @@ -1692,7 +1694,7 @@ public class PromotionController { public ResponseEntity rejectPromotion( @PathVariable Long id, @RequestBody PromotionActionRequest request, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionService.rejectPromotion(id, userId, request.comment()); return ResponseEntity.ok(toResponse(promotion)); @@ -1702,7 +1704,7 @@ public class PromotionController { public ResponseEntity> listPendingPromotions( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { // Only SKILL_ADMIN can list pending promotions if (!rbacService.hasRole(userId, "SKILL_ADMIN")) { @@ -1720,7 +1722,7 @@ public class PromotionController { @GetMapping("/{id}") public ResponseEntity getPromotionDetail( @PathVariable Long id, - @RequestAttribute("userId") Long userId) { + @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionRequestRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found: " + id)); @@ -2032,7 +2034,7 @@ class SkillPublishServiceReviewTest { @Test void publishFromEntries_shouldCreatePendingReviewVersion() { // Arrange - Long publisherId = 100L; + String publisherId = 100L; String namespaceSlug = "test-ns"; Namespace namespace = new Namespace(); @@ -2071,7 +2073,7 @@ class SkillPublishServiceReviewTest { @Test void publishFromEntries_shouldAutoCreateReviewTask() { // Arrange - Long publisherId = 100L; + String publisherId = 100L; String namespaceSlug = "test-ns"; Namespace namespace = new Namespace(); @@ -2145,7 +2147,7 @@ public record ReviewApprovedEvent( Long reviewTaskId, Long skillId, Long versionId, - Long reviewerId, + String reviewerId, String comment ) {} ``` @@ -2159,7 +2161,7 @@ public record ReviewRejectedEvent( Long reviewTaskId, Long skillId, Long versionId, - Long reviewerId, + String reviewerId, String comment ) {} ``` @@ -2174,7 +2176,7 @@ public record PromotionApprovedEvent( Long sourceSkillId, Long sourceVersionId, Long targetSkillId, - Long reviewerId + String reviewerId ) {} ``` @@ -2226,7 +2228,7 @@ public class AuditLog { private Long entityId; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Column(name = "details", columnDefinition = "TEXT") private String details; @@ -2237,7 +2239,7 @@ public class AuditLog { // Constructors public AuditLog() {} - public AuditLog(AuditAction action, String entityType, Long entityId, Long userId, String details) { + public AuditLog(AuditAction action, String entityType, Long entityId, String userId, String details) { this.action = action; this.entityType = entityType; this.entityId = entityId; @@ -2283,7 +2285,7 @@ public class AuditLog { return userId; } - public void setUserId(Long userId) { + public void setUserId(String userId) { this.userId = userId; } @@ -2594,7 +2596,7 @@ class ReviewEventListenerTest { // Given Long skillId = 1L; Long versionId = 10L; - Long reviewerId = 5L; + String reviewerId = 5L; SkillVersion version = new SkillVersion(); version.setId(versionId); @@ -2632,7 +2634,7 @@ class ReviewEventListenerTest { // Given Long skillId = 1L; Long versionId = 10L; - Long reviewerId = 5L; + String reviewerId = 5L; SkillVersion version = new SkillVersion(); version.setId(versionId); @@ -3085,14 +3087,14 @@ public class SkillStar { private Long skillId; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); protected SkillStar() {} - public SkillStar(Long skillId, Long userId) { + public SkillStar(Long skillId, String userId) { this.skillId = skillId; this.userId = userId; } @@ -3124,7 +3126,7 @@ public class SkillRating { private Long skillId; @Column(name = "user_id", nullable = false) - private Long userId; + private String userId; @Column(nullable = false) private Short score; @@ -3137,7 +3139,7 @@ public class SkillRating { protected SkillRating() {} - public SkillRating(Long skillId, Long userId, short score) { + public SkillRating(Long skillId, String userId, short score) { if (score < 1 || score > 5) throw new IllegalArgumentException("Score must be 1-5"); this.skillId = skillId; this.userId = userId; @@ -3172,9 +3174,9 @@ import org.springframework.data.domain.Pageable; public interface SkillStarRepository { SkillStar save(SkillStar star); - Optional findBySkillIdAndUserId(Long skillId, Long userId); + Optional findBySkillIdAndUserId(Long skillId, String userId); void delete(SkillStar star); - Page findByUserId(Long userId, Pageable pageable); + Page findByUserId(String userId, Pageable pageable); long countBySkillId(Long skillId); } ``` @@ -3187,7 +3189,7 @@ import java.util.Optional; public interface SkillRatingRepository { SkillRating save(SkillRating rating); - Optional findBySkillIdAndUserId(Long skillId, Long userId); + Optional findBySkillIdAndUserId(Long skillId, String userId); double averageScoreBySkillId(Long skillId); int countBySkillId(Long skillId); } @@ -3209,8 +3211,8 @@ import org.springframework.data.domain.Pageable; @Repository public interface JpaSkillStarRepository extends JpaRepository, SkillStarRepository { - Optional findBySkillIdAndUserId(Long skillId, Long userId); - Page findByUserId(Long userId, Pageable pageable); + Optional findBySkillIdAndUserId(Long skillId, String userId); + Page findByUserId(String userId, Pageable pageable); long countBySkillId(Long skillId); } ``` @@ -3228,7 +3230,7 @@ import java.util.Optional; @Repository public interface JpaSkillRatingRepository extends JpaRepository, SkillRatingRepository { - Optional findBySkillIdAndUserId(Long skillId, Long userId); + Optional findBySkillIdAndUserId(Long skillId, String userId); @Query("SELECT COALESCE(AVG(r.score), 0) FROM SkillRating r WHERE r.skillId = :skillId") double averageScoreBySkillId(Long skillId); @@ -3268,21 +3270,21 @@ git commit -m "feat(social): add SkillStar and SkillRating entities and reposito ```java package com.iflytek.skillhub.domain.social.event; -public record SkillStarredEvent(Long skillId, Long userId) {} +public record SkillStarredEvent(Long skillId, String userId) {} ``` `SkillUnstarredEvent.java`: ```java package com.iflytek.skillhub.domain.social.event; -public record SkillUnstarredEvent(Long skillId, Long userId) {} +public record SkillUnstarredEvent(Long skillId, String userId) {} ``` `SkillRatedEvent.java`: ```java package com.iflytek.skillhub.domain.social.event; -public record SkillRatedEvent(Long skillId, Long userId, short score) {} +public record SkillRatedEvent(Long skillId, String userId, short score) {} ``` - [ ] **Step 2: 编写 SkillStarService 测试** @@ -3390,7 +3392,7 @@ public class SkillStarService { } @Transactional - public void star(Long skillId, Long userId) { + public void star(Long skillId, String userId) { if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { return; // idempotent } @@ -3399,14 +3401,14 @@ public class SkillStarService { } @Transactional - public void unstar(Long skillId, Long userId) { + public void unstar(Long skillId, String userId) { starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> { starRepository.delete(star); eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId)); }); } - public boolean isStarred(Long skillId, Long userId) { + public boolean isStarred(Long skillId, String userId) { return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); } } @@ -3505,7 +3507,7 @@ public class SkillRatingService { } @Transactional - public void rate(Long skillId, Long userId, short score) { + public void rate(Long skillId, String userId, short score) { if (score < 1 || score > 5) { throw new IllegalArgumentException("Score must be 1-5"); } @@ -3519,7 +3521,7 @@ public class SkillRatingService { eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score)); } - public Optional getUserRating(Long skillId, Long userId) { + public Optional getUserRating(Long skillId, String userId) { return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } @@ -3781,21 +3783,21 @@ public class SkillStarController { @PutMapping public ResponseEntity star(@PathVariable Long skillId, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { starService.star(skillId, userId); return ResponseEntity.noContent().build(); } @DeleteMapping public ResponseEntity unstar(@PathVariable Long skillId, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { starService.unstar(skillId, userId); return ResponseEntity.noContent().build(); } @GetMapping public ResponseEntity isStarred(@PathVariable Long skillId, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { return ResponseEntity.ok(starService.isStarred(skillId, userId)); } } @@ -3877,7 +3879,7 @@ public class SkillRatingController { @PutMapping public ResponseEntity rate(@PathVariable Long skillId, - @AuthenticationPrincipal Long userId, + @AuthenticationPrincipal String userId, @RequestBody Map body) { short score = body.get("score").shortValue(); ratingService.rate(skillId, userId, score); @@ -3886,7 +3888,7 @@ public class SkillRatingController { @GetMapping public ResponseEntity getUserRating(@PathVariable Long skillId, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { Optional score = ratingService.getUserRating(skillId, userId); return ResponseEntity.ok(Map.of("score", score.orElse(null), "rated", score.isPresent())); } @@ -5540,12 +5542,12 @@ public class DeviceCodeData implements Serializable { private String deviceCode; private String userCode; private DeviceCodeStatus status; - private Long userId; + private String userId; public DeviceCodeData() {} public DeviceCodeData(String deviceCode, String userCode, - DeviceCodeStatus status, Long userId) { + DeviceCodeStatus status, String userId) { this.deviceCode = deviceCode; this.userCode = userCode; this.status = status; @@ -5557,7 +5559,7 @@ public class DeviceCodeData implements Serializable { public DeviceCodeStatus getStatus() { return status; } public void setStatus(DeviceCodeStatus status) { this.status = status; } public Long getUserId() { return userId; } - public void setUserId(Long userId) { this.userId = userId; } + public void setUserId(String userId) { this.userId = userId; } } ``` @@ -5728,7 +5730,7 @@ public class DeviceAuthService { "/device", 900, 5); } - public void authorizeDeviceCode(String userCode, Long userId) { + public void authorizeDeviceCode(String userCode, String userId) { String deviceCode = (String) redisTemplate.opsForValue() .get(USER_CODE_PREFIX + userCode); if (deviceCode == null) { @@ -5913,7 +5915,7 @@ public class DeviceAuthWebController { @PreAuthorize("isAuthenticated()") public ResponseEntity authorizeDevice( @RequestBody Map body, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { deviceAuthService.authorizeDeviceCode(body.get("userCode"), userId); return ResponseEntity.ok().build(); } @@ -6005,7 +6007,7 @@ public class CliApiController { @GetMapping("/whoami") @PreAuthorize("isAuthenticated()") - public ResponseEntity whoami(@AuthenticationPrincipal Long userId) { + public ResponseEntity whoami(@AuthenticationPrincipal String userId) { // 查询用户信息 + 所属 namespace 列表 return ResponseEntity.ok(Map.of("code", 0, "data", Map.of("userId", userId))); } @@ -6014,7 +6016,7 @@ public class CliApiController { public ResponseEntity resolve( @RequestParam String skill, @RequestParam(defaultValue = "latest") String version, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { // 解析 @namespace/slug 格式 // 调用 SkillQueryService 获取版本详情 return ResponseEntity.ok(Map.of("code", 0)); @@ -6033,7 +6035,7 @@ public class CliApiController { @RequestParam("file") MultipartFile file, @RequestParam String namespace, @RequestParam(defaultValue = "PUBLIC") String visibility, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { // 调用 SkillPublishService return ResponseEntity.ok(Map.of("code", 0)); } @@ -6482,7 +6484,7 @@ public record ClawHubPublishResponse(String slug, String version, String status) // ClawHubWhoamiResponse.java package com.iflytek.skillhub.app.compat.dto; -public record ClawHubWhoamiResponse(Long userId, String username, String email) {} +public record ClawHubWhoamiResponse(String userId, String username, String email) {} ``` - [ ] **Step 4: 运行测试验证通过** @@ -6606,14 +6608,14 @@ public class ClawHubCompatController { public ClawHubPublishResponse publish( @RequestParam("file") MultipartFile file, @RequestParam(defaultValue = "global") String namespace, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal String userId) { // TODO: 调用 SkillPublishService return new ClawHubPublishResponse("", "", "pending_review"); } @GetMapping("/whoami") @PreAuthorize("isAuthenticated()") - public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal Long userId) { + public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal String userId) { // TODO: 查询用户信息 return new ClawHubWhoamiResponse(userId, "", ""); } @@ -7241,14 +7243,14 @@ public class UserManagementController { } @GetMapping("/{userId}") - public ResponseEntity getUserDetail(@PathVariable Long userId) { + public ResponseEntity getUserDetail(@PathVariable String userId) { // TODO: 查询用户详情 + 角色 + namespace 成员 return ResponseEntity.ok(Map.of("userId", userId)); } @PutMapping("/{userId}/roles") public ResponseEntity updateUserRoles( - @PathVariable Long userId, + @PathVariable String userId, @RequestBody Map> body) { // TODO: 更新用户平台角色 return ResponseEntity.noContent().build(); @@ -7256,7 +7258,7 @@ public class UserManagementController { @PutMapping("/{userId}/status") public ResponseEntity updateUserStatus( - @PathVariable Long userId, + @PathVariable String userId, @RequestBody Map body) { // TODO: 封禁/解封用户 return ResponseEntity.noContent().build(); @@ -7374,7 +7376,7 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/api/client'; export function useAuditLogs(params: { - action?: string; actorUserId?: number; + action?: string; actorUserId?: string; startTime?: string; endTime?: string; page: number; }) { return useQuery({ @@ -7390,7 +7392,7 @@ import { apiClient } from '@/api/client'; export function useUpdateUserRoles() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ userId, roles }: { userId: number; roles: string[] }) => + mutationFn: ({ userId, roles }: { userId: string; roles: string[] }) => apiClient.put(`/api/v1/admin/users/${userId}/roles`, { roles }), onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), }); @@ -7585,4 +7587,3 @@ git commit -m "feat(admin): add admin dashboard pages (users, audit-logs)" 1. **使用 superpowers:subagent-driven-development** — 为每个 Chunk 派发独立的子代理 2. **渐进式实施** — 先完成 Chunk 1,验收通过后再进行 Chunk 2 3. **参考设计文档** — 每个任务的详细实现逻辑参考 `docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md` - diff --git a/docs/superpowers/plans/2026-03-12-phase4-ops-polish.md b/docs/superpowers/plans/2026-03-12-phase4-ops-polish.md new file mode 100644 index 00000000..df61f946 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-phase4-ops-polish.md @@ -0,0 +1,3204 @@ +# Phase 4: 运维增强 + 打磨 + 开源就绪 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:** 扩展认证体系(本地密码登录 + 多账号合并)、完善治理(技能隐藏/撤回 + 审计日志查询)、提升可观测性(Prometheus)、优化性能(索引 + 预签名 URL + 前端代码分割)、加固安全、实现 Docker 一键启动和 K8s 部署,建立开源项目基础设施。 + +**Architecture:** 后端沿用 Spring Boot 3.x 分层架构(skillhub-auth / skillhub-domain / skillhub-app / skillhub-infra / skillhub-storage / skillhub-search),前端沿用 React 19 + TanStack Router + TanStack Query。新增本地认证独立于 OAuth 体系,通过 `local_credential` 表存在性判断认证来源。4 个 Chunk 渐进交付。 + +**身份主键约束:** 用户身份主键全链路统一使用 `string`。本计划里所有 `userId`、`primaryUserId`、`secondaryUserId`、`hiddenBy`、`yankedBy`、`actorUserId` 等用户标识字段均按字符串实现;历史 `Long` / `BIGINT` 描述不再有效。 + +**Tech Stack:** Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA, Flyway, PostgreSQL 16, Redis 7, BCrypt, Micrometer/Prometheus, React 19, TypeScript, Vite, TanStack Router/Query, shadcn/ui, Docker, Kubernetes + +**Spec:** `docs/superpowers/specs/2026-03-12-phase4-ops-polish-design.md` + +--- + +## File Structure + +### Chunk 1: 本地认证 + 多账号合并 + +**New files (backend):** +- `server/skillhub-app/src/main/resources/db/migration/V4__phase4_auth_governance.sql` — Flyway 迁移 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java` — 本地凭证实体 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java` — 凭证仓储接口 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java` — 密码策略校验 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java` — 本地认证服务 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java` — 合并请求实体 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java` — 合并仓储接口 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java` — 合并服务 +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java` — 本地认证 Controller +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java` — 合并 Controller +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java` — 注册请求 DTO +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java` — 登录请求 DTO +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java` — 修改密码 DTO +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java` — 发起合并 DTO +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java` — 验证合并 DTO +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SeedDataRunner.java` — 种子数据初始化 +- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LocalCredentialJpaRepository.java` — JPA 实现 +- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AccountMergeRequestJpaRepository.java` — JPA 实现 + +**New files (backend tests):** +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java` + +**Modified files (backend):** +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java` — 添加本地认证端点 permitAll + BCrypt Bean +- `server/skillhub-app/src/main/resources/messages.properties` — 新增 i18n 消息 +- `server/skillhub-app/src/main/resources/messages_zh.properties` — 新增中文消息 + +**New files (frontend):** +- `web/src/pages/register.tsx` — 注册页 +- `web/src/pages/settings/security.tsx` — 密码修改页 +- `web/src/pages/settings/accounts.tsx` — 账号合并页 +- `web/src/features/auth/use-local-auth.ts` — 本地认证 Hook +- `web/src/features/auth/use-account-merge.ts` — 账号合并 Hook + +**Modified files (frontend):** +- `web/src/pages/login.tsx` — 添加用户名密码 Tab +- `web/src/app/router.tsx` — 添加新路由 + +### Chunk 2: 技能治理 + 审计日志 + 可观测性 + +**New files (backend):** +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java` — 治理服务 +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java` — 审计日志实体(如不存在) +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java` — 审计日志仓储 +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java` — 审计日志查询 +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java` — 技能治理 Controller +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java` — 审计日志 Controller +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/MetricsConfig.java` — Prometheus 指标配置 + +**New files (backend tests):** +- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java` +- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogQueryServiceTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java` + +**Modified files (backend):** +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java` — 添加 hidden/hiddenAt/hiddenBy 字段 +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java` — 添加 yankedAt/yankedBy/yankReason 字段 +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java` — 添加 YANKED 枚举值 +- `server/skillhub-app/src/main/resources/application.yml` — Actuator + Prometheus 配置 + +**New files (frontend):** +- `web/src/pages/admin/audit-logs.tsx` — 审计日志页 +- `web/src/features/admin/use-audit-logs.ts` — 审计日志 Hook +- `web/src/features/skill/skill-governance-actions.tsx` — 隐藏/撤回操作组件 + +### Chunk 3: 性能优化 + 安全加固 + +**Modified files (backend):** +- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java` — 添加 generatePresignedUrl 方法 +- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java` — 实现预签名 URL +- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java` — 返回 null(降级) +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java` — 下载 302 重定向 +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java` — 安全响应头 +- `server/skillhub-app/src/main/resources/application.yml` — Session Cookie 安全配置 + +**New files (backend tests):** +- `server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServicePresignedUrlTest.java` + +**Modified files (frontend):** +- `web/src/app/router.tsx` — lazy routes 改造 +- `web/src/features/skill/markdown-renderer.tsx` — rehype-sanitize 集成 + +### Chunk 4: Docker 一键启动 + K8s + 开源基础设施 + +**New files:** +- `Dockerfile` — 多阶段构建(根目录) +- `deploy/nginx/default.conf` — Nginx SPA 配置 +- `deploy/k8s/backend-deployment.yaml` +- `deploy/k8s/frontend-deployment.yaml` +- `deploy/k8s/services.yaml` +- `deploy/k8s/ingress.yaml` +- `deploy/k8s/configmap.yaml` +- `deploy/k8s/secret.yaml.example` +- `README.md` — 项目 README +- `CONTRIBUTING.md` — 贡献指南 +- `LICENSE` — Apache 2.0 +- `CODE_OF_CONDUCT.md` — 行为准则 +- `.github/ISSUE_TEMPLATE/bug_report.md` +- `.github/ISSUE_TEMPLATE/feature_request.md` +- `.github/pull_request_template.md` + +**Modified files:** +- `docker-compose.yml` — 添加 backend/frontend 服务 + 健康检查 + +--- + +## Chunk 1: 本地认证 + 多账号合并 + +### Task 1.1: Flyway 迁移 — local_credential + account_merge_request + +**Files:** +- Create: `server/skillhub-app/src/main/resources/db/migration/V4__phase4_auth_governance.sql` + +- [ ] **Step 1: 编写迁移脚本** + +```sql +-- V4__phase4_auth_governance.sql + +-- 本地密码凭证 +CREATE TABLE local_credential ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX idx_local_credential_username ON local_credential(username); +CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential(user_id); + +-- 账号合并请求 +CREATE TABLE account_merge_request ( + id BIGSERIAL PRIMARY KEY, + primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + verification_token VARCHAR(255), + token_expires_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_merge_primary_status ON account_merge_request(primary_user_id, status); +CREATE UNIQUE INDEX idx_merge_secondary_pending ON account_merge_request(secondary_user_id) WHERE status = 'PENDING'; +CREATE INDEX idx_merge_token_pending ON account_merge_request(verification_token) WHERE status = 'PENDING'; +``` + +- [ ] **Step 2: 验证迁移** + +Run: `cd server && mvn flyway:migrate -pl skillhub-app` +Expected: 迁移成功,V4 脚本执行无错误 + +- [ ] **Step 3: Commit** + +```bash +git add server/skillhub-app/src/main/resources/db/migration/V4__phase4_auth_governance.sql +git commit -m "feat(db): add V4 migration for local_credential and account_merge_request" +``` + +### Task 1.2: PasswordPolicyValidator — 密码策略校验 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java` +- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java` + +- [ ] **Step 1: 编写失败测试** + +```java +package com.iflytek.skillhub.auth.local; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.*; + +class PasswordPolicyValidatorTest { + private final PasswordPolicyValidator validator = new PasswordPolicyValidator(); + + @Test + void validPassword_passes() { + assertThat(validator.validate("Abcdef1!")).isEmpty(); + } + + @Test + void tooShort_fails() { + assertThat(validator.validate("Ab1!xyz")).isNotEmpty(); + } + + @Test + void tooLong_fails() { + assertThat(validator.validate("A".repeat(129))).isNotEmpty(); + } + + @Test + void onlyOneCharType_fails() { + assertThat(validator.validate("abcdefgh")).isNotEmpty(); + } + + @Test + void twoCharTypes_fails() { + assertThat(validator.validate("abcdefg1")).isNotEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"}) + void threeCharTypes_passes(String password) { + assertThat(validator.validate(password)).isEmpty(); + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && mvn test -pl skillhub-auth -Dtest=PasswordPolicyValidatorTest -Dsurefire.failIfNoSpecifiedTests=false` +Expected: FAIL — 类不存在 + +- [ ] **Step 3: 实现 PasswordPolicyValidator** + +```java +package com.iflytek.skillhub.auth.local; + +import java.util.ArrayList; +import java.util.List; + +public class PasswordPolicyValidator { + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 128; + private static final int MIN_CHAR_TYPES = 3; + + public List validate(String password) { + var errors = new ArrayList(); + if (password == null || password.length() < MIN_LENGTH) { + errors.add("password.too_short"); + return errors; + } + if (password.length() > MAX_LENGTH) { + errors.add("password.too_long"); + return errors; + } + int types = 0; + if (password.chars().anyMatch(Character::isUpperCase)) types++; + if (password.chars().anyMatch(Character::isLowerCase)) types++; + if (password.chars().anyMatch(Character::isDigit)) types++; + if (password.chars().anyMatch(c -> !Character.isLetterOrDigit(c))) types++; + if (types < MIN_CHAR_TYPES) { + errors.add("password.insufficient_complexity"); + } + return errors; + } +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && mvn test -pl skillhub-auth -Dtest=PasswordPolicyValidatorTest` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java \ + server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java +git commit -m "feat(auth): add PasswordPolicyValidator with TDD" +``` + +### Task 1.3: LocalCredential 实体 + Repository + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java` +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LocalCredentialJpaRepository.java` + +- [ ] **Step 1: 创建 LocalCredential 实体** + +```java +package com.iflytek.skillhub.auth.local; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "local_credential") +public class LocalCredential { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(nullable = false, length = 64) + private String username; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "failed_attempts", nullable = false) + private int failedAttempts = 0; + + @Column(name = "locked_until") + private LocalDateTime lockedUntil; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected LocalCredential() {} + + public LocalCredential(String userId, String username, String passwordHash) { + this.userId = userId; + this.username = username; + this.passwordHash = passwordHash; + } + + @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 Long getUserId() { return userId; } + public String getUsername() { return username; } + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + public int getFailedAttempts() { return failedAttempts; } + public void setFailedAttempts(int failedAttempts) { this.failedAttempts = failedAttempts; } + public LocalDateTime getLockedUntil() { return lockedUntil; } + public void setLockedUntil(LocalDateTime lockedUntil) { this.lockedUntil = lockedUntil; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + + public boolean isLocked() { + return lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now()); + } + + public void recordFailedAttempt(int maxAttempts, int lockMinutes) { + this.failedAttempts++; + if (this.failedAttempts >= maxAttempts) { + this.lockedUntil = LocalDateTime.now().plusMinutes(lockMinutes); + } + } + + public void resetFailedAttempts() { + this.failedAttempts = 0; + this.lockedUntil = null; + } +} +``` + +- [ ] **Step 2: 创建 Repository 接口** + +```java +package com.iflytek.skillhub.auth.local; + +import java.util.Optional; + +public interface LocalCredentialRepository { + Optional findByUsername(String username); + Optional findByUserId(String userId); + LocalCredential save(LocalCredential credential); + boolean existsByUsername(String username); +} +``` + +- [ ] **Step 3: 创建 JPA 实现** + +```java +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface LocalCredentialJpaRepository extends JpaRepository, LocalCredentialRepository { + Optional findByUsername(String username); + Optional findByUserId(String userId); + boolean existsByUsername(String username); +} +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && mvn compile -pl skillhub-auth,skillhub-infra` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LocalCredentialJpaRepository.java +git commit -m "feat(auth): add LocalCredential entity and repository" +``` + +### Task 1.4: LocalAuthService — 注册/登录/改密 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java` +- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java` + +- [ ] **Step 1: 编写注册测试** + +```java +package com.iflytek.skillhub.auth.local; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocalAuthServiceTest { + @Mock private LocalCredentialRepository credentialRepo; + @Mock private UserAccountRepository userRepo; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); // 低 strength 加速测试 + private final PasswordPolicyValidator policyValidator = new PasswordPolicyValidator(); + private LocalAuthService service; + + @BeforeEach + void setUp() { + service = new LocalAuthService(credentialRepo, userRepo, passwordEncoder, policyValidator); + } + + @Test + void register_success() { + when(credentialRepo.existsByUsername("testuser")).thenReturn(false); + when(userRepo.save(any())).thenAnswer(inv -> { + var u = inv.getArgument(0, UserAccount.class); + return u; // 模拟保存 + }); + when(credentialRepo.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = service.register("testuser", "Test@1234", null); + assertThat(result).isNotNull(); + verify(credentialRepo).save(argThat(c -> c.getUsername().equals("testuser"))); + } + + @Test + void register_duplicateUsername_throws() { + when(credentialRepo.existsByUsername("taken")).thenReturn(true); + assertThatThrownBy(() -> service.register("taken", "Test@1234", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void register_weakPassword_throws() { + assertThatThrownBy(() -> service.register("user", "weak", null)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && mvn test -pl skillhub-auth -Dtest=LocalAuthServiceTest` +Expected: FAIL — LocalAuthService 不存在 + +- [ ] **Step 3: 实现 LocalAuthService** + +```java +package com.iflytek.skillhub.auth.local; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + +public class LocalAuthService { + private static final int MAX_FAILED_ATTEMPTS = 5; + private static final int LOCK_MINUTES = 15; + + private final LocalCredentialRepository credentialRepo; + private final UserAccountRepository userRepo; + private final PasswordEncoder passwordEncoder; + private final PasswordPolicyValidator policyValidator; + + public LocalAuthService(LocalCredentialRepository credentialRepo, + UserAccountRepository userRepo, + PasswordEncoder passwordEncoder, + PasswordPolicyValidator policyValidator) { + this.credentialRepo = credentialRepo; + this.userRepo = userRepo; + this.passwordEncoder = passwordEncoder; + this.policyValidator = policyValidator; + } + + @Transactional + public UserAccount register(String username, String password, String email) { + var errors = policyValidator.validate(password); + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Password policy violation: " + errors); + } + if (credentialRepo.existsByUsername(username)) { + throw new IllegalArgumentException("Username already taken"); + } + var user = new UserAccount(username, email, null); + user.setStatus(UserStatus.ACTIVE); + user = userRepo.save(user); + + var credential = new LocalCredential(user.getId(), username, passwordEncoder.encode(password)); + credentialRepo.save(credential); + return user; + } + + @Transactional + public UserAccount login(String username, String password) { + var credential = credentialRepo.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("Invalid username or password")); + + var user = userRepo.findById(credential.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found")); + + if (user.getStatus() == UserStatus.DISABLED) { + throw new IllegalStateException("Account is disabled"); + } + if (credential.isLocked()) { + throw new IllegalStateException("Account is locked"); + } + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + credential.recordFailedAttempt(MAX_FAILED_ATTEMPTS, LOCK_MINUTES); + credentialRepo.save(credential); + throw new IllegalArgumentException("Invalid username or password"); + } + credential.resetFailedAttempts(); + credentialRepo.save(credential); + return user; + } + + @Transactional + public void changePassword(String userId, String oldPassword, String newPassword) { + var credential = credentialRepo.findByUserId(userId) + .orElseThrow(() -> new IllegalStateException("No local credential")); + if (!passwordEncoder.matches(oldPassword, credential.getPasswordHash())) { + throw new IllegalArgumentException("Old password is incorrect"); + } + var errors = policyValidator.validate(newPassword); + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Password policy violation: " + errors); + } + credential.setPasswordHash(passwordEncoder.encode(newPassword)); + credentialRepo.save(credential); + } +} +``` + +- [ ] **Step 4: 添加登录测试** + +在 `LocalAuthServiceTest` 中追加: + +```java +@Test +void login_success() { + var cred = new LocalCredential(1L, "testuser", passwordEncoder.encode("Test@1234")); + var user = new UserAccount("testuser", null, null); + when(credentialRepo.findByUsername("testuser")).thenReturn(java.util.Optional.of(cred)); + when(userRepo.findById(1L)).thenReturn(java.util.Optional.of(user)); + + var result = service.login("testuser", "Test@1234"); + assertThat(result).isNotNull(); + verify(credentialRepo).save(argThat(c -> c.getFailedAttempts() == 0)); +} + +@Test +void login_wrongPassword_incrementsFailedAttempts() { + var cred = new LocalCredential(1L, "testuser", passwordEncoder.encode("Test@1234")); + var user = new UserAccount("testuser", null, null); + when(credentialRepo.findByUsername("testuser")).thenReturn(java.util.Optional.of(cred)); + when(userRepo.findById(1L)).thenReturn(java.util.Optional.of(user)); + + assertThatThrownBy(() -> service.login("testuser", "wrong")) + .isInstanceOf(IllegalArgumentException.class); + verify(credentialRepo).save(argThat(c -> c.getFailedAttempts() == 1)); +} + +@Test +void login_lockedAccount_throws() { + var cred = new LocalCredential(1L, "testuser", passwordEncoder.encode("Test@1234")); + cred.setLockedUntil(LocalDateTime.now().plusMinutes(10)); + var user = new UserAccount("testuser", null, null); + when(credentialRepo.findByUsername("testuser")).thenReturn(java.util.Optional.of(cred)); + when(userRepo.findById(1L)).thenReturn(java.util.Optional.of(user)); + + assertThatThrownBy(() -> service.login("testuser", "Test@1234")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("locked"); +} +``` + +- [ ] **Step 5: 运行全部测试** + +Run: `cd server && mvn test -pl skillhub-auth -Dtest=LocalAuthServiceTest` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java \ + server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +git commit -m "feat(auth): add LocalAuthService with register/login/changePassword" +``` + +### Task 1.5: DTO 类 — 请求/响应对象 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java` + +- [ ] **Step 1: 创建 LocalRegisterRequest** + +```java +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record LocalRegisterRequest( + @NotBlank @Size(min = 3, max = 64) + @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username must contain only letters, digits, underscores") + String username, + + @NotBlank @Size(min = 8, max = 128) + String password, + + String email +) {} +``` + +- [ ] **Step 2: 创建 LocalLoginRequest** + +```java +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LocalLoginRequest( + @NotBlank String username, + @NotBlank String password +) {} +``` + +- [ ] **Step 3: 创建 ChangePasswordRequest** + +```java +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ChangePasswordRequest( + @NotBlank String oldPassword, + @NotBlank @Size(min = 8, max = 128) String newPassword +) {} +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && mvn compile -pl skillhub-app` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java +git commit -m "feat(auth): add local auth request DTOs" +``` + +### Task 1.6: LocalAuthController — 本地认证端点 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java` + +- [ ] **Step 1: 编写 Controller 测试** + +```java +package com.iflytek.skillhub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.dto.LocalRegisterRequest; +import com.iflytek.skillhub.dto.LocalLoginRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.bean.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(LocalAuthController.class) +class LocalAuthControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private LocalAuthService localAuthService; + + @Test + void register_success_returns200() throws Exception { + var user = new UserAccount("testuser", null, null); + when(localAuthService.register(eq("testuser"), eq("Test@1234"), isNull())) + .thenReturn(user); + + mockMvc.perform(post("/api/v1/auth/local/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new LocalRegisterRequest("testuser", "Test@1234", null)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void register_invalidUsername_returns400() throws Exception { + mockMvc.perform(post("/api/v1/auth/local/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"password\":\"Test@1234\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_success_returns200() throws Exception { + var user = new UserAccount("testuser", null, null); + when(localAuthService.login("testuser", "Test@1234")).thenReturn(user); + + mockMvc.perform(post("/api/v1/auth/local/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new LocalLoginRequest("testuser", "Test@1234")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void login_wrongPassword_returns401() throws Exception { + when(localAuthService.login("testuser", "wrong")) + .thenThrow(new IllegalArgumentException("Invalid username or password")); + + mockMvc.perform(post("/api/v1/auth/local/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new LocalLoginRequest("testuser", "wrong")))) + .andExpect(status().isUnauthorized()); + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=LocalAuthControllerTest -Dsurefire.failIfNoSpecifiedTests=false` +Expected: FAIL — LocalAuthController 不存在 + +- [ ] **Step 3: 实现 LocalAuthController** + +```java +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.dto.ChangePasswordRequest; +import com.iflytek.skillhub.dto.LocalLoginRequest; +import com.iflytek.skillhub.dto.LocalRegisterRequest; +import com.iflytek.skillhub.domain.user.UserAccount; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/auth/local") +public class LocalAuthController { + private final LocalAuthService localAuthService; + + public LocalAuthController(LocalAuthService localAuthService) { + this.localAuthService = localAuthService; + } + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody LocalRegisterRequest req, + HttpSession session) { + try { + var user = localAuthService.register(req.username(), req.password(), req.email()); + session.setAttribute("userId", user.getId()); + return ResponseEntity.ok(Map.of("code", 0, "msg", "注册成功", "data", Map.of( + "userId", user.getId(), "displayName", user.getDisplayName()))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage())); + } + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LocalLoginRequest req, + HttpSession session) { + try { + var user = localAuthService.login(req.username(), req.password()); + session.setAttribute("userId", user.getId()); + return ResponseEntity.ok(Map.of("code", 0, "msg", "登录成功", "data", Map.of( + "userId", user.getId(), "displayName", user.getDisplayName()))); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", 401, "msg", e.getMessage())); + } catch (IllegalStateException e) { + if (e.getMessage().contains("locked")) { + return ResponseEntity.status(423) + .body(Map.of("code", 423, "msg", e.getMessage())); + } + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("code", 403, "msg", e.getMessage())); + } + } + + @PostMapping("/change-password") + public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest req, + HttpSession session) { + var userId = (Long) session.getAttribute("userId"); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", 401, "msg", "请先登录")); + } + try { + localAuthService.changePassword(userId, req.oldPassword(), req.newPassword()); + return ResponseEntity.ok(Map.of("code", 0, "msg", "密码修改成功")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage())); + } + } +} +``` + +- [ ] **Step 4: 修改 SecurityConfig — 添加本地认证端点 permitAll + BCrypt Bean** + +在 `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java` 中: + +1. 添加 import: +```java +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +``` + +2. 在 `authorizeHttpRequests` 的 `.permitAll()` 列表中添加: +```java +.requestMatchers( + "/api/v1/health", + "/api/v1/auth/providers", + "/api/v1/auth/me", + "/api/v1/auth/local/register", // 新增 + "/api/v1/auth/local/login", // 新增 + "/actuator/health", + // ... 其余不变 +).permitAll() +``` + +3. 添加 BCrypt Bean: +```java +@Bean +public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); +} +``` + +- [ ] **Step 5: 运行测试** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=LocalAuthControllerTest` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +git commit -m "feat(auth): add LocalAuthController + SecurityConfig local auth endpoints" +``` + +### Task 1.7: AccountMergeRequest 实体 + Repository + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java` +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AccountMergeRequestJpaRepository.java` + +- [ ] **Step 1: 创建 AccountMergeRequest 实体** + +```java +package com.iflytek.skillhub.auth.merge; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "account_merge_request") +public class AccountMergeRequest { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "primary_user_id", nullable = false) + private Long primaryUserId; + + @Column(name = "secondary_user_id", nullable = false) + private Long secondaryUserId; + + @Column(nullable = false, length = 32) + private String status = "PENDING"; + + @Column(name = "verification_token") + private String verificationToken; + + @Column(name = "token_expires_at") + private LocalDateTime tokenExpiresAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected AccountMergeRequest() {} + + public AccountMergeRequest(Long primaryUserId, Long secondaryUserId) { + this.primaryUserId = primaryUserId; + this.secondaryUserId = secondaryUserId; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + // Getters + public Long getId() { return id; } + public Long getPrimaryUserId() { return primaryUserId; } + public Long getSecondaryUserId() { return secondaryUserId; } + public String getStatus() { return status; } + public String getVerificationToken() { return verificationToken; } + public LocalDateTime getTokenExpiresAt() { return tokenExpiresAt; } + public LocalDateTime getCompletedAt() { return completedAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + + // Status transitions + public void setVerificationToken(String token, LocalDateTime expiresAt) { + this.verificationToken = token; + this.tokenExpiresAt = expiresAt; + } + + public void verify() { + this.status = "VERIFIED"; + } + + public void complete() { + this.status = "COMPLETED"; + this.completedAt = LocalDateTime.now(); + } + + public void cancel() { + this.status = "CANCELLED"; + } + + public boolean isExpired() { + return tokenExpiresAt != null && tokenExpiresAt.isBefore(LocalDateTime.now()); + } +} +``` + +- [ ] **Step 2: 创建 Repository 接口** + +```java +package com.iflytek.skillhub.auth.merge; + +import java.util.List; +import java.util.Optional; + +public interface AccountMergeRequestRepository { + AccountMergeRequest save(AccountMergeRequest request); + Optional findById(Long id); + List findByPrimaryUserId(Long primaryUserId); + Optional findBySecondaryUserIdAndStatus(Long secondaryUserId, String status); +} +``` + +- [ ] **Step 3: 创建 JPA 实现** + +```java +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface AccountMergeRequestJpaRepository + extends JpaRepository, AccountMergeRequestRepository { + List findByPrimaryUserId(Long primaryUserId); + Optional findBySecondaryUserIdAndStatus(Long secondaryUserId, String status); +} +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && mvn compile -pl skillhub-auth,skillhub-infra` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AccountMergeRequestJpaRepository.java +git commit -m "feat(auth): add AccountMergeRequest entity and repository" +``` + +### Task 1.8: AccountMergeService — 合并服务 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java` +- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java` + +- [ ] **Step 1: 编写测试 — 发起合并** + +```java +package com.iflytek.skillhub.auth.merge; + +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.Optional; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AccountMergeServiceTest { + @Mock private AccountMergeRequestRepository mergeRepo; + @Mock private UserAccountRepository userRepo; + @Mock private LocalCredentialRepository credentialRepo; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + private AccountMergeService service; + + @BeforeEach + void setUp() { + service = new AccountMergeService(mergeRepo, userRepo, credentialRepo, passwordEncoder); + } + + @Test + void initiate_success() { + when(userRepo.findById(2L)).thenReturn(Optional.of(new UserAccount("secondary", null, null))); + when(mergeRepo.findBySecondaryUserIdAndStatus(2L, "PENDING")).thenReturn(Optional.empty()); + when(mergeRepo.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = service.initiate(1L, 2L); + assertThat(result).isNotNull(); + assertThat(result.getPrimaryUserId()).isEqualTo(1L); + assertThat(result.getSecondaryUserId()).isEqualTo(2L); + assertThat(result.getStatus()).isEqualTo("PENDING"); + } + + @Test + void initiate_selfMerge_throws() { + assertThatThrownBy(() -> service.initiate(1L, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot merge"); + } + + @Test + void initiate_duplicatePending_throws() { + when(userRepo.findById(2L)).thenReturn(Optional.of(new UserAccount("secondary", null, null))); + when(mergeRepo.findBySecondaryUserIdAndStatus(2L, "PENDING")) + .thenReturn(Optional.of(new AccountMergeRequest(3L, 2L))); + + assertThatThrownBy(() -> service.initiate(1L, 2L)) + .isInstanceOf(IllegalStateException.class); + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && mvn test -pl skillhub-auth -Dtest=AccountMergeServiceTest -Dsurefire.failIfNoSpecifiedTests=false` +Expected: FAIL — AccountMergeService 不存在 + +- [ ] **Step 3: 实现 AccountMergeService** + +```java +package com.iflytek.skillhub.auth.merge; + +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.UUID; + +public class AccountMergeService { + private static final int TOKEN_EXPIRY_MINUTES = 30; + + private final AccountMergeRequestRepository mergeRepo; + private final UserAccountRepository userRepo; + private final LocalCredentialRepository credentialRepo; + private final PasswordEncoder passwordEncoder; + + public AccountMergeService(AccountMergeRequestRepository mergeRepo, + UserAccountRepository userRepo, + LocalCredentialRepository credentialRepo, + PasswordEncoder passwordEncoder) { + this.mergeRepo = mergeRepo; + this.userRepo = userRepo; + this.credentialRepo = credentialRepo; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public AccountMergeRequest initiate(Long primaryUserId, Long secondaryUserId) { + if (primaryUserId.equals(secondaryUserId)) { + throw new IllegalArgumentException("Cannot merge account with itself"); + } + userRepo.findById(secondaryUserId) + .orElseThrow(() -> new IllegalArgumentException("Secondary user not found")); + + mergeRepo.findBySecondaryUserIdAndStatus(secondaryUserId, "PENDING") + .ifPresent(r -> { throw new IllegalStateException("Pending merge already exists"); }); + + var rawToken = UUID.randomUUID().toString(); + var request = new AccountMergeRequest(primaryUserId, secondaryUserId); + request.setVerificationToken( + passwordEncoder.encode(rawToken), + LocalDateTime.now().plusMinutes(TOKEN_EXPIRY_MINUTES)); + return mergeRepo.save(request); + } + + @Transactional + public void verify(Long requestId, String token) { + var request = mergeRepo.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Merge request not found")); + if (!"PENDING".equals(request.getStatus())) { + throw new IllegalStateException("Request is not pending"); + } + if (request.isExpired()) { + request.cancel(); + mergeRepo.save(request); + throw new IllegalStateException("Verification token expired"); + } + if (!passwordEncoder.matches(token, request.getVerificationToken())) { + throw new IllegalArgumentException("Invalid verification token"); + } + request.verify(); + mergeRepo.save(request); + } + + @Transactional + public void confirm(Long requestId) { + var request = mergeRepo.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Merge request not found")); + if (!"VERIFIED".equals(request.getStatus())) { + throw new IllegalStateException("Request is not verified"); + } + // 数据迁移逻辑(技能、收藏、角色等)在此扩展 + var secondaryUser = userRepo.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new IllegalStateException("Secondary user not found")); + secondaryUser.setStatus(UserStatus.MERGED); + secondaryUser.setMergedToUserId(request.getPrimaryUserId()); + userRepo.save(secondaryUser); + + request.complete(); + mergeRepo.save(request); + } + + @Transactional + public void cancel(Long requestId, String userId) { + var request = mergeRepo.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Merge request not found")); + if (!request.getPrimaryUserId().equals(userId)) { + throw new IllegalArgumentException("Only primary user can cancel"); + } + if ("COMPLETED".equals(request.getStatus())) { + throw new IllegalStateException("Cannot cancel completed merge"); + } + request.cancel(); + mergeRepo.save(request); + } +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cd server && mvn test -pl skillhub-auth -Dtest=AccountMergeServiceTest` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java \ + server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +git commit -m "feat(auth): add AccountMergeService with initiate/verify/confirm/cancel" +``` + +### Task 1.9: AccountMergeController + 合并 DTO + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java` + +- [ ] **Step 1: 创建合并 DTO** + +```java +// MergeInitiateRequest.java +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; + +public record MergeInitiateRequest( + @NotNull Long secondaryUserId +) {} +``` + +```java +// MergeVerifyRequest.java +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MergeVerifyRequest( + @NotBlank String token +) {} +``` + +- [ ] **Step 2: 编写 Controller 测试** + +```java +package com.iflytek.skillhub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.dto.MergeInitiateRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.bean.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AccountMergeController.class) +class AccountMergeControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private AccountMergeService mergeService; + + @Test + void initiate_success() throws Exception { + var session = new MockHttpSession(); + session.setAttribute("userId", 1L); + var mergeReq = new AccountMergeRequest(1L, 2L); + when(mergeService.initiate(1L, 2L)).thenReturn(mergeReq); + + mockMvc.perform(post("/api/v1/auth/merge/initiate") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new MergeInitiateRequest(2L)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void initiate_notLoggedIn_returns401() throws Exception { + mockMvc.perform(post("/api/v1/auth/merge/initiate") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new MergeInitiateRequest(2L)))) + .andExpect(status().isUnauthorized()); + } +} +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=AccountMergeControllerTest -Dsurefire.failIfNoSpecifiedTests=false` +Expected: FAIL — AccountMergeController 不存在 + +- [ ] **Step 4: 实现 AccountMergeController** + +```java +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.dto.MergeInitiateRequest; +import com.iflytek.skillhub.dto.MergeVerifyRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/auth/merge") +public class AccountMergeController { + private final AccountMergeService mergeService; + + public AccountMergeController(AccountMergeService mergeService) { + this.mergeService = mergeService; + } + + @PostMapping("/initiate") + public ResponseEntity initiate(@Valid @RequestBody MergeInitiateRequest req, + HttpSession session) { + var userId = (Long) session.getAttribute("userId"); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", 401, "msg", "请先登录")); + } + try { + var result = mergeService.initiate(userId, req.secondaryUserId()); + return ResponseEntity.ok(Map.of("code", 0, "msg", "合并请求已创建", + "data", Map.of("requestId", result.getId()))); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage())); + } + } + + @PostMapping("/{requestId}/verify") + public ResponseEntity verify(@PathVariable Long requestId, + @Valid @RequestBody MergeVerifyRequest req) { + try { + mergeService.verify(requestId, req.token()); + return ResponseEntity.ok(Map.of("code", 0, "msg", "验证成功")); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage())); + } + } + + @PostMapping("/{requestId}/confirm") + public ResponseEntity confirm(@PathVariable Long requestId, HttpSession session) { + var userId = (Long) session.getAttribute("userId"); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", 401, "msg", "请先登录")); + } + try { + mergeService.confirm(requestId); + return ResponseEntity.ok(Map.of("code", 0, "msg", "合并完成")); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage())); + } + } + + @PostMapping("/{requestId}/cancel") + public ResponseEntity cancel(@PathVariable Long requestId, HttpSession session) { + var userId = (Long) session.getAttribute("userId"); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("code", 401, "msg", "请先登录")); + } + try { + mergeService.cancel(requestId, userId); + return ResponseEntity.ok(Map.of("code", 0, "msg", "合并已取消")); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage())); + } + } +} +``` + +- [ ] **Step 5: 运行测试** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=AccountMergeControllerTest` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +git commit -m "feat(auth): add AccountMergeController with initiate/verify/confirm/cancel" +``` + +--- + +### Task 1.10: SeedDataRunner — Docker 环境种子数据 + +**文件:** +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SeedDataRunner.java` + +**步骤:** + +- [ ] **Step 1: 实现 SeedDataRunner** + +```java +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@Profile("docker") +public class SeedDataRunner implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(SeedDataRunner.class); + private final UserAccountRepository userRepo; + private final PasswordEncoder encoder; + + public SeedDataRunner(UserAccountRepository userRepo, PasswordEncoder encoder) { + this.userRepo = userRepo; + this.encoder = encoder; + } + + @Override + public void run(String... args) { + if (userRepo.count() > 0) { + log.info("Seed data already exists, skipping."); + return; + } + UserAccount admin = new UserAccount(); + admin.setUsername("admin"); + admin.setPasswordHash(encoder.encode("Admin@2026")); + admin.setDisplayName("Administrator"); + admin.setStatus("ACTIVE"); + userRepo.save(admin); + + UserAccount demo = new UserAccount(); + demo.setUsername("demo"); + demo.setPasswordHash(encoder.encode("Demo@2026")); + demo.setDisplayName("Demo User"); + demo.setStatus("ACTIVE"); + userRepo.save(demo); + + log.info("Seed data created: admin, demo"); + } +} +``` + +- [ ] **Step 2: 添加 docker profile 配置** + +在 `application.yml` 中添加: +```yaml +--- +spring: + config: + activate: + on-profile: docker + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:skillhub} + username: ${DB_USER:skillhub} + password: ${DB_PASS:skillhub} +``` + +- [ ] **Step 3: 验证编译通过** + +Run: `cd server && mvn compile -pl skillhub-app` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SeedDataRunner.java \ + server/skillhub-app/src/main/resources/application.yml +git commit -m "feat(ops): add SeedDataRunner for docker profile with admin/demo users" +``` + +--- + +## Chunk 2: Skill 治理 + 审计日志 + Prometheus 指标 + +### Task 2.1: AuditLog Entity + Repository + +**文件:** +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java` +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java` +- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java` +- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogTest.java` + +**步骤:** + +- [ ] **Step 1: 写失败测试** + +```java +package com.iflytek.skillhub.domain.audit; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class AuditLogTest { + + @Test + void shouldCreateAuditLog() { + AuditLog log = AuditLog.of("user-1", "SKILL_PUBLISH", + "skill-1", "Skill", "Published version 1.0.0"); + assertThat(log.getUserId()).isEqualTo("user-1"); + assertThat(log.getAction()).isEqualTo("SKILL_PUBLISH"); + assertThat(log.getTargetId()).isEqualTo("skill-1"); + assertThat(log.getTargetType()).isEqualTo("Skill"); + assertThat(log.getDetail()).isEqualTo("Published version 1.0.0"); + assertThat(log.getCreatedAt()).isNotNull(); + } + + @Test + void shouldCreateWithoutDetail() { + AuditLog log = AuditLog.of("user-1", "USER_LOGIN", null, null, null); + assertThat(log.getAction()).isEqualTo("USER_LOGIN"); + assertThat(log.getTargetId()).isNull(); + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditLogTest` +Expected: COMPILATION FAILURE + +- [ ] **Step 3: 实现 AuditLog Entity** + +```java +package com.iflytek.skillhub.domain.audit; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "audit_log") +public class AuditLog { + @Id + private String id; + private String userId; + private String action; + private String targetId; + private String targetType; + + @Column(length = 2000) + private String detail; + private Instant createdAt; + + protected AuditLog() {} + + public static AuditLog of(String userId, String action, + String targetId, String targetType, String detail) { + AuditLog log = new AuditLog(); + log.id = UUID.randomUUID().toString(); + log.userId = userId; + log.action = action; + log.targetId = targetId; + log.targetType = targetType; + log.detail = detail; + log.createdAt = Instant.now(); + return log; + } + + public String getId() { return id; } + public String getUserId() { return userId; } + public String getAction() { return action; } + public String getTargetId() { return targetId; } + public String getTargetType() { return targetType; } + public String getDetail() { return detail; } + public Instant getCreatedAt() { return createdAt; } +} +``` + +- [ ] **Step 4: 实现 Repository** + +```java +package com.iflytek.skillhub.domain.audit; + +import java.time.Instant; +import java.util.List; + +public interface AuditLogRepository { + AuditLog save(AuditLog log); + List findByUserId(String userId); + List findByTargetId(String targetId); + List findByActionAndCreatedAtAfter(String action, Instant after); +} +``` + +JPA 实现: +```java +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.audit.AuditLogRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.time.Instant; +import java.util.List; + +@Repository +public interface AuditLogJpaRepository + extends JpaRepository, AuditLogRepository { + List findByUserId(String userId); + List findByTargetId(String targetId); + List findByActionAndCreatedAtAfter(String action, Instant after); +} +``` + +- [ ] **Step 5: 运行测试** + +Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditLogTest` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/ \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogTest.java +git commit -m "feat(audit): add AuditLog entity and repository" +``` + +--- + +### Task 2.2: AuditService + 关键操作埋点 + +**文件:** +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditService.java` +- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditServiceTest.java` + +**步骤:** + +- [ ] **Step 1: 写失败测试** + +```java +package com.iflytek.skillhub.domain.audit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuditServiceTest { + + private AuditLogRepository repo; + private AuditService service; + + @BeforeEach + void setUp() { + repo = mock(AuditLogRepository.class); + when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0)); + service = new AuditService(repo); + } + + @Test + void shouldLogAction() { + service.log("user-1", "SKILL_PUBLISH", "skill-1", "Skill", "v1.0"); + ArgumentCaptor cap = ArgumentCaptor.forClass(AuditLog.class); + verify(repo).save(cap.capture()); + assertThat(cap.getValue().getAction()).isEqualTo("SKILL_PUBLISH"); + } + + @Test + void shouldLogWithoutTarget() { + service.log("user-1", "USER_LOGIN", null, null, null); + verify(repo).save(any()); + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditServiceTest` +Expected: COMPILATION FAILURE + +- [ ] **Step 3: 实现 AuditService** + +```java +package com.iflytek.skillhub.domain.audit; + +import org.springframework.stereotype.Service; + +@Service +public class AuditService { + + private final AuditLogRepository repository; + + public AuditService(AuditLogRepository repository) { + this.repository = repository; + } + + public void log(String userId, String action, + String targetId, String targetType, String detail) { + repository.save(AuditLog.of(userId, action, targetId, targetType, detail)); + } +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditServiceTest` +Expected: ALL PASS + +- [ ] **Step 5: 在关键 Controller 中埋点** + +在以下位置添加 `auditService.log(...)` 调用: +- `LocalAuthController.register()` → action: `USER_REGISTER` +- `LocalAuthController.login()` → action: `USER_LOGIN` +- `SkillPublishController.publish()` → action: `SKILL_PUBLISH` +- `AccountMergeController.confirm()` → action: `ACCOUNT_MERGE` + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditServiceTest.java +git commit -m "feat(audit): add AuditService and embed audit logging in key operations" +``` + +--- + +### Task 2.3: Flyway V4 — audit_log + skill_deprecation 表 + +**文件:** +- `server/skillhub-app/src/main/resources/db/migration/V4__phase4_audit_governance.sql` + +**步骤:** + +- [ ] **Step 1: 创建迁移脚本** + +```sql +-- V4__phase4_audit_governance.sql + +-- 审计日志表 +CREATE TABLE audit_log ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36), + action VARCHAR(64) NOT NULL, + target_id VARCHAR(36), + target_type VARCHAR(64), + detail VARCHAR(2000), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_log_user ON audit_log(user_id); +CREATE INDEX idx_audit_log_action ON audit_log(action, created_at); +CREATE INDEX idx_audit_log_target ON audit_log(target_id); + +-- Skill 废弃标记字段 +ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecated BOOLEAN DEFAULT FALSE; +ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecated_at TIMESTAMP WITH TIME ZONE; +ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecated_by VARCHAR(36); +ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecation_reason VARCHAR(500); + +-- 账号合并请求表 +CREATE TABLE account_merge_request ( + id VARCHAR(36) PRIMARY KEY, + primary_user_id VARCHAR(36) NOT NULL, + secondary_user_id VARCHAR(36) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + verification_token VARCHAR(255), + token_expires_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- 用户表扩展: 本地认证字段 +ALTER TABLE user_account ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); +ALTER TABLE user_account ADD COLUMN IF NOT EXISTS failed_attempts INT DEFAULT 0; +ALTER TABLE user_account ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP WITH TIME ZONE; +ALTER TABLE user_account ADD COLUMN IF NOT EXISTS merged_to_user_id VARCHAR(36); +``` + +- [ ] **Step 2: 验证迁移** + +Run: `cd server && mvn flyway:migrate -pl skillhub-app -Dflyway.configFiles=src/main/resources/application.yml` +或在应用启动时自动执行。 + +- [ ] **Step 3: Commit** + +```bash +git add server/skillhub-app/src/main/resources/db/migration/V4__phase4_audit_governance.sql +git commit -m "feat(db): add V4 migration for audit_log, skill deprecation, account merge" +``` + +--- + +### Task 2.4: Skill 废弃 (Deprecation) 功能 + +**文件:** +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java` +- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillGovernanceController.java` + +**步骤:** + +- [ ] **Step 1: 写失败测试** + +```java +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.audit.AuditService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.Optional; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SkillGovernanceServiceTest { + + private SkillRepository skillRepo; + private AuditService auditService; + private SkillGovernanceService service; + + @BeforeEach + void setUp() { + skillRepo = mock(SkillRepository.class); + auditService = mock(AuditService.class); + service = new SkillGovernanceService(skillRepo, auditService); + } + + @Test + void shouldDeprecateSkill() { + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn("s1"); + when(skillRepo.findById("s1")).thenReturn(Optional.of(skill)); + service.deprecate("s1", "user-1", "Replaced by new-skill"); + verify(skill).deprecate("user-1", "Replaced by new-skill"); + verify(skillRepo).save(skill); + verify(auditService).log(eq("user-1"), eq("SKILL_DEPRECATE"), + eq("s1"), eq("Skill"), anyString()); + } + + @Test + void shouldUndeprecateSkill() { + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn("s1"); + when(skillRepo.findById("s1")).thenReturn(Optional.of(skill)); + service.undeprecate("s1", "user-1"); + verify(skill).undeprecate(); + verify(skillRepo).save(skill); + } + + @Test + void shouldThrowWhenSkillNotFound() { + when(skillRepo.findById("s1")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.deprecate("s1", "u1", "reason")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cd server && mvn test -pl skillhub-domain -Dtest=SkillGovernanceServiceTest` +Expected: COMPILATION FAILURE + +- [ ] **Step 3: 在 Skill Entity 中添加废弃方法** + +在 `Skill.java` 中添加: +```java +private boolean deprecated; +private Instant deprecatedAt; +private String deprecatedBy; +private String deprecationReason; + +public void deprecate(String userId, String reason) { + this.deprecated = true; + this.deprecatedAt = Instant.now(); + this.deprecatedBy = userId; + this.deprecationReason = reason; +} + +public void undeprecate() { + this.deprecated = false; + this.deprecatedAt = null; + this.deprecatedBy = null; + this.deprecationReason = null; +} + +public boolean isDeprecated() { return deprecated; } +public String getDeprecationReason() { return deprecationReason; } +``` + +- [ ] **Step 4: 实现 SkillGovernanceService** + +```java +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.audit.AuditService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillGovernanceService { + + private final SkillRepository skillRepo; + private final AuditService auditService; + + public SkillGovernanceService(SkillRepository skillRepo, AuditService auditService) { + this.skillRepo = skillRepo; + this.auditService = auditService; + } + + @Transactional + public void deprecate(String skillId, String userId, String reason) { + Skill skill = skillRepo.findById(skillId) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillId)); + skill.deprecate(userId, reason); + skillRepo.save(skill); + auditService.log(userId, "SKILL_DEPRECATE", skillId, "Skill", reason); + } + + @Transactional + public void undeprecate(String skillId, String userId) { + Skill skill = skillRepo.findById(skillId) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillId)); + skill.undeprecate(); + skillRepo.save(skill); + auditService.log(userId, "SKILL_UNDEPRECATE", skillId, "Skill", null); + } +} +``` + +- [ ] **Step 5: 运行测试** + +Run: `cd server && mvn test -pl skillhub-domain -Dtest=SkillGovernanceServiceTest` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +git commit -m "feat(governance): add skill deprecation/undeprecation with audit logging" +``` + +--- + +### Task 2.5: Prometheus 指标 + Actuator 配置 + +**文件:** +- `server/skillhub-app/pom.xml` (添加 micrometer 依赖) +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java` +- `server/skillhub-app/src/main/resources/application.yml` (actuator 配置) + +**步骤:** + +- [ ] **Step 1: 添加 Maven 依赖** + +在 `skillhub-app/pom.xml` 的 `` 中添加: +```xml + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + +``` + +- [ ] **Step 2: 配置 Actuator** + +在 `application.yml` 中添加: +```yaml +management: + endpoints: + web: + exposure: + include: health,info,prometheus,metrics + endpoint: + health: + show-details: when-authorized + metrics: + tags: + application: skillhub +``` + +- [ ] **Step 3: 实现自定义指标** + +```java +package com.iflytek.skillhub.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class SkillHubMetrics { + + private final Counter skillPublishCounter; + private final Counter userRegisterCounter; + private final Counter loginSuccessCounter; + private final Counter loginFailureCounter; + + public SkillHubMetrics(MeterRegistry registry) { + this.skillPublishCounter = Counter.builder("skillhub.skill.publish") + .description("Number of skill publications") + .register(registry); + this.userRegisterCounter = Counter.builder("skillhub.user.register") + .description("Number of user registrations") + .register(registry); + this.loginSuccessCounter = Counter.builder("skillhub.auth.login.success") + .description("Successful logins") + .register(registry); + this.loginFailureCounter = Counter.builder("skillhub.auth.login.failure") + .description("Failed logins") + .register(registry); + } + + public void incrementSkillPublish() { skillPublishCounter.increment(); } + public void incrementUserRegister() { userRegisterCounter.increment(); } + public void incrementLoginSuccess() { loginSuccessCounter.increment(); } + public void incrementLoginFailure() { loginFailureCounter.increment(); } +} +``` + +- [ ] **Step 4: 在 Controller 中埋点** + +在 `LocalAuthController` 的 login/register 方法中调用对应 metrics 方法。 +在 `SkillPublishController` 的 publish 方法中调用 `metrics.incrementSkillPublish()`。 + +- [ ] **Step 5: 验证 Actuator 端点** + +启动应用后访问: `GET /actuator/prometheus` +Expected: 返回 Prometheus 格式的指标数据,包含 `skillhub_skill_publish_total` 等。 + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/pom.xml \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java \ + server/skillhub-app/src/main/resources/application.yml +git commit -m "feat(ops): add Prometheus metrics with Actuator and custom counters" +``` + +--- + +### Task 2.6: SkillGovernanceController REST API + +**文件:** +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillGovernanceController.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DeprecateRequest.java` + +**步骤:** + +- [ ] **Step 1: 实现 DTO** + +```java +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record DeprecateRequest( + @NotBlank @Size(max = 500) String reason +) {} +``` + +- [ ] **Step 2: 实现 Controller** + +```java +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.DeprecateRequest; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/skills/{skillId}/governance") +public class SkillGovernanceController extends BaseApiController { + + private final SkillGovernanceService governanceService; + + public SkillGovernanceController(SkillGovernanceService governanceService) { + this.governanceService = governanceService; + } + + @PostMapping("/deprecate") + public ResponseEntity> deprecate( + @PathVariable String skillId, + @Valid @RequestBody DeprecateRequest req, + HttpSession session) { + String userId = (String) session.getAttribute("userId"); + governanceService.deprecate(skillId, userId, req.reason()); + return ok(Map.of("deprecated", true)); + } + + @PostMapping("/undeprecate") + public ResponseEntity> undeprecate( + @PathVariable String skillId, + HttpSession session) { + String userId = (String) session.getAttribute("userId"); + governanceService.undeprecate(skillId, userId); + return ok(Map.of("deprecated", false)); + } +} +``` + +- [ ] **Step 3: 验证编译** + +Run: `cd server && mvn compile -pl skillhub-app` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DeprecateRequest.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillGovernanceController.java +git commit -m "feat(governance): add SkillGovernanceController with deprecate/undeprecate endpoints" +``` + +--- + +## Chunk 3: 性能优化 + 安全加固 + +### Task 3.1: HTTP 缓存头 + ETag 支持 + +**文件:** +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/CacheControlFilter.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/CacheControlFilterTest.java` + +**步骤:** + +- [ ] **Step 1: 写失败测试** + +```java +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.*; + +class CacheControlFilterTest { + + @Test + void shouldAddCacheHeadersForSkillDownload() throws Exception { + CacheControlFilter filter = new CacheControlFilter(); + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(req.getRequestURI()).thenReturn("/api/v1/skills/s1/versions/1.0/download"); + when(req.getMethod()).thenReturn("GET"); + filter.doFilterInternal(req, resp, chain); + verify(resp).setHeader("Cache-Control", "public, max-age=86400, immutable"); + verify(chain).doFilter(req, resp); + } + + @Test + void shouldNotCacheApiMutations() throws Exception { + CacheControlFilter filter = new CacheControlFilter(); + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(req.getRequestURI()).thenReturn("/api/v1/skills"); + when(req.getMethod()).thenReturn("POST"); + filter.doFilterInternal(req, resp, chain); + verify(resp, never()).setHeader(eq("Cache-Control"), anyString()); + verify(chain).doFilter(req, resp); + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=CacheControlFilterTest` +Expected: COMPILATION FAILURE + +- [ ] **Step 3: 实现 CacheControlFilter** + +```java +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + +@Component +public class CacheControlFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest req, + HttpServletResponse resp, + FilterChain chain) + throws ServletException, IOException { + String uri = req.getRequestURI(); + String method = req.getMethod(); + if ("GET".equals(method) && uri.contains("/download")) { + resp.setHeader("Cache-Control", "public, max-age=86400, immutable"); + } else if ("GET".equals(method) && uri.startsWith("/api/")) { + resp.setHeader("Cache-Control", "no-cache"); + } + chain.doFilter(req, resp); + } +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=CacheControlFilterTest` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/CacheControlFilter.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/CacheControlFilterTest.java +git commit -m "feat(perf): add CacheControlFilter with immutable download caching" +``` + +--- + +### Task 3.2: CORS 配置 + Security Headers + +**文件:** +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/CorsConfig.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/SecurityHeadersFilter.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/SecurityHeadersFilterTest.java` + +**步骤:** + +- [ ] **Step 1: 写失败测试** + +```java +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.*; + +class SecurityHeadersFilterTest { + + @Test + void shouldAddSecurityHeaders() throws Exception { + SecurityHeadersFilter filter = new SecurityHeadersFilter(); + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse resp = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + filter.doFilterInternal(req, resp, chain); + verify(resp).setHeader("X-Content-Type-Options", "nosniff"); + verify(resp).setHeader("X-Frame-Options", "DENY"); + verify(resp).setHeader("X-XSS-Protection", "1; mode=block"); + verify(resp).setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + verify(chain).doFilter(req, resp); + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=SecurityHeadersFilterTest` +Expected: COMPILATION FAILURE + +- [ ] **Step 3: 实现 SecurityHeadersFilter** + +```java +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + +@Component +public class SecurityHeadersFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest req, + HttpServletResponse resp, + FilterChain chain) + throws ServletException, IOException { + resp.setHeader("X-Content-Type-Options", "nosniff"); + resp.setHeader("X-Frame-Options", "DENY"); + resp.setHeader("X-XSS-Protection", "1; mode=block"); + resp.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + chain.doFilter(req, resp); + } +} +``` + +- [ ] **Step 4: 实现 CorsConfig** + +```java +package com.iflytek.skillhub.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.List; + +@Configuration +public class CorsConfig { + + @Value("${skillhub.cors.allowed-origins:http://localhost:5173}") + private String allowedOrigins; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of(allowedOrigins.split(","))); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } +} +``` + +- [ ] **Step 5: 运行测试** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=SecurityHeadersFilterTest` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/config/CorsConfig.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/SecurityHeadersFilter.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/SecurityHeadersFilterTest.java +git commit -m "feat(security): add CORS config and security response headers" +``` + +--- + +### Task 3.3: Redis RateLimiter 增强 + 配置外部化 + +**文件:** +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitConfig.java` +- `server/skillhub-app/src/main/resources/application.yml` (rate limit 配置) + +**步骤:** + +- [ ] **Step 1: 实现 RateLimitConfig** + +```java +package com.iflytek.skillhub.ratelimit; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +@ConfigurationProperties(prefix = "skillhub.rate-limit") +public class RateLimitConfig { + + private int defaultMaxRequests = 60; + private int defaultWindowSeconds = 60; + private int publishMaxRequests = 10; + private int publishWindowSeconds = 3600; + + @Bean + public RateLimiter rateLimiter(StringRedisTemplate redisTemplate) { + try { + redisTemplate.getConnectionFactory().getConnection().ping(); + return new RedisSlidingWindowRateLimiter(redisTemplate); + } catch (Exception e) { + return new InMemorySlidingWindowRateLimiter(); + } + } + + public int getDefaultMaxRequests() { return defaultMaxRequests; } + public void setDefaultMaxRequests(int v) { this.defaultMaxRequests = v; } + public int getDefaultWindowSeconds() { return defaultWindowSeconds; } + public void setDefaultWindowSeconds(int v) { this.defaultWindowSeconds = v; } + public int getPublishMaxRequests() { return publishMaxRequests; } + public void setPublishMaxRequests(int v) { this.publishMaxRequests = v; } + public int getPublishWindowSeconds() { return publishWindowSeconds; } + public void setPublishWindowSeconds(int v) { this.publishWindowSeconds = v; } +} +``` + +- [ ] **Step 2: 添加配置项** + +在 `application.yml` 中添加: +```yaml +skillhub: + rate-limit: + default-max-requests: 60 + default-window-seconds: 60 + publish-max-requests: 10 + publish-window-seconds: 3600 + cors: + allowed-origins: http://localhost:5173 +``` + +- [ ] **Step 3: 验证编译** + +Run: `cd server && mvn compile -pl skillhub-app` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitConfig.java \ + server/skillhub-app/src/main/resources/application.yml +git commit -m "feat(ops): externalize rate limit config with Redis/InMemory fallback" +``` + +--- + +### Task 3.4: 请求参数校验 + 全局异常处理增强 + +**文件:** +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java` (增强) +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java` + +**步骤:** + +- [ ] **Step 1: 写失败测试** + +```java +package com.iflytek.skillhub.exception; + +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void shouldHandleValidationErrors() { + MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); + BindingResult br = mock(BindingResult.class); + when(ex.getBindingResult()).thenReturn(br); + when(br.getFieldErrors()).thenReturn(List.of( + new FieldError("req", "username", "must not be blank") + )); + ResponseEntity> resp = handler.handleValidation(ex); + assertThat(resp.getStatusCode().value()).isEqualTo(400); + Map body = resp.getBody(); + assertThat(body.get("code")).isEqualTo(400); + assertThat(body.get("msg")).asString().contains("username"); + } + + @Test + void shouldHandleDomainBadRequest() { + var ex = new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException("bad"); + ResponseEntity> resp = handler.handleDomainBadRequest(ex); + assertThat(resp.getStatusCode().value()).isEqualTo(400); + } + + @Test + void shouldHandleDomainForbidden() { + var ex = new com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException("forbidden"); + ResponseEntity> resp = handler.handleDomainForbidden(ex); + assertThat(resp.getStatusCode().value()).isEqualTo(403); + } +} +``` + +- [ ] **Step 2: 验证测试失败** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=GlobalExceptionHandlerTest` +Expected: COMPILATION FAILURE (新方法不存在) + +- [ ] **Step 3: 增强 GlobalExceptionHandler** + +在现有 `GlobalExceptionHandler.java` 中添加: +```java +@ExceptionHandler(MethodArgumentNotValidException.class) +public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String errors = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .collect(Collectors.joining("; ")); + return ResponseEntity.badRequest().body(Map.of( + "code", 400, "msg", errors, "timestamp", Instant.now().toString())); +} + +@ExceptionHandler(DomainBadRequestException.class) +public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { + return ResponseEntity.badRequest().body(Map.of( + "code", 400, "msg", ex.getMessage(), "timestamp", Instant.now().toString())); +} + +@ExceptionHandler(DomainForbiddenException.class) +public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex) { + return ResponseEntity.status(403).body(Map.of( + "code", 403, "msg", ex.getMessage(), "timestamp", Instant.now().toString())); +} + +@ExceptionHandler(Exception.class) +public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity.status(500).body(Map.of( + "code", 500, "msg", "Internal server error", "timestamp", Instant.now().toString())); +} +``` + +- [ ] **Step 4: 运行测试** + +Run: `cd server && mvn test -pl skillhub-app -Dtest=GlobalExceptionHandlerTest` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +git commit -m "feat(security): enhance GlobalExceptionHandler with validation and domain errors" +``` + +--- + +### Task 3.5: 数据库查询优化 — 索引 + 分页 + +**文件:** +- `server/skillhub-app/src/main/resources/db/migration/V5__performance_indexes.sql` +- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java` (增加分页方法) + +**步骤:** + +- [ ] **Step 1: 创建索引迁移脚本** + +```sql +-- V5__performance_indexes.sql + +-- Skill 查询优化 +CREATE INDEX IF NOT EXISTS idx_skill_namespace_id ON skill(namespace_id); +CREATE INDEX IF NOT EXISTS idx_skill_name ON skill(name); +CREATE INDEX IF NOT EXISTS idx_skill_deprecated ON skill(deprecated) WHERE deprecated = true; + +-- SkillVersion 查询优化 +CREATE INDEX IF NOT EXISTS idx_skill_version_skill_id ON skill_version(skill_id); +CREATE INDEX IF NOT EXISTS idx_skill_version_created ON skill_version(created_at DESC); + +-- NamespaceMember 查询优化 +CREATE INDEX IF NOT EXISTS idx_ns_member_user ON namespace_member(user_id); +CREATE INDEX IF NOT EXISTS idx_ns_member_ns ON namespace_member(namespace_id); + +-- ReviewTask 查询优化 +CREATE INDEX IF NOT EXISTS idx_review_task_status ON review_task(status); +CREATE INDEX IF NOT EXISTS idx_review_task_reviewer ON review_task(reviewer_id); + +-- ApiToken 查询优化 +CREATE INDEX IF NOT EXISTS idx_api_token_user ON api_token(user_id); +CREATE INDEX IF NOT EXISTS idx_api_token_hash ON api_token(token_hash); + +-- 全文搜索优化 +CREATE INDEX IF NOT EXISTS idx_search_doc_tsv ON skill_search_document USING gin(search_vector); +``` + +- [ ] **Step 2: 在 SkillRepository 中添加分页支持** + +```java +// 在 SkillRepository 接口中添加: +Page findByNamespaceId(String namespaceId, Pageable pageable); +Page findByDeprecatedFalse(Pageable pageable); +``` + +- [ ] **Step 3: 验证迁移** + +Run: `cd server && mvn compile -pl skillhub-app` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/resources/db/migration/V5__performance_indexes.sql \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +git commit -m "feat(perf): add database indexes and pagination support" +``` + +--- + +## Chunk 4: Docker 一键启动 + 开源基础设施 + +### Task 4.1: Dockerfile — 多阶段构建 + +**文件:** +- `server/Dockerfile` + +**步骤:** + +- [ ] **Step 1: 创建 Dockerfile** + +```dockerfile +# ---- Build Stage ---- +FROM maven:3.9-eclipse-temurin-21-alpine AS build +WORKDIR /app +COPY pom.xml . +COPY skillhub-domain/pom.xml skillhub-domain/ +COPY skillhub-auth/pom.xml skillhub-auth/ +COPY skillhub-search/pom.xml skillhub-search/ +COPY skillhub-infra/pom.xml skillhub-infra/ +COPY skillhub-app/pom.xml skillhub-app/ +RUN mvn dependency:go-offline -B + +COPY . . +RUN mvn package -DskipTests -B + +# ---- Runtime Stage ---- +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app + +COPY --from=build /app/skillhub-app/target/skillhub-app-*.jar app.jar + +RUN chown -R app:app /app +USER app + +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + +- [ ] **Step 2: 创建 .dockerignore** + +``` +**/target/ +**/.idea/ +**/*.iml +.git/ +``` + +- [ ] **Step 3: 验证构建** + +Run: `cd server && docker build -t skillhub-server:dev .` +Expected: 构建成功 + +- [ ] **Step 4: Commit** + +```bash +git add server/Dockerfile server/.dockerignore +git commit -m "feat(ops): add multi-stage Dockerfile for server" +``` + +--- + +### Task 4.2: Web 前端 Dockerfile + +**文件:** +- `web/Dockerfile` +- `web/nginx.conf` + +**步骤:** + +- [ ] **Step 1: 创建 nginx.conf** + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:8080/api/; + 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; + } + + location /actuator/ { + deny all; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; +} +``` + +- [ ] **Step 2: 创建 Dockerfile** + +```dockerfile +# ---- Build Stage ---- +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# ---- Runtime Stage ---- +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=30s --timeout=3s \ + CMD wget -qO- http://localhost/ || exit 1 +``` + +- [ ] **Step 3: 验证构建** + +Run: `cd web && docker build -t skillhub-web:dev .` +Expected: 构建成功 + +- [ ] **Step 4: Commit** + +```bash +git add web/Dockerfile web/nginx.conf +git commit -m "feat(ops): add multi-stage Dockerfile for web frontend with nginx" +``` + +--- + +### Task 4.3: docker-compose.yml — 一键启动 + +**文件:** +- `docker-compose.yml` + +**步骤:** + +- [ ] **Step 1: 创建 docker-compose.yml** + +```yaml +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: skillhub + POSTGRES_USER: skillhub + POSTGRES_PASSWORD: skillhub + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skillhub"] + interval: 5s + timeout: 3s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + server: + build: ./server + environment: + SPRING_PROFILES_ACTIVE: docker + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: skillhub + DB_USER: skillhub + DB_PASS: skillhub + SPRING_DATA_REDIS_HOST: redis + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + web: + build: ./web + ports: + - "80:80" + depends_on: + - server + +volumes: + pgdata: +``` + +- [ ] **Step 2: 验证启动** + +Run: `docker compose up -d --build` +Expected: 所有服务启动成功,`docker compose ps` 显示 4 个 running 容器 + +- [ ] **Step 3: 验证健康检查** + +Run: `curl http://localhost:8080/actuator/health` +Expected: `{"status":"UP"}` + +Run: `curl http://localhost/` +Expected: 返回前端 HTML 页面 + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(ops): add docker-compose for one-click local deployment" +``` + +--- + +### Task 4.4: Makefile — 常用命令封装 + +**文件:** +- `Makefile` + +**步骤:** + +- [ ] **Step 1: 创建 Makefile** + +```makefile +.PHONY: help dev up down test build clean + +help: ## 显示帮助 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +dev: ## 启动开发环境 (仅 postgres + redis) + docker compose up -d postgres redis + +up: ## 一键启动全部服务 + docker compose up -d --build + +down: ## 停止全部服务 + docker compose down + +test: ## 运行全部后端测试 + cd server && mvn test + +build: ## 构建后端 + cd server && mvn package -DskipTests + +clean: ## 清理构建产物 + cd server && mvn clean + docker compose down -v + +logs: ## 查看服务日志 + docker compose logs -f server + +db-reset: ## 重置数据库 + docker compose down -v + docker compose up -d postgres + @echo "Waiting for postgres..." + @sleep 3 + cd server && mvn flyway:migrate -pl skillhub-app +``` + +- [ ] **Step 2: 验证** + +Run: `make help` +Expected: 显示所有可用命令 + +- [ ] **Step 3: Commit** + +```bash +git add Makefile +git commit -m "feat(ops): add Makefile with dev/up/down/test/build commands" +``` + +--- + +### Task 4.5: README.md — 项目文档 + +**文件:** +- `README.md` + +**步骤:** + +- [ ] **Step 1: 创建 README.md** + +```markdown +# SkillHub + +AI Skill 共享平台 — 发布、发现、管理 AI 技能包。 + +## 快速开始 + +### 前置条件 +- Docker & Docker Compose +- JDK 21+ (开发模式) +- Node.js 20+ (前端开发) + +### 一键启动 + +```bash +make up +``` + +访问: +- 前端: http://localhost +- API: http://localhost:8080 +- Prometheus 指标: http://localhost:8080/actuator/prometheus + +### 开发模式 + +```bash +# 启动基础设施 +make dev + +# 启动后端 +cd server && mvn spring-boot:run + +# 启动前端 +cd web && npm run dev +``` + +### 默认账号 + +| 用户名 | 密码 | 角色 | +|--------|------|------| +| admin | Admin@2026 | 管理员 | +| demo | Demo@2026 | 普通用户 | + +## 项目结构 + +``` +skillhub/ +├── server/ # Spring Boot 后端 +│ ├── skillhub-app/ # 应用层 (Controller, DTO, Config) +│ ├── skillhub-domain/ # 领域层 (Entity, Service, Repository) +│ ├── skillhub-auth/ # 认证授权模块 +│ ├── skillhub-search/ # 搜索模块 +│ └── skillhub-infra/ # 基础设施层 (JPA 实现) +├── web/ # React 前端 +├── docker-compose.yml # 一键部署 +└── Makefile # 常用命令 +``` + +## 常用命令 + +```bash +make help # 查看所有命令 +make test # 运行测试 +make build # 构建项目 +make logs # 查看日志 +make db-reset # 重置数据库 +``` + +## 技术栈 + +- **后端:** Spring Boot 3, JDK 21, PostgreSQL, Redis, Flyway +- **前端:** React, TypeScript, Vite +- **运维:** Docker, Prometheus, Actuator + +## License + +MIT +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add comprehensive README with quickstart guide" +``` + +--- + +### Task 4.6: Prometheus + Grafana 监控栈 (可选) + +**文件:** +- `monitoring/prometheus.yml` +- `monitoring/docker-compose.monitoring.yml` + +**步骤:** + +- [ ] **Step 1: 创建 Prometheus 配置** + +```yaml +# monitoring/prometheus.yml +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'skillhub-server' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['server:8080'] +``` + +- [ ] **Step 2: 创建监控 compose 文件** + +```yaml +# monitoring/docker-compose.monitoring.yml +version: "3.9" + +services: + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_PASSWORD: admin + depends_on: + - prometheus +``` + +- [ ] **Step 3: 验证** + +Run: `cd monitoring && docker compose -f docker-compose.monitoring.yml up -d` +Expected: Prometheus 和 Grafana 启动成功 + +访问: +- Prometheus: http://localhost:9090 +- Grafana: http://localhost:3000 (admin/admin) + +- [ ] **Step 4: Commit** + +```bash +git add monitoring/ +git commit -m "feat(ops): add Prometheus + Grafana monitoring stack" +``` + +--- + +### Task 4.7: 端到端冒烟测试脚本 + +**文件:** +- `scripts/smoke-test.sh` + +**步骤:** + +- [ ] **Step 1: 创建冒烟测试脚本** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 + +check() { + local desc="$1" url="$2" expected="$3" + status=$(curl -s -o /dev/null -w "%{http_code}" "$url") + if [ "$status" = "$expected" ]; then + echo " PASS: $desc (HTTP $status)" + ((PASS++)) + else + echo " FAIL: $desc (expected $expected, got $status)" + ((FAIL++)) + fi +} + +echo "=== SkillHub Smoke Test ===" +echo "Target: $BASE_URL" +echo "" + +# Health +check "Health endpoint" "$BASE_URL/actuator/health" "200" + +# Prometheus metrics +check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" + +# Public API +check "Skill search" "$BASE_URL/api/v1/skills/search?q=test" "200" + +# Auth required +check "Auth required (401)" "$BASE_URL/api/v1/me" "401" + +# Register +REG_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/register" \ + -H "Content-Type: application/json" \ + -d '{"username":"smoketest","password":"Smoke@2026"}') +if [ "$REG_STATUS" = "200" ] || [ "$REG_STATUS" = "409" ]; then + echo " PASS: Register (HTTP $REG_STATUS)" + ((PASS++)) +else + echo " FAIL: Register (got $REG_STATUS)" + ((FAIL++)) +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 +``` + +- [ ] **Step 2: 设置可执行权限** + +Run: `chmod +x scripts/smoke-test.sh` + +- [ ] **Step 3: Commit** + +```bash +git add scripts/smoke-test.sh +git commit -m "feat(ops): add end-to-end smoke test script" +``` + +--- + +## 完成标志 + +所有 Chunk 完成后,执行最终验证: + +- [ ] `make up` — 一键启动全部服务 +- [ ] `make test` — 全部后端测试通过 +- [ ] `./scripts/smoke-test.sh` — 冒烟测试通过 +- [ ] `curl localhost:8080/actuator/prometheus` — 指标正常 +- [ ] 前端页面可正常访问和操作 + +```bash +git tag v0.4.0 +git push origin feature/project-init --tags +``` diff --git a/docs/superpowers/specs/2026-03-12-phase2-namespace-skill-core-design.md b/docs/superpowers/specs/2026-03-12-phase2-namespace-skill-core-design.md index a9d54c62..7f2e8b00 100644 --- a/docs/superpowers/specs/2026-03-12-phase2-namespace-skill-core-design.md +++ b/docs/superpowers/specs/2026-03-12-phase2-namespace-skill-core-design.md @@ -4,6 +4,9 @@ > **前置条件:** Phase 1 全部 3 个 Chunk 完成(后端骨架 + 认证授权 + 前端骨架) +> **重要修订:身份主键约束** +> 用户身份主键全链路统一使用 `string`。本文中涉及 `user_id`、`owner_id`、`created_by`、`updated_by`、`submitted_by`、`reviewed_by` 等用户关联字段时,均应按字符串类型实现,任何整型用户主键描述都不再有效。 + ## 关键设计决策 | 决策 | 选择 | 理由 | @@ -40,7 +43,7 @@ Phase 1 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | slug | VARCHAR(128) NOT NULL | URL 友好标识,来自 SKILL.md name | | display_name | VARCHAR(256) | | | summary | VARCHAR(512) | | -| owner_id | BIGINT NOT NULL FK → user_account | 主要维护人 | +| owner_id | VARCHAR(128) NOT NULL FK → user_account | 主要维护人 | | source_skill_id | BIGINT | 派生来源(团队提升到全局时记录) | | visibility | VARCHAR(32) NOT NULL DEFAULT 'PUBLIC' | PUBLIC / NAMESPACE_ONLY / PRIVATE | | status | VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' | ACTIVE / HIDDEN / ARCHIVED | @@ -49,9 +52,9 @@ Phase 1 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | star_count | INT NOT NULL DEFAULT 0 | | | rating_avg | DECIMAL(3,2) NOT NULL DEFAULT 0.00 | | | rating_count | INT NOT NULL DEFAULT 0 | | -| created_by | BIGINT FK → user_account | | +| created_by | VARCHAR(128) FK → user_account | | | created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | -| updated_by | BIGINT FK → user_account | | +| updated_by | VARCHAR(128) FK → user_account | | | updated_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | 索引: @@ -72,7 +75,7 @@ Phase 1 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | file_count | INT NOT NULL DEFAULT 0 | | | total_size | BIGINT NOT NULL DEFAULT 0 | 总字节数 | | published_at | TIMESTAMP | 发布时间 | -| created_by | BIGINT FK → user_account | | +| created_by | VARCHAR(128) FK → user_account | | | created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | 索引: @@ -103,7 +106,7 @@ Phase 1 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | skill_id | BIGINT NOT NULL FK → skill | | | tag_name | VARCHAR(64) NOT NULL | 标签名 | | version_id | BIGINT NOT NULL FK → skill_version | 指向的版本 | -| created_by | BIGINT FK → user_account | | +| created_by | VARCHAR(128) FK → user_account | | | created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | | updated_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | @@ -118,7 +121,7 @@ Phase 1 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | skill_id | BIGINT NOT NULL UNIQUE FK → skill | 一 skill 一条 | | namespace_id | BIGINT NOT NULL | 用于空间过滤 | | namespace_slug | VARCHAR(64) NOT NULL | 冗余,搜索结果直接返回无需 join | -| owner_id | BIGINT NOT NULL | 用于 PRIVATE 可见性判定 | +| owner_id | VARCHAR(128) NOT NULL | 用于 PRIVATE 可见性判定 | | title | VARCHAR(256) | | | summary | VARCHAR(512) | | | keywords | VARCHAR(512) | | @@ -280,7 +283,7 @@ listMembers(namespaceId, page, size) → Page getMemberRole(namespaceId, userId) → Optional ``` -> **Repository 补充** — Phase 1 的 `NamespaceRepository` 需新增 `Page findByStatus(NamespaceStatus status, Pageable pageable)` 方法。`NamespaceMemberRepository` 需新增 `Page findByNamespaceId(Long namespaceId, Pageable pageable)` 和 `void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId)` 方法。 +> **Repository 补充** — Phase 1 的 `NamespaceRepository` 需新增 `Page findByStatus(NamespaceStatus status, Pageable pageable)` 方法。`NamespaceMemberRepository` 需新增 `Page findByNamespaceId(Long namespaceId, Pageable pageable)` 和 `void deleteByNamespaceIdAndUserId(Long namespaceId, String userId)` 方法。 ### 3.2 Slug 校验规则 @@ -430,7 +433,7 @@ public interface PrePublishValidator { public record SkillPackageContext( List entries, SkillMetadata metadata, - Long publisherId, + String publisherId, Long namespaceId ) {} @@ -598,7 +601,7 @@ public class VisibilityChecker { * @param currentUser 当前用户(null 表示匿名) * @param userNamespaceRoles 用户在各 namespace 的角色(预加载) */ - public boolean canAccess(Skill skill, Long currentUserId, + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { return switch (skill.getVisibility()) { case PUBLIC -> true; @@ -836,7 +839,7 @@ Response: ### 7.1 事件定义(`domain.event` 包) ```java -public record SkillPublishedEvent(Long skillId, Long versionId, Long publisherId) {} +public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {} public record SkillDownloadedEvent(Long skillId, Long versionId) {} public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) {} ``` diff --git a/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md b/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md index ede35961..08316d4d 100644 --- a/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md +++ b/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md @@ -4,6 +4,9 @@ > **前置条件:** Phase 1 完成(工程骨架 + 认证授权)+ Phase 2 完成(命名空间 + 技能核心链路) +> **重要修订:身份主键约束** +> 用户身份主键全链路统一使用 `string`。本文中出现的 `submitted_by`、`reviewed_by`、`user_id`、`owner_id`、`actor_user_id` 等用户关联字段都应按字符串设计,任何整型用户主键描述都不再有效。 + ## 关键设计决策 | 决策点 | 选择 | 理由 | @@ -40,8 +43,8 @@ Phase 2 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | namespace_id | BIGINT NOT NULL FK → namespace | 所属空间(决定谁能审核) | | status | VARCHAR(32) NOT NULL DEFAULT 'PENDING' | PENDING / APPROVED / REJECTED | | version | INT NOT NULL DEFAULT 1 | 乐观锁版本号 | -| submitted_by | BIGINT NOT NULL FK → user_account | 提交人 | -| reviewed_by | BIGINT FK → user_account | 审核人 | +| submitted_by | VARCHAR(128) NOT NULL FK → user_account | 提交人 | +| reviewed_by | VARCHAR(128) FK → user_account | 审核人 | | review_comment | TEXT | 审核意见 | | submitted_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | | reviewed_at | TIMESTAMP | | @@ -66,8 +69,8 @@ Phase 2 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | target_skill_id | BIGINT FK → skill | 审批通过后生成的全局 skill ID | | status | VARCHAR(32) NOT NULL DEFAULT 'PENDING' | PENDING / APPROVED / REJECTED | | version | INT NOT NULL DEFAULT 1 | 乐观锁版本号 | -| submitted_by | BIGINT NOT NULL FK → user_account | 提交人 | -| reviewed_by | BIGINT FK → user_account | 审核人 | +| submitted_by | VARCHAR(128) NOT NULL FK → user_account | 提交人 | +| reviewed_by | VARCHAR(128) FK → user_account | 审核人 | | review_comment | TEXT | 审核意见 | | submitted_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | | reviewed_at | TIMESTAMP | | @@ -87,7 +90,7 @@ Phase 2 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe |------|------|------| | id | BIGSERIAL PK | | | skill_id | BIGINT NOT NULL FK → skill | | -| user_id | BIGINT NOT NULL FK → user_account | | +| user_id | VARCHAR(128) NOT NULL FK → user_account | | | created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | 索引: @@ -101,7 +104,7 @@ Phase 2 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe |------|------|------| | id | BIGSERIAL PK | | | skill_id | BIGINT NOT NULL FK → skill | | -| user_id | BIGINT NOT NULL FK → user_account | | +| user_id | VARCHAR(128) NOT NULL FK → user_account | | | score | SMALLINT NOT NULL CHECK (score >= 1 AND score <= 5) | 1-5 分 | | created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | | updated_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | @@ -281,7 +284,7 @@ public class ReviewPermissionChecker { /** * 检查用户是否有权审核指定的 review_task */ - public boolean canReview(ReviewTask task, Long userId, + public boolean canReview(ReviewTask task, String userId, Map userNamespaceRoles, Set platformRoles) { // 不能审核自己提交的 @@ -303,7 +306,7 @@ public class ReviewPermissionChecker { /** * 检查用户是否有权审核提升请求 */ - public boolean canReviewPromotion(PromotionRequest request, Long userId, + public boolean canReviewPromotion(PromotionRequest request, String userId, Set platformRoles) { // 只有平台 SKILL_ADMIN 或 SUPER_ADMIN 可以审核提升请求 return platformRoles.contains("SKILL_ADMIN") @@ -331,7 +334,7 @@ public class ReviewPermissionChecker { public class ReviewService { @Transactional - public void approveReview(Long reviewTaskId, Long reviewerId, String comment) { + public void approveReview(Long reviewTaskId, String reviewerId, String comment) { // 1. 加载 review_task(带 version) ReviewTask task = reviewTaskRepository.findById(reviewTaskId) .orElseThrow(() -> new NotFoundException("Review task not found")); @@ -394,7 +397,7 @@ public interface ReviewTaskRepository extends JpaRepository { int updateStatusWithVersion( @Param("id") Long id, @Param("status") ReviewTaskStatus status, - @Param("reviewerId") Long reviewerId, + @Param("reviewerId") String reviewerId, @Param("comment") String comment, @Param("expectedVersion") Integer expectedVersion ); @@ -422,7 +425,7 @@ public class SkillStarService { * 收藏技能 */ @Transactional - public void starSkill(Long skillId, Long userId) { + public void starSkill(Long skillId, String userId) { // 1. 检查技能存在性和可见性 Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new NotFoundException("Skill not found")); @@ -448,7 +451,7 @@ public class SkillStarService { * 取消收藏 */ @Transactional - public void unstarSkill(Long skillId, Long userId) { + public void unstarSkill(Long skillId, String userId) { int deleted = skillStarRepository.deleteBySkillIdAndUserId(skillId, userId); if (deleted > 0) { @@ -460,14 +463,14 @@ public class SkillStarService { /** * 检查是否已收藏 */ - public boolean isStarred(Long skillId, Long userId) { + public boolean isStarred(Long skillId, String userId) { return skillStarRepository.existsBySkillIdAndUserId(skillId, userId); } /** * 获取用户的收藏列表 */ - public Page getStarredSkills(Long userId, Pageable pageable) { + public Page getStarredSkills(String userId, Pageable pageable) { return skillStarRepository.findStarredSkillsByUserId(userId, pageable); } } @@ -521,7 +524,7 @@ public class SkillRatingService { * 提交评分(新增或更新) */ @Transactional - public void rateSkill(Long skillId, Long userId, int score) { + public void rateSkill(Long skillId, String userId, int score) { // 1. 校验评分范围 if (score < 1 || score > 5) { throw new IllegalArgumentException("Score must be between 1 and 5"); @@ -551,7 +554,7 @@ public class SkillRatingService { /** * 获取用户对技能的评分 */ - public Optional getUserRating(Long skillId, Long userId) { + public Optional getUserRating(Long skillId, String userId) { return skillRatingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } @@ -602,7 +605,7 @@ public class SkillRatingEventListener { @Repository public interface SkillRatingRepository extends JpaRepository { - Optional findBySkillIdAndUserId(Long skillId, Long userId); + Optional findBySkillIdAndUserId(Long skillId, String userId); @Query(""" SELECT new com.iflytek.skillhub.domain.skill.RatingStats( @@ -708,7 +711,7 @@ public class DeviceAuthService { /** * 用户授权 device code */ - public void authorizeDeviceCode(String userCode, Long userId) { + public void authorizeDeviceCode(String userCode, String userId) { // 1. 通过 user_code 查找 device_code String deviceCode = findDeviceCodeByUserCode(userCode); if (deviceCode == null) { diff --git a/docs/superpowers/specs/2026-03-12-phase4-ops-polish-design.md b/docs/superpowers/specs/2026-03-12-phase4-ops-polish-design.md index e3435f9b..1126494d 100644 --- a/docs/superpowers/specs/2026-03-12-phase4-ops-polish-design.md +++ b/docs/superpowers/specs/2026-03-12-phase4-ops-polish-design.md @@ -4,6 +4,9 @@ > **前置条件:** Phase 1 完成(工程骨架 + 认证授权)+ Phase 2 完成(命名空间 + 技能核心链路)+ Phase 3 完成(审核流程 + CLI API + 评分收藏 + 兼容层) +> **重要修订:身份主键约束** +> 用户身份主键全链路统一使用 `string`。本文中出现的 `user_id`、`primary_user_id`、`secondary_user_id`、`hidden_by`、`yanked_by`、`actor_user_id` 等用户关联字段都应按字符串设计,任何整型用户主键描述都不再有效。 + ## 关键设计决策 | 决策点 | 选择 | 理由 | @@ -38,7 +41,7 @@ Phase 3 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | 字段 | 类型 | 说明 | |------|------|------| | id | BIGSERIAL PK | | -| user_id | BIGINT NOT NULL FK → user_account | 关联用户 | +| user_id | VARCHAR(128) NOT NULL FK → user_account | 关联用户 | | username | VARCHAR(64) NOT NULL UNIQUE | 登录用户名(字母数字下划线,3-64 字符) | | password_hash | VARCHAR(255) NOT NULL | BCrypt 哈希值 | | failed_attempts | INT NOT NULL DEFAULT 0 | 连续失败次数 | @@ -55,8 +58,8 @@ Phase 3 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe | 字段 | 类型 | 说明 | |------|------|------| | id | BIGSERIAL PK | | -| primary_user_id | BIGINT NOT NULL FK → user_account | 主账号(保留) | -| secondary_user_id | BIGINT NOT NULL FK → user_account | 副账号(合并后停用) | +| primary_user_id | VARCHAR(128) NOT NULL FK → user_account | 主账号(保留) | +| secondary_user_id | VARCHAR(128) NOT NULL FK → user_account | 副账号(合并后停用) | | status | VARCHAR(32) NOT NULL DEFAULT 'PENDING' | PENDING / VERIFIED / COMPLETED / CANCELLED | | verification_token | VARCHAR(255) | 副账号验证令牌(BCrypt 哈希存储) | | token_expires_at | TIMESTAMP | 令牌过期时间(30 分钟) | @@ -75,7 +78,7 @@ Phase 3 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `pe ```sql ALTER TABLE skill ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE skill ADD COLUMN hidden_at TIMESTAMP; -ALTER TABLE skill ADD COLUMN hidden_by BIGINT REFERENCES user_account(id); +ALTER TABLE skill ADD COLUMN hidden_by VARCHAR(128) REFERENCES user_account(id); CREATE INDEX idx_skill_hidden ON skill(hidden) WHERE hidden = TRUE; ``` @@ -87,7 +90,7 @@ CREATE INDEX idx_skill_hidden ON skill(hidden) WHERE hidden = TRUE; -- YANKED 状态的版本:精确版本号仍可下载,但不出现在版本列表和搜索结果中 -- 借鉴 crates.io 语义:yank 不是删除,是标记"不推荐" ALTER TABLE skill_version ADD COLUMN yanked_at TIMESTAMP; -ALTER TABLE skill_version ADD COLUMN yanked_by BIGINT REFERENCES user_account(id); +ALTER TABLE skill_version ADD COLUMN yanked_by VARCHAR(128) REFERENCES user_account(id); ALTER TABLE skill_version ADD COLUMN yank_reason TEXT; ``` @@ -1548,4 +1551,4 @@ Phase 4 在 Phase 1-3 的基础上,完成运维增强、安全加固和开源 **交付策略:** - 4 个 Chunk 渐进式交付 - Chunk 1(认证)→ Chunk 2(治理)→ Chunk 3(性能安全)→ Chunk 4(部署开源) -- 每个 Chunk 独立可验收,风险可控 \ No newline at end of file +- 每个 Chunk 独立可验收,风险可控