docs: update specs, plans, and design documents for Phase 2-4

This commit is contained in:
vsxd 2026-03-12 17:46:41 +08:00
parent b7a798914a
commit 9d7282221c
13 changed files with 3582 additions and 242 deletions

View file

@ -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}`

View file

@ -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 IDnullable |
| 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 IDnullable |
| 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) | |

View file

@ -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

View file

@ -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) | |

View file

@ -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 APIOAuth2 登录相关)
| 方法 | 路径 | 说明 |
@ -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"
}
```

View file

@ -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 等问题被假实现掩盖

View file

@ -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

View file

@ -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">

View file

@ -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`

File diff suppressed because it is too large Load diff

View file

@ -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) {}
```

View file

@ -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) {

View file

@ -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 独立可验收,风险可控