mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
docs: update specs, plans, and design documents for Phase 2-4
This commit is contained in:
parent
b7a798914a
commit
9d7282221c
13 changed files with 3582 additions and 242 deletions
|
|
@ -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}`。
|
||||
|
|
|
|||
|
|
@ -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) | |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public record SearchVisibilityScope(
|
|||
boolean includeAllPublic, // 是否包含所有 PUBLIC 技能
|
||||
Set<Long> memberNamespaceIds, // 用户是 MEMBER 的 namespace(可见 NAMESPACE_ONLY)
|
||||
Set<Long> 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) | |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 等问题被假实现掩盖
|
||||
|
|
|
|||
|
|
@ -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<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, Long userId);
|
||||
List<NamespaceMember> findByUserId(Long userId);
|
||||
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
|
||||
List<NamespaceMember> findByUserId(String userId);
|
||||
NamespaceMember save(NamespaceMember member);
|
||||
}
|
||||
```
|
||||
|
|
@ -1663,8 +1663,8 @@ import java.util.Optional;
|
|||
@Repository
|
||||
public interface NamespaceMemberJpaRepository
|
||||
extends JpaRepository<NamespaceMember, Long>, NamespaceMemberRepository {
|
||||
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, Long userId);
|
||||
List<NamespaceMember> findByUserId(Long userId);
|
||||
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
|
||||
List<NamespaceMember> 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<ApiToken, Long> {
|
||||
Optional<ApiToken> findByTokenHash(String tokenHash);
|
||||
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(Long userId);
|
||||
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
|
||||
}
|
||||
|
||||
// RoleRepository.java
|
||||
|
|
@ -2028,7 +2028,7 @@ import java.util.List;
|
|||
|
||||
@Repository
|
||||
public interface UserRoleBindingRepository extends JpaRepository<UserRoleBinding, Long> {
|
||||
List<UserRoleBinding> findByUserId(Long userId);
|
||||
List<UserRoleBinding> 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<String> scopes,
|
||||
public String createToken(String userId, String name, List<String> scopes,
|
||||
LocalDateTime expiresAt) {
|
||||
byte[] randomBytes = new byte[32];
|
||||
RANDOM.nextBytes(randomBytes);
|
||||
|
|
@ -2682,12 +2682,12 @@ public class ApiTokenService {
|
|||
});
|
||||
}
|
||||
|
||||
public List<ApiToken> listByUser(Long userId) {
|
||||
public List<ApiToken> 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<NamespaceMember> member = namespaceMemberRepo
|
||||
.findByNamespaceIdAndUserId(namespaceId, userId);
|
||||
return member.map(m -> m.getRole().ordinal() <= minRole.ordinal()).orElse(false);
|
||||
}
|
||||
|
||||
/** 获取用户在指定命名空间的角色 */
|
||||
public Optional<NamespaceRole> getNamespaceRole(Long userId, Long namespaceId) {
|
||||
public Optional<NamespaceRole> getNamespaceRole(String userId, Long namespaceId) {
|
||||
return namespaceMemberRepo.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.map(NamespaceMember::getRole);
|
||||
}
|
||||
|
||||
/** 获取用户所有平台角色码 */
|
||||
public Set<String> getPlatformRoleCodes(Long userId) {
|
||||
public Set<String> 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
|
||||
|
|
|
|||
|
|
@ -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<NamespaceMember> 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<NamespaceMember> 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<PackageEntry> 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<Long, NamespaceRole> 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<NamespaceRole> getMemberRole(Long namespaceId, Long userId) {
|
||||
public Optional<NamespaceRole> 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<String, String> 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<String, String> 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<PackageEntry> 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<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace ns = findNamespace(namespaceSlug);
|
||||
Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug)
|
||||
|
|
@ -3826,7 +3828,7 @@ public class SkillQueryService {
|
|||
}
|
||||
|
||||
public Page<Skill> listSkillsByNamespace(String namespaceSlug,
|
||||
Long currentUserId,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<PackageEntry> 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<PackageEntry> 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<Long> memberNamespaceIds,
|
||||
Set<Long> 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 (
|
||||
<div className="border rounded-lg">
|
||||
|
|
|
|||
|
|
@ -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<ReviewTask> findById(Long id);
|
||||
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
|
||||
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(Long submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> 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<ReviewTask, Long>
|
|||
|
||||
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(Long submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
|
|
@ -419,7 +421,7 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
|
|||
""")
|
||||
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<PromotionRequest> findById(Long id);
|
||||
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
|
||||
Page<PromotionRequest> 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<PromotionRe
|
|||
""")
|
||||
int updateStatusWithVersion(@Param("id") Long id,
|
||||
@Param("status") ReviewTaskStatus status,
|
||||
@Param("reviewedBy") Long reviewedBy,
|
||||
@Param("reviewedBy") String reviewedBy,
|
||||
@Param("reviewComment") String reviewComment,
|
||||
@Param("targetSkillId") Long targetSkillId,
|
||||
@Param("expectedVersion") Integer expectedVersion);
|
||||
|
|
@ -534,7 +536,7 @@ class ReviewPermissionCheckerTest {
|
|||
|
||||
@Test
|
||||
void cannotReviewOwnSubmission() {
|
||||
Long userId = 1L;
|
||||
String userId = 1L;
|
||||
ReviewTask task = createTask(1L, NamespaceType.TEAM, userId);
|
||||
|
||||
boolean canReview = checker.canReview(task, userId, Map.of(), Set.of());
|
||||
|
|
@ -572,7 +574,7 @@ class ReviewPermissionCheckerTest {
|
|||
assertFalse(canReview, "SKILL_ADMIN cannot review team skill");
|
||||
}
|
||||
|
||||
private ReviewTask createTask(Long namespaceId, NamespaceType type, Long submittedBy) {
|
||||
private ReviewTask createTask(Long namespaceId, NamespaceType type, String submittedBy) {
|
||||
// Mock ReviewTask with namespace info
|
||||
return new ReviewTask(1L, namespaceId, submittedBy);
|
||||
}
|
||||
|
|
@ -600,7 +602,7 @@ import java.util.Set;
|
|||
@Component
|
||||
public class ReviewPermissionChecker {
|
||||
|
||||
public boolean canReview(ReviewTask task, Long userId,
|
||||
public boolean canReview(ReviewTask task, String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> 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<String> 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<PromotionReque
|
|||
Page<PromotionRequest> findByTargetNamespaceAndStatus(Long targetNamespaceId, PromotionStatus status, Pageable pageable);
|
||||
|
||||
@Query("SELECT pr FROM PromotionRequest pr WHERE pr.submittedBy = :userId")
|
||||
Page<PromotionRequest> findBySubmittedBy(Long userId, Pageable pageable);
|
||||
Page<PromotionRequest> 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<PromotionRequest> listMyPromotions(Long userId, Pageable pageable) {
|
||||
public Page<PromotionRequest> 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<ReviewTaskResponse> 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<ReviewTaskResponse> 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<ReviewTaskResponse> 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<ReviewTaskResponse> 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<ReviewTask> tasks;
|
||||
if (namespace != null) {
|
||||
|
|
@ -1551,7 +1553,7 @@ public class ReviewController {
|
|||
public ResponseEntity<Page<ReviewTaskResponse>> listMySubmissions(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") Long userId) {
|
||||
@RequestAttribute("userId") String userId) {
|
||||
|
||||
Page<ReviewTask> 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<ReviewTaskResponse> 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<PromotionResponseDto> 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<PromotionResponseDto> 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<PromotionResponseDto> 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<Page<PromotionResponseDto>> 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<PromotionResponseDto> 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<SkillStar> findBySkillIdAndUserId(Long skillId, Long userId);
|
||||
Optional<SkillStar> findBySkillIdAndUserId(Long skillId, String userId);
|
||||
void delete(SkillStar star);
|
||||
Page<SkillStar> findByUserId(Long userId, Pageable pageable);
|
||||
Page<SkillStar> 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<SkillRating> findBySkillIdAndUserId(Long skillId, Long userId);
|
||||
Optional<SkillRating> 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<SkillStar, Long>, SkillStarRepository {
|
||||
Optional<SkillStar> findBySkillIdAndUserId(Long skillId, Long userId);
|
||||
Page<SkillStar> findByUserId(Long userId, Pageable pageable);
|
||||
Optional<SkillStar> findBySkillIdAndUserId(Long skillId, String userId);
|
||||
Page<SkillStar> findByUserId(String userId, Pageable pageable);
|
||||
long countBySkillId(Long skillId);
|
||||
}
|
||||
```
|
||||
|
|
@ -3228,7 +3230,7 @@ import java.util.Optional;
|
|||
|
||||
@Repository
|
||||
public interface JpaSkillRatingRepository extends JpaRepository<SkillRating, Long>, SkillRatingRepository {
|
||||
Optional<SkillRating> findBySkillIdAndUserId(Long skillId, Long userId);
|
||||
Optional<SkillRating> 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<Short> getUserRating(Long skillId, Long userId) {
|
||||
public Optional<Short> getUserRating(Long skillId, String userId) {
|
||||
return ratingRepository.findBySkillIdAndUserId(skillId, userId)
|
||||
.map(SkillRating::getScore);
|
||||
}
|
||||
|
|
@ -3781,21 +3783,21 @@ public class SkillStarController {
|
|||
|
||||
@PutMapping
|
||||
public ResponseEntity<Void> star(@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal Long userId) {
|
||||
@AuthenticationPrincipal String userId) {
|
||||
starService.star(skillId, userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
public ResponseEntity<Void> unstar(@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal Long userId) {
|
||||
@AuthenticationPrincipal String userId) {
|
||||
starService.unstar(skillId, userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Boolean> 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<Void> rate(@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestBody Map<String, Integer> 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<Short> 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<Void> authorizeDevice(
|
||||
@RequestBody Map<String, String> 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<Void> updateUserRoles(
|
||||
@PathVariable Long userId,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, List<String>> body) {
|
||||
// TODO: 更新用户平台角色
|
||||
return ResponseEntity.noContent().build();
|
||||
|
|
@ -7256,7 +7258,7 @@ public class UserManagementController {
|
|||
|
||||
@PutMapping("/{userId}/status")
|
||||
public ResponseEntity<Void> updateUserStatus(
|
||||
@PathVariable Long userId,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, String> 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`
|
||||
|
||||
|
|
|
|||
3204
docs/superpowers/plans/2026-03-12-phase4-ops-polish.md
Normal file
3204
docs/superpowers/plans/2026-03-12-phase4-ops-polish.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<NamespaceMember>
|
|||
getMemberRole(namespaceId, userId) → Optional<NamespaceRole>
|
||||
```
|
||||
|
||||
> **Repository 补充** — Phase 1 的 `NamespaceRepository` 需新增 `Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable)` 方法。`NamespaceMemberRepository` 需新增 `Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable)` 和 `void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId)` 方法。
|
||||
> **Repository 补充** — Phase 1 的 `NamespaceRepository` 需新增 `Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable)` 方法。`NamespaceMemberRepository` 需新增 `Page<NamespaceMember> 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<PackageEntry> 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<Long, NamespaceRole> 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) {}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
// 不能审核自己提交的
|
||||
|
|
@ -303,7 +306,7 @@ public class ReviewPermissionChecker {
|
|||
/**
|
||||
* 检查用户是否有权审核提升请求
|
||||
*/
|
||||
public boolean canReviewPromotion(PromotionRequest request, Long userId,
|
||||
public boolean canReviewPromotion(PromotionRequest request, String userId,
|
||||
Set<String> 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<ReviewTask, Long> {
|
|||
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<Skill> getStarredSkills(Long userId, Pageable pageable) {
|
||||
public Page<Skill> 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<Integer> getUserRating(Long skillId, Long userId) {
|
||||
public Optional<Integer> 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<SkillRating, Long> {
|
||||
|
||||
Optional<SkillRating> findBySkillIdAndUserId(Long skillId, Long userId);
|
||||
Optional<SkillRating> 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) {
|
||||
|
|
|
|||
|
|
@ -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 独立可验收,风险可控
|
||||
- 每个 Chunk 独立可验收,风险可控
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue