diff --git a/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md b/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md new file mode 100644 index 00000000..002b2ca8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md @@ -0,0 +1,2060 @@ +# Phase 3: 审核流程 + CLI API + 评分收藏 + 兼容层 设计文档 + +> **Goal:** 在 Phase 2 命名空间和技能核心链路基础上,建立完整的治理体系、CLI 生态和社交功能。实现审核流程、团队技能提升、评分收藏、CLI API、ClawHub 兼容层、幂等去重和管理后台。 + +> **前置条件:** Phase 1 完成(工程骨架 + 认证授权)+ Phase 2 完成(命名空间 + 技能核心链路) + +## 关键设计决策 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 审核模式 | 严格审核(所有版本都需审核) | 企业内部平台治理需求,确保技能质量 | +| 审核权限 | 严格分级(团队自治,平台不越权) | 保持团队自治性,平台管理员只管全局空间和提升审核 | +| 审核并发控制 | 乐观锁(version 字段) + partial unique index | 防止多 Pod 并发审核同一任务 | +| CLI 认证 | OAuth Device Flow(Web 授权) | 现代 CLI 标准做法,用户体验最佳 | +| 兼容层范围 | 核心操作(search、resolve、download、publish、whoami) | 平衡兼容性和实现复杂度 | +| 评分收藏 | 仅登录用户可用 | 确保数据可信度,避免刷分/刷收藏 | +| 幂等去重 | Redis SETNX + PostgreSQL idempotency_record | 双层防护,Redis 快速去重,PostgreSQL 持久化兜底 | +| 实施策略 | 审核优先 + 渐进式 CLI(5 个 Chunk) | 渐进式交付,风险可控 | + +## Tech Stack(沿用 Phase 1/2 + 新增) + +- 沿用:Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Security + Spring Data JPA + Flyway +- 沿用前端:React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui + Tailwind CSS +- 新增前端:react-rating-stars-component(评分组件) + +--- + +## 1. 数据库迁移(V3__phase3_review_social_tables.sql) + +Phase 2 已有表:`user_account`, `identity_binding`, `api_token`, `role`, `permission`, `role_permission`, `user_role_binding`, `namespace`, `namespace_member`, `audit_log`, `skill`, `skill_version`, `skill_file`, `skill_tag`, `skill_search_document` + +### 1.1 新增表 + +#### review_task(发布审核任务) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGSERIAL PK | | +| skill_version_id | BIGINT NOT NULL FK → skill_version | 关联的版本 | +| 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 | 审核人 | +| review_comment | TEXT | 审核意见 | +| submitted_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | +| reviewed_at | TIMESTAMP | | + +索引: +- `(namespace_id, status)` — 审核列表 +- `(submitted_by, status)` — 我的提交 +- `(skill_version_id) WHERE status = 'PENDING'` — partial unique index,防止重复提交 + +业务约束: +- 同一 `skill_version_id` 在 `status=PENDING` 时只能存在一条记录 +- 撤回时物理删除 review_task 记录,依赖 partial unique index 防并发 + +#### promotion_request(提升审核任务) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGSERIAL PK | | +| source_skill_id | BIGINT NOT NULL FK → skill | 来源团队 skill | +| source_version_id | BIGINT NOT NULL FK → skill_version | 申请提升的版本 | +| target_namespace_id | BIGINT NOT NULL FK → namespace | 目标全局 namespace | +| 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 | 审核人 | +| review_comment | TEXT | 审核意见 | +| submitted_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | +| reviewed_at | TIMESTAMP | | + +索引: +- `(source_skill_id)` — 按来源 skill 查询 +- `(status)` — 待审核列表 +- `(source_version_id) WHERE status = 'PENDING'` — partial unique index,防止重复提交 + +业务约束: +- 同一 `source_version_id` 在 `status=PENDING` 时只能存在一条记录 +- 审批通过后填充 `target_skill_id` + +#### skill_star(技能收藏) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGSERIAL PK | | +| skill_id | BIGINT NOT NULL FK → skill | | +| user_id | BIGINT NOT NULL FK → user_account | | +| created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | + +索引: +- `(skill_id, user_id)` UNIQUE — 唯一约束 +- `(user_id)` — 我的收藏 +- `(skill_id)` — 技能收藏数 + +#### skill_rating(技能评分) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGSERIAL PK | | +| skill_id | BIGINT NOT NULL FK → skill | | +| user_id | BIGINT 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 | | + +索引: +- `(skill_id, user_id)` UNIQUE — 唯一约束,每人每技能一条 +- `(skill_id)` — 评分聚合 + +#### idempotency_record(幂等记录) + +| 字段 | 类型 | 说明 | +|------|------|------| +| request_id | VARCHAR(64) PK | 客户端传入的 UUID v4 | +| resource_type | VARCHAR(64) NOT NULL | 如 skill_version, api_token | +| resource_id | BIGINT | 业务操作产生的资源 ID | +| status | VARCHAR(32) NOT NULL | PROCESSING / COMPLETED / FAILED | +| response_status_code | INT | 原始响应状态码 | +| created_at | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | | +| expires_at | TIMESTAMP NOT NULL | 过期时间(默认 24h) | + +索引: +- `(expires_at)` — 过期清理 +- `(status, created_at)` — 僵死任务检测 + +### 1.2 Phase 2 表结构调整 + +Phase 2 设计中 `skill_version.status` 直接到 PUBLISHED,Phase 3 需要调整为审核流程: + +**skill_version 表无需修改** - status 枚举已包含 DRAFT / PENDING_REVIEW / PUBLISHED / REJECTED / YANKED + +**Phase 2 → Phase 3 迁移策略:** +- Phase 2 发布流程:上传 → DRAFT → 自动提交 → PENDING_REVIEW → 自动通过 → PUBLISHED +- Phase 3 发布流程:上传 → DRAFT → 提交审核 → PENDING_REVIEW → 人工审核 → PUBLISHED/REJECTED + +Phase 2 代码中的自动审核逻辑在 Phase 3 移除,改为创建 review_task 等待人工审核。 + +--- + +## 2. 审核流程设计 + +### 2.1 普通发布审核流程 + +``` +用户发布技能(Phase 2 已实现) + │ + ▼ +① skill_version 创建(status=DRAFT) + │ + ▼ +② 用户提交审核(POST /api/v1/skills/{namespace}/{slug}/versions/{version}/submit) + │ + ▼ +③ 创建 review_task + - skill_version.status → PENDING_REVIEW + - INSERT INTO review_task (skill_version_id, namespace_id, status=PENDING, submitted_by) + - 检查 partial unique index 防止重复提交 + │ + ▼ +④ 审核人审核(PUT /api/v1/review-tasks/{id}/approve 或 /reject) + ├── 通过 → + │ ① 乐观锁更新:UPDATE review_task SET status='APPROVED', reviewed_by=?, reviewed_at=?, version=version+1 WHERE id=? AND version=? + │ ② skill_version.status → PUBLISHED + │ ③ 更新 skill.latest_version_id(如果是最新版本) + │ ④ 发布 SkillPublishedEvent(触发搜索索引更新) + │ ⑤ 同步写入 audit_log + │ + └── 拒绝 → + ① 乐观锁更新:UPDATE review_task SET status='REJECTED', reviewed_by=?, reviewed_at=?, review_comment=?, version=version+1 WHERE id=? AND version=? + ② skill_version.status → REJECTED + ③ 记录 reject_reason + ④ 同步写入 audit_log +``` + +**撤回审核:** +``` +用户撤回审核(DELETE /api/v1/skills/{namespace}/{slug}/versions/{version}/review) + │ + ▼ +① 检查 review_task.status = PENDING(只能撤回待审核的) + │ + ▼ +② 物理删除 review_task 记录 + │ + ▼ +③ skill_version.status → DRAFT +``` + +### 2.2 提升审核流程 + +``` +团队空间技能(已发布,status=PUBLISHED) + │ + ▼ +① 技能 owner 或 namespace ADMIN 发起提升申请 + POST /api/v1/skills/{namespace}/{slug}/promote + Body: { "targetNamespaceSlug": "global", "versionId": 123 } + │ + ▼ +② 创建 promotion_request + - INSERT INTO promotion_request (source_skill_id, source_version_id, target_namespace_id, status=PENDING, submitted_by) + - 检查 partial unique index 防止重复提交 + - 检查 source_version.status = PUBLISHED(只能提升已发布版本) + - 检查 target_namespace.type = GLOBAL(只能提升到全局空间) + │ + ▼ +③ 平台管理员审核(PUT /api/v1/promotion-requests/{id}/approve 或 /reject) + ├── 通过 → + │ ① 乐观锁更新:UPDATE promotion_request SET status='APPROVED', reviewed_by=?, reviewed_at=?, version=version+1 WHERE id=? AND version=? + │ ② 在全局空间创建新 skill + │ - namespace_id = target_namespace_id + │ - slug = 原 skill.slug(如果冲突则拒绝) + │ - source_skill_id = 原 skill.id + │ - owner_id = 原 skill.owner_id + │ - visibility = PUBLIC + │ ③ 复制 source_version_id 对应版本的文件和元数据到新 skill + │ - 创建新 skill_version(status=PUBLISHED) + │ - 复制 skill_file 记录(对象存储文件复用,只复制元数据) + │ - 更新新 skill.latest_version_id + │ ④ 更新 promotion_request.target_skill_id = 新 skill.id + │ ⑤ 发布 SkillPromotedEvent(触发搜索索引写入新 skill) + │ ⑥ 同步写入 audit_log + │ + └── 拒绝 → + ① 乐观锁更新:UPDATE promotion_request SET status='REJECTED', reviewed_by=?, reviewed_at=?, review_comment=?, version=version+1 WHERE id=? AND version=? + ② 同步写入 audit_log +``` + +**提升后的版本管理:** +- 全局空间的新 skill 由其 owner 独立管理版本 +- 原团队 skill 可继续独立迭代 +- 两者版本不自动同步,如需同步由 owner 手动操作 + +### 2.3 审核权限判定 + +#### ReviewPermissionChecker(`domain.review.ReviewPermissionChecker`) + +```java +public class ReviewPermissionChecker { + + /** + * 检查用户是否有权审核指定的 review_task + */ + public boolean canReview(ReviewTask task, Long userId, + Map userNamespaceRoles, + Set platformRoles) { + // 不能审核自己提交的 + if (task.getSubmittedBy().equals(userId)) { + return false; + } + + // 全局空间:只有平台 SKILL_ADMIN 或 SUPER_ADMIN 可以审核 + if (task.getNamespace().getType() == NamespaceType.GLOBAL) { + return platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN"); + } + + // 团队空间:该 namespace 的 ADMIN 或 OWNER 可以审核 + NamespaceRole role = userNamespaceRoles.get(task.getNamespaceId()); + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + } + + /** + * 检查用户是否有权审核提升请求 + */ + public boolean canReviewPromotion(PromotionRequest request, Long userId, + Set platformRoles) { + // 只有平台 SKILL_ADMIN 或 SUPER_ADMIN 可以审核提升请求 + return platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN"); + } +} +``` + +#### 权限矩阵 + +| 操作 | 团队空间 | 全局空间 | 提升请求 | +|------|---------|---------|---------| +| 提交审核 | namespace MEMBER+ | 平台 SKILL_ADMIN+ | namespace ADMIN+ | +| 审核通过/拒绝 | namespace ADMIN+ | 平台 SKILL_ADMIN+ | 平台 SKILL_ADMIN+ | +| 撤回审核 | 提交人本人 | 提交人本人 | 提交人本人 | + +### 2.4 乐观锁并发控制 + +**问题:** 多个审核人同时审核同一任务,可能导致重复审核或状态不一致。 + +**解决方案:** 使用乐观锁(version 字段)+ 数据库 UPDATE 影响行数判定。 + +```java +@Service +public class ReviewService { + + @Transactional + public void approveReview(Long reviewTaskId, Long reviewerId, String comment) { + // 1. 加载 review_task(带 version) + ReviewTask task = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new NotFoundException("Review task not found")); + + // 2. 检查状态(只能审核 PENDING 状态) + if (task.getStatus() != ReviewTaskStatus.PENDING) { + throw new BusinessException("Review task is not pending"); + } + + // 3. 检查权限 + if (!reviewPermissionChecker.canReview(task, reviewerId, ...)) { + throw new ForbiddenException("No permission to review"); + } + + // 4. 乐观锁更新 + int updated = reviewTaskRepository.updateStatusWithVersion( + reviewTaskId, + ReviewTaskStatus.APPROVED, + reviewerId, + comment, + task.getVersion() // WHERE version = ? + ); + + // 5. 检查更新结果 + if (updated == 0) { + throw new ConcurrentModificationException("Review task was modified by another user"); + } + + // 6. 更新 skill_version.status + skillVersionRepository.updateStatus(task.getSkillVersionId(), SkillVersionStatus.PUBLISHED); + + // 7. 更新 skill.latest_version_id + // ... + + // 8. 发布事件 + eventPublisher.publishEvent(new SkillPublishedEvent(...)); + + // 9. 写入审计日志 + auditLogService.log(...); + } +} +``` + +**Repository 实现:** + +```java +@Repository +public interface ReviewTaskRepository extends JpaRepository { + + @Modifying + @Query(""" + UPDATE ReviewTask t + SET t.status = :status, + t.reviewedBy = :reviewerId, + t.reviewComment = :comment, + t.reviewedAt = CURRENT_TIMESTAMP, + t.version = t.version + 1 + WHERE t.id = :id AND t.version = :expectedVersion + """) + int updateStatusWithVersion( + @Param("id") Long id, + @Param("status") ReviewTaskStatus status, + @Param("reviewerId") Long reviewerId, + @Param("comment") String comment, + @Param("expectedVersion") Integer expectedVersion + ); +} +``` + +**并发场景:** +- 审核人 A 和 B 同时审核任务 T(version=1) +- A 先提交:UPDATE ... WHERE id=T AND version=1 → 成功,version 变为 2 +- B 后提交:UPDATE ... WHERE id=T AND version=1 → 失败(version 已变为 2),返回 409 Conflict + +--- + +## 3. 评分收藏设计 + +### 3.1 收藏功能 + +#### SkillStarService(`domain.skill.service.SkillStarService`) + +```java +@Service +public class SkillStarService { + + /** + * 收藏技能 + */ + @Transactional + public void starSkill(Long skillId, Long userId) { + // 1. 检查技能存在性和可见性 + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new NotFoundException("Skill not found")); + + if (!visibilityChecker.canAccess(skill, userId, ...)) { + throw new ForbiddenException("No permission to access this skill"); + } + + // 2. 插入 skill_star(唯一约束自动去重) + try { + SkillStar star = new SkillStar(skillId, userId); + skillStarRepository.save(star); + } catch (DataIntegrityViolationException e) { + // 已收藏,幂等返回成功 + return; + } + + // 3. 异步更新计数器 + eventPublisher.publishEvent(new SkillStarredEvent(skillId, true)); + } + + /** + * 取消收藏 + */ + @Transactional + public void unstarSkill(Long skillId, Long userId) { + int deleted = skillStarRepository.deleteBySkillIdAndUserId(skillId, userId); + + if (deleted > 0) { + // 异步更新计数器 + eventPublisher.publishEvent(new SkillStarredEvent(skillId, false)); + } + } + + /** + * 检查是否已收藏 + */ + public boolean isStarred(Long skillId, Long userId) { + return skillStarRepository.existsBySkillIdAndUserId(skillId, userId); + } + + /** + * 获取用户的收藏列表 + */ + public Page getStarredSkills(Long userId, Pageable pageable) { + return skillStarRepository.findStarredSkillsByUserId(userId, pageable); + } +} +``` + +#### 计数器更新(异步事件) + +```java +@Component +public class SkillStarEventListener { + + @EventListener + @Async("skillhubEventExecutor") + public void onSkillStarred(SkillStarredEvent event) { + if (event.isStarred()) { + // 原子递增 + skillRepository.incrementStarCount(event.skillId()); + } else { + // 原子递减 + skillRepository.decrementStarCount(event.skillId()); + } + } +} +``` + +**Repository 实现:** + +```java +@Repository +public interface SkillRepository extends JpaRepository { + + @Modifying + @Query("UPDATE Skill s SET s.starCount = s.starCount + 1 WHERE s.id = :id") + void incrementStarCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE Skill s SET s.starCount = s.starCount - 1 WHERE s.id = :id AND s.starCount > 0") + void decrementStarCount(@Param("id") Long id); +} +``` + +### 3.2 评分功能 + +#### SkillRatingService(`domain.skill.service.SkillRatingService`) + +```java +@Service +public class SkillRatingService { + + /** + * 提交评分(新增或更新) + */ + @Transactional + public void rateSkill(Long skillId, Long userId, int score) { + // 1. 校验评分范围 + if (score < 1 || score > 5) { + throw new IllegalArgumentException("Score must be between 1 and 5"); + } + + // 2. 检查技能存在性和可见性 + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new NotFoundException("Skill not found")); + + if (!visibilityChecker.canAccess(skill, userId, ...)) { + throw new ForbiddenException("No permission to access this skill"); + } + + // 3. 插入或更新评分 + SkillRating rating = skillRatingRepository + .findBySkillIdAndUserId(skillId, userId) + .orElse(new SkillRating(skillId, userId)); + + rating.setScore(score); + rating.setUpdatedAt(Instant.now()); + skillRatingRepository.save(rating); + + // 4. 异步重算平均分 + eventPublisher.publishEvent(new SkillRatedEvent(skillId)); + } + + /** + * 获取用户对技能的评分 + */ + public Optional getUserRating(Long skillId, Long userId) { + return skillRatingRepository.findBySkillIdAndUserId(skillId, userId) + .map(SkillRating::getScore); + } +} +``` + +#### 评分重算(异步事件 + Redis 分布式锁) + +```java +@Component +public class SkillRatingEventListener { + + @EventListener + @Async("skillhubEventExecutor") + public void onSkillRated(SkillRatedEvent event) { + String lockKey = "rating:recalc:" + event.skillId(); + + // 获取 Redis 分布式锁(TTL 10s) + boolean locked = redisTemplate.opsForValue() + .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); + + if (!locked) { + // 已有其他线程在重算,跳过 + return; + } + + try { + // 重新计算平均分和评分人数 + RatingStats stats = skillRatingRepository.calculateStats(event.skillId()); + + // 更新 skill 表 + skillRepository.updateRatingStats( + event.skillId(), + stats.avgScore(), + stats.count() + ); + } finally { + // 释放锁 + redisTemplate.delete(lockKey); + } + } +} +``` + +**Repository 实现:** + +```java +@Repository +public interface SkillRatingRepository extends JpaRepository { + + Optional findBySkillIdAndUserId(Long skillId, Long userId); + + @Query(""" + SELECT new com.iflytek.skillhub.domain.skill.RatingStats( + COALESCE(AVG(r.score), 0.0), + COUNT(r) + ) + FROM SkillRating r + WHERE r.skillId = :skillId + """) + RatingStats calculateStats(@Param("skillId") Long skillId); +} + +public record RatingStats(Double avgScore, Long count) {} +``` + +**容错机制:** +- 如果 Redis 不可用,跳过分布式锁,直接重算(可能重复计算,但结果最终一致) +- 定时任务每天凌晨从 `skill_rating` 表重算所有技能的评分,修正异步事件丢失导致的不一致 + +--- + +## 4. CLI API 设计 + +### 4.1 OAuth Device Flow 认证 + +**标准流程(RFC 8628):** + +``` +CLI 用户运行 skillhub login + │ + ▼ +① CLI 请求 device code + POST /api/v1/cli/auth/device/code + Response: { + "device_code": "xxx", + "user_code": "ABCD-1234", + "verification_uri": "https://skills.example.com/device", + "expires_in": 900, + "interval": 5 + } + │ + ▼ +② CLI 显示提示信息 + "Please visit https://skills.example.com/device and enter code: ABCD-1234" + CLI 自动打开浏览器(可选) + │ + ▼ +③ 用户在浏览器中访问 verification_uri + 输入 user_code + 登录(如果未登录) + 确认授权 + │ + ▼ +④ CLI 轮询 token 端点 + POST /api/v1/cli/auth/device/token + Body: { "device_code": "xxx" } + + - 授权前:返回 { "error": "authorization_pending" } + - 授权后:返回 { "access_token": "sk_xxx", "token_type": "Bearer", "expires_in": null } + │ + ▼ +⑤ CLI 保存 token 到本地配置文件 + ~/.skillhub/config.json: { "token": "sk_xxx" } +``` + +#### 后端实现 + +**DeviceAuthService(`skillhub-auth` 模块)** + +```java +@Service +public class DeviceAuthService { + + /** + * 生成 device code 和 user code + */ + public DeviceCodeResponse generateDeviceCode() { + String deviceCode = generateSecureToken(32); // 长随机字符串 + String userCode = generateUserFriendlyCode(); // ABCD-1234 格式 + + // 存储到 Redis(TTL 15 分钟) + DeviceCodeData data = new DeviceCodeData( + deviceCode, + userCode, + DeviceCodeStatus.PENDING, + null // userId,授权后填充 + ); + redisTemplate.opsForValue().set( + "device:code:" + deviceCode, + data, + Duration.ofMinutes(15) + ); + + return new DeviceCodeResponse( + deviceCode, + userCode, + "https://skills.example.com/device", + 900, // expires_in + 5 // interval + ); + } + + /** + * 用户授权 device code + */ + public void authorizeDeviceCode(String userCode, Long userId) { + // 1. 通过 user_code 查找 device_code + String deviceCode = findDeviceCodeByUserCode(userCode); + if (deviceCode == null) { + throw new NotFoundException("Invalid user code"); + } + + // 2. 更新状态为 AUTHORIZED,填充 userId + DeviceCodeData data = getDeviceCodeData(deviceCode); + data.setStatus(DeviceCodeStatus.AUTHORIZED); + data.setUserId(userId); + redisTemplate.opsForValue().set( + "device:code:" + deviceCode, + data, + Duration.ofMinutes(15) + ); + } + + /** + * CLI 轮询获取 token + */ + public DeviceTokenResponse pollToken(String deviceCode) { + DeviceCodeData data = getDeviceCodeData(deviceCode); + + if (data == null) { + throw new NotFoundException("Invalid or expired device code"); + } + + return switch (data.getStatus()) { + case PENDING -> DeviceTokenResponse.pending(); + case AUTHORIZED -> { + // 生成 API Token + ApiToken token = apiTokenService.createToken( + data.getUserId(), + "CLI Device Auth", + null // 永不过期 + ); + + // 标记为已使用,防止重复获取 + data.setStatus(DeviceCodeStatus.USED); + redisTemplate.opsForValue().set( + "device:code:" + deviceCode, + data, + Duration.ofMinutes(1) // 短 TTL,快速清理 + ); + + yield DeviceTokenResponse.success(token.getTokenString()); + } + case USED -> throw new BusinessException("Device code already used"); + }; + } + + private String generateUserFriendlyCode() { + // 生成 ABCD-1234 格式的 8 字符码 + String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 去除易混淆字符 + Random random = new SecureRandom(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < 8; i++) { + if (i == 4) code.append('-'); + code.append(chars.charAt(random.nextInt(chars.length()))); + } + return code.toString(); + } +} +``` + +**Controller 层** + +```java +@RestController +@RequestMapping("/api/v1/cli/auth/device") +public class DeviceAuthController { + + @PostMapping("/code") + public DeviceCodeResponse requestDeviceCode() { + return deviceAuthService.generateDeviceCode(); + } + + @PostMapping("/token") + public DeviceTokenResponse pollToken(@RequestBody DeviceTokenRequest request) { + return deviceAuthService.pollToken(request.deviceCode()); + } +} + +@RestController +@RequestMapping("/device") +public class DeviceAuthWebController { + + @GetMapping + public String showDeviceAuthPage(Model model) { + return "device-auth"; // Thymeleaf 模板 + } + + @PostMapping("/authorize") + @PreAuthorize("isAuthenticated()") + public String authorizeDevice(@RequestParam String userCode, + @AuthenticationPrincipal PlatformPrincipal principal) { + deviceAuthService.authorizeDeviceCode(userCode, principal.getUserId()); + return "device-auth-success"; + } +} +``` + +### 4.2 CLI API 端点 + +#### whoami - 查询当前用户信息 + +``` +GET /api/v1/cli/whoami +Authorization: Bearer sk_xxx + +Response 200: +{ + "code": 0, + "data": { + "userId": 123, + "displayName": "张三", + "email": "zhangsan@example.com", + "namespaces": [ + { + "slug": "global", + "displayName": "Global", + "role": null + }, + { + "slug": "team-ai", + "displayName": "AI Team", + "role": "ADMIN" + } + ] + } +} +``` + +#### publish - 发布技能 + +``` +POST /api/v1/cli/publish +Authorization: Bearer sk_xxx +Content-Type: multipart/form-data +X-Request-Id: uuid-v4(可选,用于幂等) + +Parts: + - file: zip 包(必需) + - namespace: 目标命名空间 slug(必需) + - visibility: PUBLIC / NAMESPACE_ONLY / PRIVATE(可选,默认 PUBLIC) + - auto_submit: boolean(可选,默认 true,自动提交审核) + +Response 200: +{ + "code": 0, + "data": { + "skillId": 456, + "skillVersionId": 123, + "namespace": "team-ai", + "slug": "my-skill", + "version": "1.2.0", + "status": "PENDING_REVIEW", // auto_submit=true 时 + "fileCount": 5, + "totalSize": 12345 + } +} +``` + +**与 Phase 2 的差异:** +- Phase 2:上传 → DRAFT → 自动 PUBLISHED +- Phase 3:上传 → DRAFT → 提交审核 → PENDING_REVIEW → 人工审核 → PUBLISHED + +#### resolve - 解析技能版本 + +``` +GET /api/v1/cli/resolve?skill=@team-ai/my-skill&version=1.2.0 +Authorization: Bearer sk_xxx(可选,匿名可访问 PUBLIC 技能) + +Query Parameters: + - skill: 技能坐标(@namespace/slug) + - version: 版本号 / 标签名 / "latest"(可选,默认 latest) + +Response 200: +{ + "code": 0, + "data": { + "skillId": 456, + "namespace": "team-ai", + "slug": "my-skill", + "displayName": "My Skill", + "version": "1.2.0", + "versionId": 123, + "status": "PUBLISHED", + "downloadUrl": "/api/v1/skills/team-ai/my-skill/versions/1.2.0/download", + "fileCount": 5, + "totalSize": 12345, + "publishedAt": "2026-03-12T10:00:00Z" + } +} +``` + +#### check - 检查技能包有效性 + +``` +POST /api/v1/cli/check +Authorization: Bearer sk_xxx +Content-Type: multipart/form-data + +Parts: + - file: zip 包(必需) + +Response 200: +{ + "code": 0, + "data": { + "valid": true, + "metadata": { + "name": "my-skill", + "version": "1.2.0", + "description": "..." + }, + "fileCount": 5, + "totalSize": 12345, + "errors": [] + } +} + +Response 200(校验失败): +{ + "code": 0, + "data": { + "valid": false, + "errors": [ + "SKILL.md not found", + "Invalid version format: 1.2" + ] + } +} +``` + +--- + +## 5. ClawHub 兼容层设计 + +### 5.1 Canonical Slug 映射规则 + +根据 `00-product-direction.md` 1.1 节的冻结决策: + +| skillhub 坐标 | ClawHub canonical slug | 说明 | +|--------------|----------------------|------| +| `@global/my-skill` | `my-skill` | 全局空间省略前缀 | +| `@team-ai/my-skill` | `team-ai--my-skill` | 团队空间使用双连字符 | + +**映射规则:** +- 分隔符为双连字符 `--` +- skill slug 和 namespace slug 均禁止包含 `--`(在校验规则中已强制) +- 兼容层解析 canonical slug 时: + - 包含 `--` → 拆分为 `namespace_slug` + `skill_slug` + - 不包含 `--` → 视为 `@global/{slug}` + +**CanonicalSlugMapper(`skillhub-app` 模块)** + +```java +@Component +public class CanonicalSlugMapper { + + /** + * skillhub 坐标 → canonical slug + */ + public String toCanonical(String namespaceSlug, String skillSlug) { + if ("global".equals(namespaceSlug)) { + return skillSlug; + } + return namespaceSlug + "--" + skillSlug; + } + + /** + * canonical slug → skillhub 坐标 + */ + public SkillCoordinate fromCanonical(String canonicalSlug) { + int separatorIndex = canonicalSlug.indexOf("--"); + + if (separatorIndex == -1) { + // 无 --,视为全局空间 + return new SkillCoordinate("global", canonicalSlug); + } + + // 有 --,拆分为 namespace + skill + String namespaceSlug = canonicalSlug.substring(0, separatorIndex); + String skillSlug = canonicalSlug.substring(separatorIndex + 2); + return new SkillCoordinate(namespaceSlug, skillSlug); + } +} + +public record SkillCoordinate(String namespaceSlug, String skillSlug) {} +``` + +### 5.2 兼容层端点 + +#### /.well-known/clawhub.json - 服务发现 + +``` +GET /.well-known/clawhub.json + +Response 200: +{ + "apiBase": "/api/compat/v1" +} +``` + +#### search - 搜索技能 + +``` +GET /api/compat/v1/search?q=keyword&page=0&size=20 +Authorization: Bearer sk_xxx(可选) + +Response 200: +{ + "items": [ + { + "slug": "my-skill", // canonical slug + "name": "My Skill", + "description": "...", + "version": "1.2.0", + "downloads": 1234, + "stars": 56 + }, + { + "slug": "team-ai--another-skill", + "name": "Another Skill", + "description": "...", + "version": "2.0.0", + "downloads": 567, + "stars": 23 + } + ], + "total": 42, + "page": 0, + "size": 20 +} +``` + +**实现:** 调用 skillhub 搜索 API,将结果转换为 canonical slug 格式。 + +#### resolve - 解析技能版本 + +``` +GET /api/compat/v1/resolve?slug=my-skill&version=1.2.0 +Authorization: Bearer sk_xxx(可选) + +Response 200: +{ + "slug": "my-skill", + "name": "My Skill", + "version": "1.2.0", + "downloadUrl": "/api/compat/v1/download/my-skill/1.2.0", + "fileCount": 5, + "totalSize": 12345 +} +``` + +**实现:** +1. 解析 canonical slug → skillhub 坐标 +2. 调用 skillhub resolve API +3. 转换响应格式 + +#### download - 下载技能包 + +``` +GET /api/compat/v1/download/{slug}/{version} +Authorization: Bearer sk_xxx(可选) + +Response 200: +Content-Type: application/zip +Content-Disposition: attachment; filename="my-skill-1.2.0.zip" + + +``` + +**实现:** +1. 解析 canonical slug → skillhub 坐标 +2. 调用 skillhub download API +3. 透传 zip 文件 + +#### publish - 发布技能 + +``` +POST /api/compat/v1/publish +Authorization: Bearer sk_xxx +Content-Type: multipart/form-data + +Parts: + - file: zip 包(必需) + - namespace: 目标命名空间 slug(可选,默认 global) + +Response 200: +{ + "slug": "my-skill", + "version": "1.2.0", + "status": "pending_review" +} +``` + +**实现:** 调用 skillhub publish API,转换响应格式。 + +#### whoami - 查询当前用户 + +``` +GET /api/compat/v1/whoami +Authorization: Bearer sk_xxx + +Response 200: +{ + "userId": 123, + "username": "zhangsan", + "email": "zhangsan@example.com" +} +``` + +**实现:** 调用 skillhub whoami API,转换响应格式。 + +### 5.3 兼容层 Controller 实现 + +```java +@RestController +@RequestMapping("/api/compat/v1") +public class ClawHubCompatController { + + private final CanonicalSlugMapper slugMapper; + private final SkillQueryService skillQueryService; + private final SkillDownloadService skillDownloadService; + private final SkillPublishService skillPublishService; + private final SkillSearchAppService skillSearchAppService; + + @GetMapping("/search") + public ClawHubSearchResponse search( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal PlatformPrincipal principal) { + + // 调用 skillhub 搜索 + SearchResultDTO result = skillSearchAppService.searchSkills( + q, null, "relevance", page, size, principal + ); + + // 转换为 ClawHub 格式 + List items = result.items().stream() + .map(item -> new ClawHubSkillItem( + slugMapper.toCanonical(item.namespace(), item.slug()), + item.displayName(), + item.summary(), + item.latestVersion(), + item.downloadCount(), + item.starCount() + )) + .toList(); + + return new ClawHubSearchResponse(items, result.total(), page, size); + } + + @GetMapping("/resolve") + public ClawHubResolveResponse resolve( + @RequestParam String slug, + @RequestParam(defaultValue = "latest") String version, + @AuthenticationPrincipal PlatformPrincipal principal) { + + // 解析 canonical slug + SkillCoordinate coord = slugMapper.fromCanonical(slug); + + // 调用 skillhub resolve + SkillVersionDetailDTO detail = skillQueryService.getVersionDetail( + coord.namespaceSlug(), + coord.skillSlug(), + version, + principal + ); + + // 转换为 ClawHub 格式 + return new ClawHubResolveResponse( + slug, + detail.displayName(), + detail.version(), + "/api/compat/v1/download/" + slug + "/" + detail.version(), + detail.fileCount(), + detail.totalSize() + ); + } + + @GetMapping("/download/{slug}/{version}") + public ResponseEntity download( + @PathVariable String slug, + @PathVariable String version, + @AuthenticationPrincipal PlatformPrincipal principal) { + + // 解析 canonical slug + SkillCoordinate coord = slugMapper.fromCanonical(slug); + + // 调用 skillhub download + DownloadResult result = skillDownloadService.downloadVersion( + coord.namespaceSlug(), + coord.skillSlug(), + version, + principal + ); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + result.filename() + "\"") + .contentLength(result.contentLength()) + .body(new InputStreamResource(result.content())); + } + + @PostMapping("/publish") + public ClawHubPublishResponse publish( + @RequestParam("file") MultipartFile file, + @RequestParam(defaultValue = "global") String namespace, + @AuthenticationPrincipal PlatformPrincipal principal) { + + // 调用 skillhub publish + SkillVersion version = skillPublishService.publishSkill( + namespace, + file.getInputStream(), + principal.getUserId(), + SkillVisibility.PUBLIC + ); + + // 转换为 ClawHub 格式 + String canonicalSlug = slugMapper.toCanonical(namespace, version.getSkill().getSlug()); + return new ClawHubPublishResponse( + canonicalSlug, + version.getVersion(), + version.getStatus().name().toLowerCase() + ); + } + + @GetMapping("/whoami") + public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) { + UserAccount user = userAccountRepository.findById(principal.getUserId()) + .orElseThrow(); + + return new ClawHubWhoamiResponse( + user.getId(), + user.getDisplayName(), + user.getEmail() + ); + } +} +``` + +--- + +## 6. 幂等去重设计 + +### 6.1 双层防护架构 + +**Redis 层(快速去重):** +- Key: `idempotent:{requestId}` +- Value: "1" +- TTL: 24 小时 +- 作用:快速拦截重复请求,避免数据库查询 + +**PostgreSQL 层(持久化兜底):** +- 表:`idempotency_record` +- 作用:持久化幂等记录,Redis 失效后仍能去重 + +### 6.2 幂等拦截器 + +```java +@Component +public class IdempotencyInterceptor implements HandlerInterceptor { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String IDEMPOTENCY_ATTR = "idempotency.requestId"; + + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, + Object handler) throws Exception { + + // 只拦截写操作 + String method = request.getMethod(); + if (!("POST".equals(method) || "PUT".equals(method) || "DELETE".equals(method))) { + return true; + } + + // 获取 Request-Id + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + // 客户端未传 Request-Id,不做幂等处理 + return true; + } + + // 校验 UUID 格式 + if (!isValidUUID(requestId)) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.getWriter().write("{\"error\":\"Invalid X-Request-Id format\"}"); + return false; + } + + // 检查 Redis 快速去重 + String redisKey = "idempotent:" + requestId; + Boolean exists = redisTemplate.opsForValue().setIfAbsent( + redisKey, + "1", + Duration.ofHours(24) + ); + + if (Boolean.FALSE.equals(exists)) { + // Redis 中已存在,查询 PostgreSQL 获取原始结果 + IdempotencyRecord record = idempotencyRecordRepository + .findById(requestId) + .orElse(null); + + if (record == null) { + // Redis 有但 PostgreSQL 无,可能是脏数据,删除 Redis key 允许重试 + redisTemplate.delete(redisKey); + return true; + } + + return switch (record.getStatus()) { + case COMPLETED -> { + // 返回原始结果 + response.setStatus(record.getResponseStatusCode()); + response.setContentType("application/json"); + response.getWriter().write(buildIdempotentResponse(record)); + yield false; // 拦截请求 + } + case PROCESSING -> { + // 正在处理中,返回 409 Conflict + response.setStatus(HttpStatus.CONFLICT.value()); + response.getWriter().write("{\"error\":\"Request is being processed\"}"); + yield false; + } + case FAILED -> { + // 失败状态,允许重试 + redisTemplate.delete(redisKey); + yield true; + } + }; + } + + // Redis SETNX 成功,插入 PostgreSQL PROCESSING 记录 + IdempotencyRecord record = new IdempotencyRecord( + requestId, + null, // resourceType,业务层填充 + null, // resourceId,业务层填充 + IdempotencyStatus.PROCESSING, + null, + Instant.now(), + Instant.now().plus(24, ChronoUnit.HOURS) + ); + idempotencyRecordRepository.save(record); + + // 将 requestId 存入 request attribute,供业务层使用 + request.setAttribute(IDEMPOTENCY_ATTR, requestId); + + return true; + } + + private String buildIdempotentResponse(IdempotencyRecord record) { + // 根据 resourceType 和 resourceId 构建响应 + return String.format( + "{\"code\":0,\"data\":{\"resourceType\":\"%s\",\"resourceId\":%d}}", + record.getResourceType(), + record.getResourceId() + ); + } +} +``` + +### 6.3 业务层使用 + +```java +@Service +public class SkillPublishService { + + @Transactional + public SkillVersion publishSkill(..., HttpServletRequest request) { + // 业务逻辑 + SkillVersion version = doPublish(...); + + // 更新幂等记录为 COMPLETED + String requestId = (String) request.getAttribute("idempotency.requestId"); + if (requestId != null) { + idempotencyRecordRepository.updateToCompleted( + requestId, + "skill_version", + version.getId(), + 200 + ); + } + + return version; + } +} +``` + +### 6.4 定时清理任务 + +```java +@Component +public class IdempotencyCleanupTask { + + @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点 + public void cleanupExpiredRecords() { + int deleted = idempotencyRecordRepository.deleteExpired(Instant.now()); + log.info("Cleaned up {} expired idempotency records", deleted); + } + + @Scheduled(fixedDelay = 300000) // 每 5 分钟 + public void cleanupStaleProcessing() { + // 清理超过 5 分钟仍在 PROCESSING 状态的记录(视为僵死) + Instant staleThreshold = Instant.now().minus(5, ChronoUnit.MINUTES); + int updated = idempotencyRecordRepository.markStaleAsFailed(staleThreshold); + if (updated > 0) { + log.warn("Marked {} stale PROCESSING records as FAILED", updated); + } + } +} +``` + +--- + +## 7. 前端设计 + +### 7.1 审核中心 + +#### 路由结构 + +``` +/dashboard/reviews → 我的审核任务(我有权审核的) +/dashboard/reviews/my-submissions → 我的提交(我提交的审核) +/dashboard/reviews/{id} → 审核详情页 +/dashboard/promotions → 提升审核列表(仅平台管理员) +/dashboard/promotions/{id} → 提升审核详情页 +``` + +#### 审核任务列表页(`/dashboard/reviews`) + +**布局:** +- Tab 切换:待审核 / 已审核 / 全部 +- 筛选器:命名空间下拉、提交人搜索、提交时间范围 +- 表格列:技能名、版本号、提交人、提交时间、状态、操作 + +**表格列定义:** + +| 列 | 内容 | +|----|------| +| 技能名 | `@namespace/slug` + displayName | +| 版本号 | `1.2.0` | +| 提交人 | 用户头像 + 名称 | +| 提交时间 | 相对时间(2 小时前) | +| 状态 | Badge(PENDING 黄色 / APPROVED 绿色 / REJECTED 红色) | +| 操作 | 查看详情按钮 | + +**权限过滤:** +- 团队管理员:只看到自己管理的 namespace 的审核任务 +- 平台 SKILL_ADMIN:只看到全局空间的审核任务 +- SUPER_ADMIN:看到所有审核任务 + +#### 审核详情页(`/dashboard/reviews/{id}`) + +**布局:** +- 左侧主区域(70%): + - 技能信息卡片:名称、版本、提交人、提交时间 + - Tab 切换:README / 文件列表 / 变更历史 + - README tab:Markdown 渲染 + - 文件列表 tab:文件树 + 文件内容预览 + - 变更历史 tab:与上一版本的 diff(如果有) +- 右侧操作栏(30%): + - 审核状态 Badge + - 审核意见输入框(Textarea) + - 通过按钮(绿色,带确认对话框) + - 拒绝按钮(红色,必须填写拒绝原因) + - 审核历史(如果已审核) + +**通过确认对话框:** +``` +标题:确认通过审核 +内容:确认通过技能 @team-ai/my-skill v1.2.0 的审核?通过后技能将立即发布。 +按钮:取消 / 确认通过 +``` + +**拒绝对话框:** +``` +标题:拒绝审核 +内容: + - 拒绝原因(必填,Textarea) + - 提示:拒绝原因将发送给提交人 +按钮:取消 / 确认拒绝 +``` + +#### 我的提交列表页(`/dashboard/reviews/my-submissions`) + +**布局:** 与审核任务列表页类似,但只显示当前用户提交的审核。 + +**操作列:** +- PENDING 状态:撤回按钮 +- APPROVED 状态:查看详情 +- REJECTED 状态:查看详情 + 查看拒绝原因 + +#### 提升审核列表页(`/dashboard/promotions`) + +**仅平台 SKILL_ADMIN 和 SUPER_ADMIN 可访问。** + +**布局:** +- Tab 切换:待审核 / 已审核 / 全部 +- 表格列:来源技能、目标空间、申请版本、提交人、提交时间、状态、操作 + +**表格列定义:** + +| 列 | 内容 | +|----|------| +| 来源技能 | `@team-ai/my-skill` | +| 目标空间 | `@global` | +| 申请版本 | `1.2.0` | +| 提交人 | 用户头像 + 名称 | +| 提交时间 | 相对时间 | +| 状态 | Badge | +| 操作 | 查看详情按钮 | + +#### 提升审核详情页(`/dashboard/promotions/{id}`) + +**布局:** 与审核详情页类似,但增加提升信息: +- 来源技能:`@team-ai/my-skill` +- 目标空间:`@global` +- 提升后坐标:`@global/my-skill` +- 冲突检查:如果目标空间已存在同名技能,显示警告 + +### 7.2 评分收藏 UI + +#### 技能详情页增强(Phase 2 已有,Phase 3 增强) + +**右侧信息栏新增:** + +```tsx +// 评分组件 +
+
+ + + {skill.ratingAvg.toFixed(1)} ({skill.ratingCount} 评分) + +
+ + {isAuthenticated ? ( +
+ + +
+ ) : ( +

+ 登录 后可评分 +

+ )} +
+ +// 收藏按钮 + +``` + +**匿名用户点击评分/收藏:** +- 弹出 Toast 提示:"请先登录" +- 点击 Toast 跳转到登录页 + +#### 我的收藏页(`/dashboard/favorites`) + +**布局:** +- 网格布局 SkillCard 列表(与搜索页类似) +- 排序选项:收藏时间 / 下载量 / 评分 +- 空状态:引导用户浏览技能并收藏 + +**SkillCard 增强:** +- 右上角显示收藏时间(相对时间) +- 悬浮显示取消收藏按钮 + +### 7.3 Token 管理页 + +#### 路由:`/dashboard/tokens` + +**布局:** +- 顶部:创建 Token 按钮 +- 表格列:名称、前缀、创建时间、最后使用时间、过期时间、操作 + +**表格列定义:** + +| 列 | 内容 | +|----|------| +| 名称 | Token 名称(如"CI/CD"、"本地开发") | +| 前缀 | `sk_abc...`(只显示前 10 个字符) | +| 创建时间 | 相对时间 | +| 最后使用时间 | 相对时间 / "从未使用" | +| 过期时间 | 日期 / "永不过期" | +| 操作 | 吊销按钮(红色,带确认) | + +**创建 Token 对话框:** + +``` +标题:创建 API Token +内容: + - Token 名称(必填,Text Input) + - 过期时间(可选,Date Picker / "永不过期") + - 提示:Token 只会显示一次,请妥善保存 +按钮:取消 / 创建 +``` + +**创建成功对话框:** + +``` +标题:Token 创建成功 +内容: + - Token 字符串(Monospace 字体,带复制按钮) + - 警告:此 Token 只会显示一次,请立即复制保存 +按钮:我已复制 +``` + +**吊销确认对话框:** + +``` +标题:吊销 Token +内容:确认吊销 Token "CI/CD"?吊销后无法恢复,使用此 Token 的应用将无法访问。 +按钮:取消 / 确认吊销 +``` + +### 7.4 管理后台 + +#### 路由结构 + +``` +/admin → 管理后台首页(仅平台管理员) +/admin/users → 用户管理 +/admin/users/{id} → 用户详情 +/admin/roles → 角色管理 +/admin/audit-logs → 审计日志 +``` + +**权限要求:** +- `/admin/users`:USER_ADMIN 或 SUPER_ADMIN +- `/admin/roles`:SUPER_ADMIN +- `/admin/audit-logs`:AUDITOR 或 SUPER_ADMIN + +#### 用户管理页(`/admin/users`) + +**布局:** +- 搜索框:按用户名/邮箱搜索 +- 筛选器:状态(ACTIVE / PENDING / DISABLED / MERGED) +- 表格列:用户名、邮箱、状态、角色、创建时间、操作 + +**表格列定义:** + +| 列 | 内容 | +|----|------| +| 用户名 | 头像 + displayName | +| 邮箱 | email | +| 状态 | Badge(ACTIVE 绿色 / PENDING 黄色 / DISABLED 红色) | +| 角色 | 平台角色列表(Tag) | +| 创建时间 | 相对时间 | +| 操作 | 查看详情 / 编辑角色 / 封禁/解封 | + +**操作按钮:** +- **查看详情** - 跳转到用户详情页 +- **编辑角色** - 弹出对话框,多选平台角色(SKILL_ADMIN / USER_ADMIN / AUDITOR) +- **封禁/解封** - 切换用户状态(ACTIVE ↔ DISABLED),带确认对话框 + +**编辑角色对话框:** + +``` +标题:编辑用户角色 +内容: + - 用户:张三 (zhangsan@example.com) + - 角色(多选 Checkbox): + □ SKILL_ADMIN - 技能治理 + □ USER_ADMIN - 用户治理 + □ AUDITOR - 审计只读 + - 提示:SUPER_ADMIN 角色只能由超管分配 +按钮:取消 / 保存 +``` + +#### 用户详情页(`/admin/users/{id}`) + +**布局:** +- 用户信息卡片:头像、名称、邮箱、状态、创建时间 +- Tab 切换:基本信息 / 平台角色 / 命名空间成员 / 操作历史 + +**基本信息 Tab:** +- 显示用户的所有身份绑定(GitHub、GitLab 等) +- 显示用户的 API Token 列表(只显示前缀和创建时间) + +**平台角色 Tab:** +- 显示用户的平台角色列表 +- 添加/移除角色按钮 + +**命名空间成员 Tab:** +- 显示用户所属的命名空间及角色 +- 表格列:命名空间、角色、加入时间 + +**操作历史 Tab:** +- 显示用户的审计日志(最近 100 条) +- 表格列:操作、目标、时间、IP + +#### 审计日志页(`/admin/audit-logs`) + +**布局:** +- 筛选器: + - 操作类型下拉(发布、审核、下载、删除等) + - 用户搜索 + - 时间范围选择器 + - 目标类型下拉(skill、namespace、user 等) +- 表格列:时间、操作人、操作、目标、IP、详情 + +**表格列定义:** + +| 列 | 内容 | +|----|------| +| 时间 | 精确时间(2026-03-12 10:30:45) | +| 操作人 | 用户头像 + 名称 | +| 操作 | Badge(PUBLISH / APPROVE / REJECT / DELETE 等) | +| 目标 | 目标类型 + ID(如"skill #123") | +| IP | 客户端 IP | +| 详情 | 展开按钮,显示 detail_json | + +**详情展开:** +- JSON 格式化显示 +- 语法高亮 +- 可复制 + +### 7.5 前端文件结构(Phase 3 新增) + +``` +web/src/ +├── pages/ +│ ├── dashboard/ +│ │ ├── reviews.tsx # 审核任务列表 +│ │ ├── review-detail.tsx # 审核详情 +│ │ ├── my-submissions.tsx # 我的提交 +│ │ ├── promotions.tsx # 提升审核列表 +│ │ ├── promotion-detail.tsx # 提升审核详情 +│ │ ├── favorites.tsx # 我的收藏 +│ │ └── tokens.tsx # Token 管理 +│ └── admin/ +│ ├── users.tsx # 用户管理 +│ ├── user-detail.tsx # 用户详情 +│ ├── roles.tsx # 角色管理 +│ └── audit-logs.tsx # 审计日志 +├── features/ +│ ├── review/ +│ │ ├── review-task-table.tsx +│ │ ├── review-detail-view.tsx +│ │ ├── review-action-panel.tsx +│ │ ├── approve-dialog.tsx +│ │ ├── reject-dialog.tsx +│ │ ├── use-review-tasks.ts +│ │ ├── use-approve-review.ts +│ │ └── use-reject-review.ts +│ ├── promotion/ +│ │ ├── promotion-table.tsx +│ │ ├── promotion-detail-view.tsx +│ │ ├── use-promotions.ts +│ │ └── use-approve-promotion.ts +│ ├── rating/ +│ │ ├── star-rating.tsx # 评分组件 +│ │ ├── rating-display.tsx # 评分展示 +│ │ ├── use-rate-skill.ts +│ │ └── use-user-rating.ts +│ ├── star/ +│ │ ├── star-button.tsx # 收藏按钮 +│ │ ├── use-star-skill.ts +│ │ └── use-starred-skills.ts +│ ├── token/ +│ │ ├── token-table.tsx +│ │ ├── create-token-dialog.tsx +│ │ ├── token-created-dialog.tsx +│ │ ├── revoke-token-dialog.tsx +│ │ ├── use-tokens.ts +│ │ ├── use-create-token.ts +│ │ └── use-revoke-token.ts +│ └── admin/ +│ ├── user-table.tsx +│ ├── user-detail-view.tsx +│ ├── edit-roles-dialog.tsx +│ ├── audit-log-table.tsx +│ ├── use-users.ts +│ ├── use-audit-logs.ts +│ └── use-update-user-roles.ts +└── shared/ + └── components/ + ├── confirm-dialog.tsx # 通用确认对话框 + └── json-viewer.tsx # JSON 查看器 +``` + +--- + +## 8. Chunk 划分与验收标准 + +### Chunk 1:审核流程核心(后端) + +**范围:** 数据库迁移 + 审核流程 + 提升流程 + 乐观锁 + 分级权限 + +**任务清单:** +1. 数据库迁移 `V3__phase3_review_social_tables.sql` + - 创建 review_task、promotion_request、skill_star、skill_rating、idempotency_record 表 + - 创建 partial unique index +2. 领域实体 + - ReviewTask、PromotionRequest、SkillStar、SkillRating、IdempotencyRecord +3. 审核服务 + - ReviewService:提交审核、审核通过/拒绝、撤回审核 + - PromotionService:提交提升、审核提升 + - ReviewPermissionChecker:权限判定 +4. Repository 实现 + - ReviewTaskRepository:乐观锁更新方法 + - PromotionRequestRepository:乐观锁更新方法 +5. Controller 层 + - ReviewController:审核任务 CRUD、审核操作 + - PromotionController:提升请求 CRUD、审核操作 +6. 单元测试 + 集成测试 + +**验收标准:** +1. 用户可以提交审核,创建 review_task(status=PENDING) +2. 审核人可以通过/拒绝审核,乐观锁防止并发冲突 +3. 审核通过后,skill_version.status → PUBLISHED,触发搜索索引更新 +4. 审核拒绝后,skill_version.status → REJECTED,记录拒绝原因 +5. 用户可以撤回 PENDING 状态的审核 +6. 团队管理员只能审核自己管理的 namespace 的技能 +7. 平台 SKILL_ADMIN 只能审核全局空间的技能 +8. 用户可以提交提升请求,创建 promotion_request(status=PENDING) +9. 平台 SKILL_ADMIN 可以审核提升请求 +10. 提升通过后,在全局空间创建新 skill,复制版本和文件 +11. 所有审核操作写入 audit_log +12. 所有测试通过 + +### Chunk 2:评分收藏 + 前端审核中心 + +**范围:** 评分收藏后端 + 审核中心前端 + Token 管理前端 + +**任务清单:** + +**后端:** +1. 评分收藏服务 + - SkillStarService:收藏/取消收藏 + - SkillRatingService:提交评分 +2. 异步事件监听器 + - SkillStarEventListener:更新 star_count + - SkillRatingEventListener:重算 rating_avg +3. Controller 层 + - SkillStarController:收藏操作、我的收藏列表 + - SkillRatingController:评分操作、获取用户评分 + +**前端:** +1. 审核中心页面 + - 审核任务列表页 + - 审核详情页 + - 我的提交列表页 + - 提升审核列表页 + - 提升审核详情页 +2. 评分收藏组件 + - StarRating 组件 + - StarButton 组件 + - 技能详情页集成 + - 我的收藏页 +3. Token 管理页 + - Token 列表 + - 创建 Token 对话框 + - 吊销 Token 对话框 + +**验收标准:** +1. 用户可以收藏技能,skill.star_count 异步更新 +2. 用户可以取消收藏,star_count 异步递减 +3. 用户可以对技能评分(1-5 分),skill.rating_avg 异步重算 +4. 用户可以修改评分,rating_avg 重新计算 +5. 匿名用户点击评分/收藏,提示登录 +6. 审核中心:审核人可以查看待审核任务列表 +7. 审核中心:审核人可以查看审核详情,通过/拒绝审核 +8. 审核中心:用户可以查看自己的提交列表,撤回 PENDING 审核 +9. 提升审核:平台管理员可以查看提升请求列表,审核提升 +10. Token 管理:用户可以创建 Token,查看 Token 列表,吊销 Token +11. 前端测试通过 + +### Chunk 3:CLI API + Web 授权 + +**范围:** OAuth Device Flow + CLI API 端点 + +**任务清单:** +1. OAuth Device Flow 实现 + - DeviceAuthService:生成 device code、授权、轮询 token + - DeviceAuthController:device code 端点、token 端点 + - DeviceAuthWebController:Web 授权页面 +2. CLI API 端点 + - whoami:查询当前用户信息 + - publish:发布技能(复用 Phase 2 逻辑) + - resolve:解析技能版本 + - check:检查技能包有效性 +3. 前端 Device Auth 页面 + - 输入 user code + - 确认授权 + - 授权成功提示 +4. CLI 工具集成测试(手动测试) + +**验收标准:** +1. CLI 运行 `skillhub login`,获取 device code 和 user code +2. CLI 打开浏览器,跳转到授权页面 +3. 用户输入 user code,确认授权 +4. CLI 轮询获取 token,保存到本地配置文件 +5. CLI 运行 `skillhub whoami`,返回当前用户信息 +6. CLI 运行 `skillhub publish`,上传技能包,提交审核 +7. CLI 运行 `skillhub resolve @team-ai/my-skill`,返回版本信息 +8. CLI 运行 `skillhub check skill.zip`,返回校验结果 +9. 所有 CLI API 端点测试通过 + +### Chunk 4:ClawHub 兼容层 + +**范围:** canonical slug 映射 + 兼容层端点 + +**任务清单:** +1. CanonicalSlugMapper 实现 +2. Well-known 端点:`/.well-known/clawhub.json` +3. 兼容层 Controller + - search:搜索技能 + - resolve:解析技能版本 + - download:下载技能包 + - publish:发布技能 + - whoami:查询当前用户 +4. 协议适配测试(使用 ClawHub CLI 真实请求) + +**验收标准:** +1. ClawHub CLI 可以通过 `/.well-known/clawhub.json` 发现兼容层 API +2. ClawHub CLI 可以搜索技能,返回 canonical slug 格式 +3. ClawHub CLI 可以解析技能版本(`my-skill` 和 `team-ai--my-skill`) +4. ClawHub CLI 可以下载技能包 +5. ClawHub CLI 可以发布技能(需要 Token 认证) +6. ClawHub CLI 可以查询当前用户信息 +7. 所有兼容层端点测试通过 + +### Chunk 5:幂等去重 + 管理后台 + +**范围:** 幂等拦截器 + 管理后台前端 + +**任务清单:** + +**后端:** +1. 幂等拦截器 + - IdempotencyInterceptor:Redis + PostgreSQL 双层去重 + - IdempotencyCleanupTask:定时清理过期记录 +2. 管理后台 API + - UserManagementController:用户管理、角色分配 + - AuditLogController:审计日志查询 + +**前端:** +1. 管理后台页面 + - 用户管理页 + - 用户详情页 + - 审计日志页 +2. 权限守卫 + - 路由守卫:检查平台角色 + - 组件级权限控制 + +**验收标准:** +1. 写操作带 `X-Request-Id` 时,重复请求返回原始结果 +2. Redis 不可用时,PostgreSQL 兜底去重 +3. 定时任务清理过期幂等记录 +4. 管理后台:USER_ADMIN 可以查看用户列表,编辑角色,封禁/解封用户 +5. 管理后台:AUDITOR 可以查看审计日志,筛选和搜索 +6. 管理后台:SUPER_ADMIN 可以访问所有管理功能 +7. 前端路由守卫:非管理员访问 `/admin` 跳转到 403 页面 +8. 所有测试通过 + +--- + +## 9. 测试策略 + +### 9.1 后端测试 + +| 层级 | 范围 | 工具 | 覆盖重点 | +|------|------|------|---------| +| 单元测试 | 领域服务、权限检查器 | JUnit 5 + Mockito | ReviewService、PromotionService、ReviewPermissionChecker、SkillStarService、SkillRatingService | +| 集成测试 | Repository + DB | @DataJpaTest + Testcontainers | 乐观锁更新、partial unique index、计数器原子操作 | +| 集成测试 | Redis 幂等去重 | @SpringBootTest + Testcontainers Redis | IdempotencyInterceptor、Redis SETNX | +| API 测试 | Controller | @WebMvcTest + MockMvc | 审核操作、评分收藏、CLI API、兼容层端点 | +| 端到端测试 | 审核全链路 | @SpringBootTest + Testcontainers | 提交审核 → 审核通过 → 发布 → 搜索索引更新 | + +### 9.2 关键测试用例 + +#### 审核流程测试 + +- 提交审核 → review_task 创建,skill_version.status → PENDING_REVIEW +- 审核通过 → skill_version.status → PUBLISHED,搜索索引更新 +- 审核拒绝 → skill_version.status → REJECTED,记录拒绝原因 +- 撤回审核 → review_task 删除,skill_version.status → DRAFT +- 并发审核 → 乐观锁冲突,第二个审核人返回 409 Conflict +- 重复提交 → partial unique index 防止重复 + +#### 提升审核测试 + +- 提交提升 → promotion_request 创建 +- 提升通过 → 全局空间创建新 skill,复制版本和文件 +- 提升拒绝 → 原技能不受影响 +- slug 冲突 → 提升失败,返回错误 + +#### 评分收藏测试 + +- 收藏技能 → skill_star 创建,star_count 异步递增 +- 取消收藏 → skill_star 删除,star_count 异步递减 +- 重复收藏 → 幂等,不重复计数 +- 提交评分 → skill_rating 创建/更新,rating_avg 异步重算 +- 修改评分 → rating_avg 重新计算 + +#### CLI API 测试 + +- Device Flow → 生成 device code → 授权 → 轮询获取 token +- whoami → 返回当前用户信息 +- publish → 上传技能包,提交审核 +- resolve → 解析技能版本 +- check → 校验技能包 + +#### 兼容层测试 + +- canonical slug 映射 → `my-skill` ↔ `@global/my-skill`,`team-ai--my-skill` ↔ `@team-ai/my-skill` +- search → 返回 canonical slug 格式 +- resolve → 解析 canonical slug +- download → 下载技能包 +- publish → 发布技能 + +#### 幂等去重测试 + +- 带 Request-Id 的重复请求 → 返回原始结果 +- Redis 不可用 → PostgreSQL 兜底 +- PROCESSING 状态超时 → 标记为 FAILED + +### 9.3 前端测试 + +| 类型 | 工具 | 覆盖重点 | +|------|------|---------| +| 组件测试 | Vitest + React Testing Library | StarRating、StarButton、ReviewTaskTable、TokenTable | +| Hook 测试 | renderHook | useReviewTasks、useRateSkill、useStarSkill、useTokens | +| 页面测试 | Vitest + MSW | 审核中心交互、评分收藏交互、Token 管理交互 | + +--- + +## 10. 风险与应对 + +| 风险 | 应对 | +|------|------| +| 审核流程需求变更 | 状态机设计灵活,支持扩展新状态 | +| 乐观锁冲突频繁 | 前端提示用户刷新重试,后端记录冲突日志监控 | +| 评分重算性能问题 | Redis 分布式锁防止重复重算,定时任务兜底修正 | +| ClawHub CLI 协议细节不一致 | 兼容层独立 Controller,协议回归测试覆盖 | +| Device Flow 用户体验问题 | 提供手动 Token 配置备选方案 | +| 幂等去重 Redis 不可用 | PostgreSQL 兜底,容错设计 | + +--- + +## 11. 总结 + +Phase 3 在 Phase 2 的基础上,建立了完整的治理体系、CLI 生态和社交功能: + +**核心价值:** +1. **审核流程** - 所有技能发布必须经过审核,建立分级审核权限体系,确保技能质量 +2. **提升机制** - 团队技能可以申请提升到全局空间,经平台管理员审核,建立技能晋升通道 +3. **评分收藏** - 用户可以对技能评分和收藏,建立技能质量反馈机制 +4. **CLI API** - 提供 skillhub CLI 所需的核心 API,支持 Web 授权流程,用户体验最佳 +5. **ClawHub 兼容层** - 实现 ClawHub CLI 协议兼容,支持现有 ClawHub CLI 用户无缝迁移 +6. **幂等去重** - 基于 Redis + PostgreSQL 的双层幂等机制,保证写操作的幂等性 +7. **管理后台** - 用户管理、角色分配、审计日志查询,建立完整的运营能力 + +**技术亮点:** +- 乐观锁 + partial unique index 防止并发冲突 +- Redis + PostgreSQL 双层幂等去重 +- OAuth Device Flow 提供最佳 CLI 认证体验 +- Canonical slug 映射实现 ClawHub 兼容 +- 异步事件 + 分布式锁实现计数器更新 +- 严格分级权限体系保持团队自治 + +**交付策略:** +- 5 个 Chunk 渐进式交付,每个 Chunk 都有明确的验收标准 +- 审核流程优先,建立治理能力 +- CLI API 和兼容层后置,不阻塞核心功能 +- 前后端并行开发,提高交付效率 + +Phase 3 完成后,skillhub 将具备完整的企业内部技能注册中心能力,支持 Web 端和 CLI 端的完整工作流,兼容 ClawHub CLI,建立完善的治理体系和社交功能。