# Phase 3: 审核流程 + CLI API + 评分收藏 + 兼容层 Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 Phase 2 基础上建立完整的治理体系、CLI 生态和社交功能,实现审核流程、提升机制、评分收藏、CLI API、ClawHub 兼容层和管理后台。 **Architecture:** - 审核流程:乐观锁 + partial unique index 防止并发冲突,分级权限控制 - 评分收藏:异步事件 + Redis 分布式锁更新计数器 - CLI API:OAuth Device Flow 标准认证流程 - 兼容层: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 - 新增:react-rating-stars-component(评分组件) --- ## Chunk 1: 审核流程核心(后端) **范围:** 数据库迁移 + 审核流程 + 提升流程 + 乐观锁 + 分级权限 **验收标准:** 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. 所有测试通过 ### Task 1: 数据库迁移脚本 **Files:** - Create: `server/skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql` - [ ] **Step 1: 创建数据库迁移脚本** 创建 `V3__phase3_review_social_tables.sql`,包含 5 个新表: ```sql -- review_task 表 CREATE TABLE review_task ( id BIGSERIAL PRIMARY KEY, skill_version_id BIGINT NOT NULL REFERENCES skill_version(id), namespace_id BIGINT NOT NULL REFERENCES namespace(id), status VARCHAR(32) NOT NULL DEFAULT 'PENDING', version INT NOT NULL DEFAULT 1, 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 ); CREATE INDEX idx_review_task_namespace_status ON review_task(namespace_id, status); CREATE INDEX idx_review_task_submitted_by_status ON review_task(submitted_by, status); CREATE UNIQUE INDEX idx_review_task_version_pending ON review_task(skill_version_id) WHERE status = 'PENDING'; -- promotion_request 表 CREATE TABLE promotion_request ( id BIGSERIAL PRIMARY KEY, source_skill_id BIGINT NOT NULL REFERENCES skill(id), source_version_id BIGINT NOT NULL REFERENCES skill_version(id), target_namespace_id BIGINT NOT NULL REFERENCES namespace(id), target_skill_id BIGINT REFERENCES skill(id), status VARCHAR(32) NOT NULL DEFAULT 'PENDING', version INT NOT NULL DEFAULT 1, 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 ); CREATE INDEX idx_promotion_request_source_skill ON promotion_request(source_skill_id); CREATE INDEX idx_promotion_request_status ON promotion_request(status); CREATE UNIQUE INDEX idx_promotion_request_version_pending ON promotion_request(source_version_id) WHERE status = 'PENDING'; -- skill_star 表 CREATE TABLE skill_star ( id BIGSERIAL PRIMARY KEY, skill_id BIGINT NOT NULL REFERENCES skill(id), user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_id, user_id) ); CREATE INDEX idx_skill_star_user_id ON skill_star(user_id); CREATE INDEX idx_skill_star_skill_id ON skill_star(skill_id); -- skill_rating 表 CREATE TABLE skill_rating ( id BIGSERIAL PRIMARY KEY, skill_id BIGINT NOT NULL REFERENCES skill(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, UNIQUE(skill_id, user_id) ); CREATE INDEX idx_skill_rating_skill_id ON skill_rating(skill_id); -- idempotency_record 表 CREATE TABLE idempotency_record ( request_id VARCHAR(64) PRIMARY KEY, resource_type VARCHAR(64) NOT NULL, resource_id BIGINT, status VARCHAR(32) NOT NULL, response_status_code INT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL ); CREATE INDEX idx_idempotency_record_expires_at ON idempotency_record(expires_at); CREATE INDEX idx_idempotency_record_status_created ON idempotency_record(status, created_at); ``` - [ ] **Step 2: 验证迁移脚本语法** 运行:`cd server && ./mvnw flyway:validate` 预期:SUCCESS - [ ] **Step 3: 执行数据库迁移** 运行:`cd server && ./mvnw flyway:migrate` 预期:V3 迁移成功,5 个新表创建 - [ ] **Step 4: 验证表结构** 运行:`psql -d skillhub -c "\d review_task"` 预期:显示表结构,包含 partial unique index - [ ] **Step 5: Commit** ```bash git add server/skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql git commit -m "feat(db): add Phase 3 database migration - Add review_task table with partial unique index - Add promotion_request table - Add skill_star and skill_rating tables - Add idempotency_record table" ``` ### Task 2: 审核流程领域实体 **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskStatus.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java` - [ ] **Step 1: 创建 ReviewTaskStatus 枚举** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskStatus.java`: ```java package com.iflytek.skillhub.domain.review; public enum ReviewTaskStatus { PENDING, APPROVED, REJECTED } ``` - [ ] **Step 2: 创建 ReviewTask 实体** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java`: ```java package com.iflytek.skillhub.domain.review; import jakarta.persistence.*; import java.time.Instant; @Entity @Table(name = "review_task") public class ReviewTask { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "skill_version_id", nullable = false) private Long skillVersionId; @Column(name = "namespace_id", nullable = false) private Long namespaceId; @Enumerated(EnumType.STRING) @Column(nullable = false) private ReviewTaskStatus status = ReviewTaskStatus.PENDING; @Version @Column(nullable = false) private Integer version = 1; @Column(name = "submitted_by", nullable = false) private String submittedBy; @Column(name = "reviewed_by") private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @Column(name = "submitted_at", nullable = false) private Instant submittedAt = Instant.now(); @Column(name = "reviewed_at") private Instant reviewedAt; // Constructors protected ReviewTask() {} public ReviewTask(Long skillVersionId, Long namespaceId, String submittedBy) { this.skillVersionId = skillVersionId; this.namespaceId = namespaceId; this.submittedBy = submittedBy; } // Getters and Setters public Long getId() { return id; } public Long getSkillVersionId() { return skillVersionId; } public Long getNamespaceId() { return namespaceId; } public ReviewTaskStatus getStatus() { return status; } public void setStatus(ReviewTaskStatus status) { this.status = status; } public Integer getVersion() { return version; } public Long getSubmittedBy() { return submittedBy; } public Long getReviewedBy() { return 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; } public Instant getReviewedAt() { return reviewedAt; } public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } } ``` - [ ] **Step 3: 创建 PromotionRequest 实体** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java`: ```java package com.iflytek.skillhub.domain.review; import jakarta.persistence.*; import java.time.Instant; @Entity @Table(name = "promotion_request") public class PromotionRequest { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "source_skill_id", nullable = false) private Long sourceSkillId; @Column(name = "source_version_id", nullable = false) private Long sourceVersionId; @Column(name = "target_namespace_id", nullable = false) private Long targetNamespaceId; @Column(name = "target_skill_id") private Long targetSkillId; @Enumerated(EnumType.STRING) @Column(nullable = false) private ReviewTaskStatus status = ReviewTaskStatus.PENDING; @Version @Column(nullable = false) private Integer version = 1; @Column(name = "submitted_by", nullable = false) private String submittedBy; @Column(name = "reviewed_by") private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @Column(name = "submitted_at", nullable = false) private Instant submittedAt = Instant.now(); @Column(name = "reviewed_at") private Instant reviewedAt; // Constructors protected PromotionRequest() {} public PromotionRequest(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String submittedBy) { this.sourceSkillId = sourceSkillId; this.sourceVersionId = sourceVersionId; this.targetNamespaceId = targetNamespaceId; this.submittedBy = submittedBy; } // Getters and Setters (similar to ReviewTask) public Long getId() { return id; } public Long getSourceSkillId() { return sourceSkillId; } public Long getSourceVersionId() { return sourceVersionId; } public Long getTargetNamespaceId() { return targetNamespaceId; } public Long getTargetSkillId() { return targetSkillId; } public void setTargetSkillId(Long targetSkillId) { this.targetSkillId = targetSkillId; } public ReviewTaskStatus getStatus() { return status; } public void setStatus(ReviewTaskStatus status) { this.status = status; } public Integer getVersion() { return version; } public Long getSubmittedBy() { return submittedBy; } public Long getReviewedBy() { return 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; } public Instant getReviewedAt() { return reviewedAt; } public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } } ``` - [ ] **Step 4: 编译验证** 运行:`cd server && ./mvnw compile` 预期:编译成功 - [ ] **Step 5: Commit** ```bash git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ git commit -m "feat(domain): add review entities - Add ReviewTaskStatus enum - Add ReviewTask entity with optimistic locking - Add PromotionRequest entity" ``` ### Task 3: Repository 层实现 **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java` - Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java` - Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java` - [ ] **Step 1: 创建 ReviewTaskRepository 接口** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java`: ```java package com.iflytek.skillhub.domain.review; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.Optional; public interface ReviewTaskRepository { ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); void delete(ReviewTask reviewTask); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Integer expectedVersion); } ``` - [ ] **Step 2: 创建 ReviewTaskJpaRepository 实现** 创建 `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java`: ```java package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.Instant; import java.util.Optional; @Repository public interface ReviewTaskJpaRepository extends JpaRepository, ReviewTaskRepository { Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); @Modifying @Query(""" UPDATE ReviewTask t SET t.status = :status, t.reviewedBy = :reviewedBy, t.reviewComment = :reviewComment, 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("reviewedBy") String reviewedBy, @Param("reviewComment") String reviewComment, @Param("expectedVersion") Integer expectedVersion); } ``` - [ ] **Step 3: 创建 PromotionRequestRepository 接口和实现** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java`: ```java package com.iflytek.skillhub.domain.review; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.Optional; public interface PromotionRequestRepository { PromotionRequest save(PromotionRequest request); Optional findById(Long id); Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Long targetSkillId, Integer expectedVersion); } ``` 创建 `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java`: ```java package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface PromotionRequestJpaRepository extends JpaRepository, PromotionRequestRepository { Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); @Modifying @Query(""" UPDATE PromotionRequest p SET p.status = :status, p.reviewedBy = :reviewedBy, p.reviewComment = :reviewComment, p.targetSkillId = :targetSkillId, p.reviewedAt = CURRENT_TIMESTAMP, p.version = p.version + 1 WHERE p.id = :id AND p.version = :expectedVersion """) int updateStatusWithVersion(@Param("id") Long id, @Param("status") ReviewTaskStatus status, @Param("reviewedBy") String reviewedBy, @Param("reviewComment") String reviewComment, @Param("targetSkillId") Long targetSkillId, @Param("expectedVersion") Integer expectedVersion); } ``` - [ ] **Step 4: 编译验证** 运行:`cd server && ./mvnw compile` 预期:编译成功 - [ ] **Step 5: Commit** ```bash git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/*Repository.java git add server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/*Repository.java git commit -m "feat(repo): add review repositories - Add ReviewTaskRepository with optimistic lock update - Add PromotionRequestRepository - Implement JPA repositories in infra module" ``` ### Task 4: 审核权限检查器 **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java` - Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java` - [ ] **Step 1: 编写权限检查器测试** 创建测试文件,验证权限逻辑: ```java package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import org.junit.jupiter.api.Test; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; class ReviewPermissionCheckerTest { private final ReviewPermissionChecker checker = new ReviewPermissionChecker(); @Test void cannotReviewOwnSubmission() { String userId = 1L; ReviewTask task = createTask(1L, NamespaceType.TEAM, userId); boolean canReview = checker.canReview(task, userId, Map.of(), Set.of()); assertFalse(canReview, "Cannot review own submission"); } @Test void teamAdminCanReviewTeamSkill() { ReviewTask task = createTask(1L, NamespaceType.TEAM, 2L); boolean canReview = checker.canReview(task, 1L, Map.of(1L, NamespaceRole.ADMIN), Set.of()); assertTrue(canReview, "Team ADMIN can review team skill"); } @Test void skillAdminCanReviewGlobalSkill() { ReviewTask task = createTask(1L, NamespaceType.GLOBAL, 2L); boolean canReview = checker.canReview(task, 1L, Map.of(), Set.of("SKILL_ADMIN")); assertTrue(canReview, "SKILL_ADMIN can review global skill"); } @Test void skillAdminCannotReviewTeamSkill() { ReviewTask task = createTask(1L, NamespaceType.TEAM, 2L); boolean canReview = checker.canReview(task, 1L, Map.of(), Set.of("SKILL_ADMIN")); assertFalse(canReview, "SKILL_ADMIN cannot review team skill"); } private ReviewTask createTask(Long namespaceId, NamespaceType type, String submittedBy) { // Mock ReviewTask with namespace info return new ReviewTask(1L, namespaceId, submittedBy); } } ``` - [ ] **Step 2: 运行测试确认失败** 运行:`cd server && ./mvnw test -Dtest=ReviewPermissionCheckerTest` 预期:测试失败(类不存在) - [ ] **Step 3: 实现权限检查器** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java`: ```java package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import org.springframework.stereotype.Component; import java.util.Map; import java.util.Set; @Component public class ReviewPermissionChecker { public boolean canReview(ReviewTask task, String userId, Map userNamespaceRoles, Set platformRoles) { // Cannot review own submission if (task.getSubmittedBy().equals(userId)) { return false; } // Get namespace type (需要从 task 中获取,这里简化处理) NamespaceType namespaceType = getNamespaceType(task.getNamespaceId()); // Global namespace: only SKILL_ADMIN or SUPER_ADMIN if (namespaceType == NamespaceType.GLOBAL) { return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); } // Team namespace: namespace ADMIN or OWNER NamespaceRole role = userNamespaceRoles.get(task.getNamespaceId()); return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; } public boolean canReviewPromotion(PromotionRequest request, String userId, Set platformRoles) { // Only SKILL_ADMIN or SUPER_ADMIN can review promotion return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); } private NamespaceType getNamespaceType(Long namespaceId) { // TODO: 实际实现需要查询 namespace 表 // 这里简化处理,假设 id=1 是 GLOBAL return namespaceId == 1L ? NamespaceType.GLOBAL : NamespaceType.TEAM; } } ``` - [ ] **Step 4: 运行测试确认通过** 运行:`cd server && ./mvnw test -Dtest=ReviewPermissionCheckerTest` 预期:所有测试通过 - [ ] **Step 5: Commit** ```bash git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java git add server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java git commit -m "feat(review): add permission checker with tests - Implement ReviewPermissionChecker - Add unit tests for permission logic - Verify team admin can only review team skills - Verify SKILL_ADMIN can only review global skills" ``` ### Task 5: 审核服务实现(核心逻辑) **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java` - Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java` 由于篇幅限制,这里提供关键方法的实现框架: - [ ] **Step 1: 创建 ReviewService 接口** ```java package com.iflytek.skillhub.domain.review; public interface ReviewService { 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); } ``` - [ ] **Step 2-5: 实现服务方法(TDD 循环)** 参考设计文档第 2.1 节的流程图实现每个方法,包括: - 乐观锁更新 - 状态机转换 - 事件发布 - 审计日志 - [ ] **Step 6: Commit** ```bash git commit -m "feat(review): implement review service - Add submitReview with duplicate check - Add approveReview with optimistic locking - Add rejectReview with reason recording - Add withdrawReview with PENDING check" ``` ### Task 6: PromotionService(提升流程服务) **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionRequest.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionRequestRepository.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionStatus.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/service/PromotionService.java` - Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/service/PromotionServiceTest.java` - [ ] **Step 1: 创建 PromotionStatus 枚举** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionStatus.java`: ```java package com.iflytek.skillhub.domain.promotion; public enum PromotionStatus { PENDING, APPROVED, REJECTED, WITHDRAWN } ``` - [ ] **Step 2: 创建 PromotionRequest 实体** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionRequest.java`: ```java package com.iflytek.skillhub.domain.promotion; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity @Table(name = "promotion_request") public class PromotionRequest { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "source_skill_id", nullable = false) private Long sourceSkillId; @Column(name = "source_version_id", nullable = false) private Long sourceVersionId; @Column(name = "target_namespace_id", nullable = false) private Long targetNamespaceId; @Column(name = "target_skill_id") private Long targetSkillId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 32) private PromotionStatus status = PromotionStatus.PENDING; @Version @Column(nullable = false) private Integer version = 1; @Column(name = "submitted_by", nullable = false) private String submittedBy; @Column(name = "reviewed_by") private String reviewedBy; @Column(name = "review_comment", columnDefinition = "TEXT") private String reviewComment; @Column(name = "submitted_at", nullable = false) private LocalDateTime submittedAt = LocalDateTime.now(); @Column(name = "reviewed_at") private LocalDateTime reviewedAt; // Constructors public PromotionRequest() {} public PromotionRequest(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String submittedBy) { this.sourceSkillId = sourceSkillId; this.sourceVersionId = sourceVersionId; this.targetNamespaceId = targetNamespaceId; this.submittedBy = submittedBy; this.submittedAt = LocalDateTime.now(); } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Long getSourceSkillId() { return sourceSkillId; } public void setSourceSkillId(Long sourceSkillId) { this.sourceSkillId = sourceSkillId; } public Long getSourceVersionId() { return sourceVersionId; } public void setSourceVersionId(Long sourceVersionId) { this.sourceVersionId = sourceVersionId; } public Long getTargetNamespaceId() { return targetNamespaceId; } public void setTargetNamespaceId(Long targetNamespaceId) { this.targetNamespaceId = targetNamespaceId; } public Long getTargetSkillId() { return targetSkillId; } public void setTargetSkillId(Long targetSkillId) { this.targetSkillId = targetSkillId; } public PromotionStatus getStatus() { return status; } public void setStatus(PromotionStatus status) { this.status = status; } public Integer getVersion() { return version; } public void setVersion(Integer version) { this.version = version; } public Long getSubmittedBy() { return submittedBy; } public void setSubmittedBy(String submittedBy) { this.submittedBy = submittedBy; } public Long getReviewedBy() { return reviewedBy; } public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } public String getReviewComment() { return reviewComment; } public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } public LocalDateTime getSubmittedAt() { return submittedAt; } public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } public LocalDateTime getReviewedAt() { return reviewedAt; } public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } } ``` - [ ] **Step 3: 创建 PromotionRequestRepository** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionRequestRepository.java`: ```java package com.iflytek.skillhub.domain.promotion; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface PromotionRequestRepository extends JpaRepository { @Query("SELECT pr FROM PromotionRequest pr WHERE pr.sourceSkillId = :sourceSkillId AND pr.sourceVersionId = :sourceVersionId AND pr.status = 'PENDING'") Optional findPendingBySourceVersion(Long sourceSkillId, Long sourceVersionId); @Query("SELECT pr FROM PromotionRequest pr WHERE pr.targetNamespaceId = :targetNamespaceId AND pr.status = :status") Page findByTargetNamespaceAndStatus(Long targetNamespaceId, PromotionStatus status, Pageable pageable); @Query("SELECT pr FROM PromotionRequest pr WHERE pr.submittedBy = :userId") Page findBySubmittedBy(String userId, Pageable pageable); } ``` - [ ] **Step 4: 创建 PromotionService** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/service/PromotionService.java`: ```java package com.iflytek.skillhub.domain.promotion.service; import com.iflytek.skillhub.domain.event.PromotionApprovedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.promotion.PromotionRequest; import com.iflytek.skillhub.domain.promotion.PromotionRequestRepository; import com.iflytek.skillhub.domain.promotion.PromotionStatus; import com.iflytek.skillhub.domain.skill.*; import jakarta.persistence.OptimisticLockException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @Service public class PromotionService { private final PromotionRequestRepository promotionRequestRepository; private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillFileRepository skillFileRepository; private final NamespaceRepository namespaceRepository; private final ApplicationEventPublisher eventPublisher; public PromotionService( PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, ApplicationEventPublisher eventPublisher) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; this.namespaceRepository = namespaceRepository; this.eventPublisher = eventPublisher; } @Transactional 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")); SkillVersion sourceVersion = skillVersionRepository.findById(sourceVersionId) .orElseThrow(() -> new IllegalArgumentException("Source version not found")); if (!sourceVersion.getSkillId().equals(sourceSkillId)) { throw new IllegalArgumentException("Version does not belong to skill"); } // 2. Check if version is published if (sourceVersion.getStatus() != SkillVersionStatus.PUBLISHED) { throw new IllegalArgumentException("Only published versions can be promoted"); } // 3. Check if target namespace exists and is global Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId) .orElseThrow(() -> new IllegalArgumentException("Target namespace not found")); if (!targetNamespace.isGlobal()) { throw new IllegalArgumentException("Can only promote to global namespace"); } // 4. Check if there's already a pending promotion promotionRequestRepository.findPendingBySourceVersion(sourceSkillId, sourceVersionId) .ifPresent(pr -> { throw new IllegalArgumentException("A pending promotion request already exists"); }); // 5. Create promotion request PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); return promotionRequestRepository.save(request); } @Transactional 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")); // 2. Check status if (request.getStatus() != PromotionStatus.PENDING) { throw new IllegalStateException("Promotion request is not pending"); } // 3. Load source skill and version Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) .orElseThrow(() -> new IllegalArgumentException("Source skill not found")); SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) .orElseThrow(() -> new IllegalArgumentException("Source version not found")); // 4. Check if target skill already exists Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()) .orElseThrow(() -> new IllegalArgumentException("Target namespace not found")); Skill targetSkill = skillRepository.findByNamespaceIdAndSlug(targetNamespace.getId(), sourceSkill.getSlug()) .orElseGet(() -> { // Create new skill in global namespace Skill newSkill = new Skill( targetNamespace.getId(), sourceSkill.getSlug(), sourceSkill.getDisplayName(), sourceSkill.getSummary(), sourceSkill.getVisibility(), reviewerId ); return skillRepository.save(newSkill); }); // 5. Copy version SkillVersion targetVersion = new SkillVersion( targetSkill.getId(), sourceVersion.getVersionNumber(), sourceVersion.getMetadataJson(), reviewerId ); targetVersion.setStatus(SkillVersionStatus.PUBLISHED); targetVersion.setFileCount(sourceVersion.getFileCount()); targetVersion.setTotalSize(sourceVersion.getTotalSize()); targetVersion = skillVersionRepository.save(targetVersion); // 6. Copy files List sourceFiles = skillFileRepository.findByVersionId(sourceVersion.getId()); for (SkillFile sourceFile : sourceFiles) { SkillFile targetFile = new SkillFile( targetVersion.getId(), sourceFile.getFilePath(), sourceFile.getFileSize(), sourceFile.getContentType(), sourceFile.getSha256(), sourceFile.getStorageKey() // Reuse same storage key ); skillFileRepository.save(targetFile); } // 7. Update target skill targetSkill.setLatestVersionId(targetVersion.getId()); targetSkill.setUpdatedBy(reviewerId); skillRepository.save(targetSkill); // 8. Update promotion request request.setStatus(PromotionStatus.APPROVED); request.setReviewedBy(reviewerId); request.setReviewComment(comment); request.setReviewedAt(LocalDateTime.now()); request.setTargetSkillId(targetSkill.getId()); try { request = promotionRequestRepository.save(request); } catch (OptimisticLockException e) { throw new IllegalStateException("Promotion request was modified by another process", e); } // 9. Publish event eventPublisher.publishEvent(new PromotionApprovedEvent( request.getId(), targetSkill.getId(), targetVersion.getId(), reviewerId )); return request; } @Transactional 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")); // 2. Check status if (request.getStatus() != PromotionStatus.PENDING) { throw new IllegalStateException("Promotion request is not pending"); } // 3. Update status request.setStatus(PromotionStatus.REJECTED); request.setReviewedBy(reviewerId); request.setReviewComment(comment); request.setReviewedAt(LocalDateTime.now()); try { return promotionRequestRepository.save(request); } catch (OptimisticLockException e) { throw new IllegalStateException("Promotion request was modified by another process", e); } } @Transactional public PromotionRequest withdrawPromotion(Long promotionId, String userId) { // 1. Load promotion request PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found")); // 2. Check ownership if (!request.getSubmittedBy().equals(userId)) { throw new IllegalArgumentException("Only the submitter can withdraw the promotion"); } // 3. Check status if (request.getStatus() != PromotionStatus.PENDING) { throw new IllegalStateException("Only pending promotions can be withdrawn"); } // 4. Update status request.setStatus(PromotionStatus.WITHDRAWN); request.setReviewedAt(LocalDateTime.now()); try { return promotionRequestRepository.save(request); } catch (OptimisticLockException e) { throw new IllegalStateException("Promotion request was modified by another process", e); } } @Transactional(readOnly = true) public Page listPendingPromotions(Long targetNamespaceId, Pageable pageable) { return promotionRequestRepository.findByTargetNamespaceAndStatus( targetNamespaceId, PromotionStatus.PENDING, pageable); } @Transactional(readOnly = true) public Page listMyPromotions(String userId, Pageable pageable) { return promotionRequestRepository.findBySubmittedBy(userId, pageable); } @Transactional(readOnly = true) public PromotionRequest getPromotionDetail(Long promotionId) { return promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found")); } } ``` - [ ] **Step 5: 创建 PromotionApprovedEvent** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java`: ```java package com.iflytek.skillhub.domain.event; public record PromotionApprovedEvent( Long promotionId, Long targetSkillId, Long targetVersionId, String reviewerId ) {} ``` - [ ] **Step 6: 编写 PromotionService 测试** 创建 `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/service/PromotionServiceTest.java`: ```java package com.iflytek.skillhub.domain.promotion.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.promotion.PromotionRequest; import com.iflytek.skillhub.domain.promotion.PromotionRequestRepository; import com.iflytek.skillhub.domain.promotion.PromotionStatus; import com.iflytek.skillhub.domain.skill.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.*; @SpringBootTest @ActiveProfiles("test") @Transactional class PromotionServiceTest { @Autowired private PromotionService promotionService; @Autowired private PromotionRequestRepository promotionRequestRepository; @Autowired private SkillRepository skillRepository; @Autowired private SkillVersionRepository skillVersionRepository; @Autowired private SkillFileRepository skillFileRepository; @Autowired private NamespaceRepository namespaceRepository; private Namespace teamNamespace; private Namespace globalNamespace; private Skill teamSkill; private SkillVersion publishedVersion; private String userId = 1L; @BeforeEach void setUp() { // Create team namespace teamNamespace = new Namespace("team-alpha", "Team Alpha", false, userId); teamNamespace = namespaceRepository.save(teamNamespace); // Create global namespace globalNamespace = new Namespace("global", "Global", true, userId); globalNamespace = namespaceRepository.save(globalNamespace); // Create team skill teamSkill = new Skill(teamNamespace.getId(), "awesome-skill", "Awesome Skill", "A great skill", SkillVisibility.PUBLIC, userId); teamSkill = skillRepository.save(teamSkill); // Create published version publishedVersion = new SkillVersion(teamSkill.getId(), "1.0.0", "{}", userId); publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); publishedVersion = skillVersionRepository.save(publishedVersion); teamSkill.setLatestVersionId(publishedVersion.getId()); skillRepository.save(teamSkill); } @Test void submitPromotion_success() { PromotionRequest request = promotionService.submitPromotion( teamSkill.getId(), publishedVersion.getId(), globalNamespace.getId(), userId); assertThat(request.getId()).isNotNull(); assertThat(request.getStatus()).isEqualTo(PromotionStatus.PENDING); assertThat(request.getSourceSkillId()).isEqualTo(teamSkill.getId()); assertThat(request.getTargetNamespaceId()).isEqualTo(globalNamespace.getId()); } @Test void submitPromotion_duplicatePending_throwsException() { promotionService.submitPromotion(teamSkill.getId(), publishedVersion.getId(), globalNamespace.getId(), userId); assertThatThrownBy(() -> promotionService.submitPromotion( teamSkill.getId(), publishedVersion.getId(), globalNamespace.getId(), userId)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("pending promotion request already exists"); } @Test void approvePromotion_success() { PromotionRequest request = promotionService.submitPromotion( teamSkill.getId(), publishedVersion.getId(), globalNamespace.getId(), userId); PromotionRequest approved = promotionService.approvePromotion(request.getId(), userId, "Looks good"); assertThat(approved.getStatus()).isEqualTo(PromotionStatus.APPROVED); assertThat(approved.getReviewedBy()).isEqualTo(userId); assertThat(approved.getTargetSkillId()).isNotNull(); // Verify target skill created Skill targetSkill = skillRepository.findById(approved.getTargetSkillId()).orElseThrow(); assertThat(targetSkill.getNamespaceId()).isEqualTo(globalNamespace.getId()); assertThat(targetSkill.getSlug()).isEqualTo(teamSkill.getSlug()); } @Test void rejectPromotion_success() { PromotionRequest request = promotionService.submitPromotion( teamSkill.getId(), publishedVersion.getId(), globalNamespace.getId(), userId); PromotionRequest rejected = promotionService.rejectPromotion(request.getId(), userId, "Not ready"); assertThat(rejected.getStatus()).isEqualTo(PromotionStatus.REJECTED); assertThat(rejected.getReviewComment()).isEqualTo("Not ready"); } @Test void withdrawPromotion_success() { PromotionRequest request = promotionService.submitPromotion( teamSkill.getId(), publishedVersion.getId(), globalNamespace.getId(), userId); PromotionRequest withdrawn = promotionService.withdrawPromotion(request.getId(), userId); assertThat(withdrawn.getStatus()).isEqualTo(PromotionStatus.WITHDRAWN); } } ``` --- ### Task 7: ReviewController + PromotionController(审核和提升 REST API) **Files:** - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskRequest.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewActionRequest.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRequestDto.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionActionRequest.java` - [ ] **Step 1: 创建 Review DTOs** 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskRequest.java`: ```java package com.iflytek.skillhub.dto; public record ReviewTaskRequest( Long skillVersionId ) {} ``` 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java`: ```java package com.iflytek.skillhub.dto; import java.time.LocalDateTime; public record ReviewTaskResponse( Long id, Long skillVersionId, String namespace, String skillSlug, String version, String status, String submittedBy, String submittedByUsername, String reviewedBy, String reviewedByUsername, String reviewComment, LocalDateTime submittedAt, LocalDateTime reviewedAt ) {} ``` 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewActionRequest.java`: ```java package com.iflytek.skillhub.dto; public record ReviewActionRequest( String comment ) {} ``` - [ ] **Step 2: 创建 Promotion DTOs** 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRequestDto.java`: ```java package com.iflytek.skillhub.dto; public record PromotionRequestDto( Long sourceSkillId, Long sourceVersionId, String targetNamespace ) {} ``` 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java`: ```java package com.iflytek.skillhub.dto; import java.time.LocalDateTime; public record PromotionResponseDto( Long id, Long sourceSkillId, String sourceNamespace, String sourceSkillSlug, String sourceVersion, String targetNamespace, Long targetSkillId, String status, String submittedBy, String submittedByUsername, String reviewedBy, String reviewedByUsername, String reviewComment, LocalDateTime submittedAt, LocalDateTime reviewedAt ) {} ``` 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionActionRequest.java`: ```java package com.iflytek.skillhub.dto; public record PromotionActionRequest( String comment ) {} ``` - [ ] **Step 3: 创建 ReviewController** 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java`: ```java package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.service.ReviewService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.ReviewActionRequest; import com.iflytek.skillhub.dto.ReviewTaskRequest; import com.iflytek.skillhub.dto.ReviewTaskResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @RestController @RequestMapping("/api/v1/reviews") public class ReviewController { private final ReviewService reviewService; private final ReviewTaskRepository reviewTaskRepository; private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; public ReviewController( ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService) { this.reviewService = reviewService; this.reviewTaskRepository = reviewTaskRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; } @PostMapping public ResponseEntity submitReview( @RequestBody ReviewTaskRequest request, @RequestAttribute("userId") String userId) { ReviewTask task = reviewService.submitReview(request.skillVersionId(), userId); return ResponseEntity.ok(toResponse(task)); } @PostMapping("/{id}/approve") public ResponseEntity approveReview( @PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId) { String comment = request != null ? request.comment() : null; ReviewTask task = reviewService.approveReview(id, userId, comment); return ResponseEntity.ok(toResponse(task)); } @PostMapping("/{id}/reject") public ResponseEntity rejectReview( @PathVariable Long id, @RequestBody ReviewActionRequest request, @RequestAttribute("userId") String userId) { ReviewTask task = reviewService.rejectReview(id, userId, request.comment()); return ResponseEntity.ok(toResponse(task)); } @PostMapping("/{id}/withdraw") public ResponseEntity withdrawReview( @PathVariable Long id, @RequestAttribute("userId") String userId) { ReviewTask task = reviewService.withdrawReview(id, userId); return ResponseEntity.ok(toResponse(task)); } @GetMapping("/pending") public ResponseEntity> listPendingReviews( @RequestParam(required = false) String namespace, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Page tasks; if (namespace != null) { Namespace ns = namespaceRepository.findBySlug(namespace) .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespace)); tasks = reviewTaskRepository.findPendingByNamespace(ns.getId(), PageRequest.of(page, size)); } else { // List all pending reviews user can access boolean isSkillAdmin = rbacService.hasRole(userId, "SKILL_ADMIN"); if (isSkillAdmin) { // SKILL_ADMIN can see all pending reviews tasks = reviewTaskRepository.findByStatus(com.iflytek.skillhub.domain.review.ReviewStatus.PENDING, PageRequest.of(page, size)); } else { // Regular users see reviews for namespaces they manage tasks = Page.empty(); } } return ResponseEntity.ok(tasks.map(this::toResponse)); } @GetMapping("/my-submissions") public ResponseEntity> listMySubmissions( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { Page tasks = reviewTaskRepository.findBySubmittedBy(userId, PageRequest.of(page, size)); return ResponseEntity.ok(tasks.map(this::toResponse)); } @GetMapping("/{id}") public ResponseEntity getReviewDetail( @PathVariable Long id, @RequestAttribute("userId") String userId) { ReviewTask task = reviewTaskRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Review task not found: " + id)); return ResponseEntity.ok(toResponse(task)); } private ReviewTaskResponse toResponse(ReviewTask task) { SkillVersion version = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new IllegalStateException("Version not found")); Skill skill = skillRepository.findById(version.getSkillId()) .orElseThrow(() -> new IllegalStateException("Skill not found")); Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) .orElseThrow(() -> new IllegalStateException("Namespace not found")); UserAccount submitter = userAccountRepository.findById(task.getSubmittedBy()) .orElseThrow(() -> new IllegalStateException("Submitter not found")); String reviewedByUsername = null; if (task.getReviewedBy() != null) { reviewedByUsername = userAccountRepository.findById(task.getReviewedBy()) .map(UserAccount::getUsername) .orElse(null); } return new ReviewTaskResponse( task.getId(), task.getSkillVersionId(), namespace.getSlug(), skill.getSlug(), version.getVersion(), task.getStatus().name(), task.getSubmittedBy(), submitter.getUsername(), task.getReviewedBy(), reviewedByUsername, task.getReviewComment(), task.getSubmittedAt(), task.getReviewedAt() ); } } ``` - [ ] **Step 4: 创建 PromotionController** 创建 `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java`: ```java package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.promotion.PromotionRequest; import com.iflytek.skillhub.domain.promotion.PromotionRequestRepository; import com.iflytek.skillhub.domain.promotion.PromotionStatus; import com.iflytek.skillhub.domain.promotion.service.PromotionService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.PromotionActionRequest; import com.iflytek.skillhub.dto.PromotionRequestDto; import com.iflytek.skillhub.dto.PromotionResponseDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/promotions") public class PromotionController { private final PromotionService promotionService; private final PromotionRequestRepository promotionRequestRepository; private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final NamespaceRepository namespaceRepository; private final UserAccountRepository userAccountRepository; private final RbacService rbacService; public PromotionController( PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, NamespaceRepository namespaceRepository, UserAccountRepository userAccountRepository, RbacService rbacService) { this.promotionService = promotionService; this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.namespaceRepository = namespaceRepository; this.userAccountRepository = userAccountRepository; this.rbacService = rbacService; } @PostMapping public ResponseEntity submitPromotion( @RequestBody PromotionRequestDto request, @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), request.targetNamespace(), userId ); return ResponseEntity.ok(toResponse(promotion)); } @PostMapping("/{id}/approve") public ResponseEntity approvePromotion( @PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request, @RequestAttribute("userId") String userId) { String comment = request != null ? request.comment() : null; PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment); return ResponseEntity.ok(toResponse(promotion)); } @PostMapping("/{id}/reject") public ResponseEntity rejectPromotion( @PathVariable Long id, @RequestBody PromotionActionRequest request, @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionService.rejectPromotion(id, userId, request.comment()); return ResponseEntity.ok(toResponse(promotion)); } @GetMapping("/pending") public ResponseEntity> listPendingPromotions( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) { // Only SKILL_ADMIN can list pending promotions if (!rbacService.hasRole(userId, "SKILL_ADMIN")) { throw new IllegalArgumentException("Only SKILL_ADMIN can list pending promotions"); } Page promotions = promotionRequestRepository.findByStatus( PromotionStatus.PENDING, PageRequest.of(page, size) ); return ResponseEntity.ok(promotions.map(this::toResponse)); } @GetMapping("/{id}") public ResponseEntity getPromotionDetail( @PathVariable Long id, @RequestAttribute("userId") String userId) { PromotionRequest promotion = promotionRequestRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Promotion request not found: " + id)); return ResponseEntity.ok(toResponse(promotion)); } private PromotionResponseDto toResponse(PromotionRequest promotion) { Skill sourceSkill = skillRepository.findById(promotion.getSourceSkillId()) .orElseThrow(() -> new IllegalStateException("Source skill not found")); SkillVersion sourceVersion = skillVersionRepository.findById(promotion.getSourceVersionId()) .orElseThrow(() -> new IllegalStateException("Source version not found")); Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) .orElseThrow(() -> new IllegalStateException("Source namespace not found")); Namespace targetNamespace = namespaceRepository.findById(promotion.getTargetNamespaceId()) .orElseThrow(() -> new IllegalStateException("Target namespace not found")); UserAccount submitter = userAccountRepository.findById(promotion.getSubmittedBy()) .orElseThrow(() -> new IllegalStateException("Submitter not found")); String reviewedByUsername = null; if (promotion.getReviewedBy() != null) { reviewedByUsername = userAccountRepository.findById(promotion.getReviewedBy()) .map(UserAccount::getUsername) .orElse(null); } return new PromotionResponseDto( promotion.getId(), promotion.getSourceSkillId(), sourceNamespace.getSlug(), sourceSkill.getSlug(), sourceVersion.getVersion(), targetNamespace.getSlug(), promotion.getTargetSkillId(), promotion.getStatus().name(), promotion.getSubmittedBy(), submitter.getUsername(), promotion.getReviewedBy(), reviewedByUsername, promotion.getReviewComment(), promotion.getSubmittedAt(), promotion.getReviewedAt() ); } } ``` - [ ] **Step 5: 编写 Controller 集成测试** 创建 `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/ReviewControllerTest.java`: ```java package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.domain.review.ReviewStatus; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.review.service.ReviewService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ReviewController.class) class ReviewControllerTest { @Autowired private MockMvc mockMvc; @MockBean private ReviewService reviewService; @MockBean private ReviewTaskRepository reviewTaskRepository; @Test void submitReview_shouldReturn200() throws Exception { ReviewTask task = new ReviewTask(1L, 1L, 1L); task.setId(1L); when(reviewService.submitReview(anyLong(), anyLong())).thenReturn(task); mockMvc.perform(post("/api/v1/reviews") .contentType(MediaType.APPLICATION_JSON) .content("{\"skillVersionId\": 1}") .requestAttr("userId", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)); } @Test void approveReview_shouldReturn200() throws Exception { ReviewTask task = new ReviewTask(1L, 1L, 1L); task.setId(1L); task.setStatus(ReviewStatus.APPROVED); when(reviewService.approveReview(anyLong(), anyLong(), anyString())).thenReturn(task); mockMvc.perform(post("/api/v1/reviews/1/approve") .contentType(MediaType.APPLICATION_JSON) .content("{\"comment\": \"LGTM\"}") .requestAttr("userId", 2L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")); } } ``` --- ### Task 8: 发布流程改造(修改 SkillPublishService) **Files:** - Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java` - Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java` - Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceReviewTest.java` - [ ] **Step 1: 修改 SkillVersionStatus 枚举(已完成)** 确认 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java` 已包含 `PENDING_REVIEW`: ```java package com.iflytek.skillhub.domain.skill; public enum SkillVersionStatus { DRAFT, PENDING_REVIEW, PUBLISHED, REJECTED } ``` - [ ] **Step 2: 修改 SkillPublishService 创建 PENDING_REVIEW 版本** 修改 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java`: 在 `publishFromEntries` 方法中,找到创建 `SkillVersion` 的代码(约第 120 行): ```java // 原代码: SkillVersion version = new SkillVersion( skill.getId(), metadata.version(), SkillVersionStatus.PUBLISHED, // <-- 修改这里 publisherId ); ``` 修改为: ```java // 新代码: SkillVersion version = new SkillVersion( skill.getId(), metadata.version(), SkillVersionStatus.PENDING_REVIEW, // <-- 改为 PENDING_REVIEW publisherId ); ``` - [ ] **Step 3: 在 SkillPublishService 中自动创建 ReviewTask** 在 `SkillPublishService` 类中添加依赖注入: ```java // 在类的字段声明部分添加: private final ReviewTaskRepository reviewTaskRepository; // 在构造函数中添加参数: public SkillPublishService( NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, ObjectStorageService objectStorageService, SkillPackageValidator skillPackageValidator, SkillMetadataParser skillMetadataParser, PrePublishValidator prePublishValidator, ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, ReviewTaskRepository reviewTaskRepository) { // <-- 新增 this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; this.objectStorageService = objectStorageService; this.skillPackageValidator = skillPackageValidator; this.skillMetadataParser = skillMetadataParser; this.prePublishValidator = prePublishValidator; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.reviewTaskRepository = reviewTaskRepository; // <-- 新增 } ``` 在 `publishFromEntries` 方法的最后,在发布事件之前(约第 189 行),添加创建 ReviewTask 的代码: ```java // 在 eventPublisher.publishEvent(...) 之前添加: // 12.5. Auto-create ReviewTask ReviewTask reviewTask = new ReviewTask( version.getId(), namespace.getId(), publisherId ); reviewTaskRepository.save(reviewTask); ``` 同时需要在文件顶部添加 import: ```java import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; ``` - [ ] **Step 4: 修改 SkillPublishedEvent 的触发时机** 在 `publishFromEntries` 方法中,找到发布事件的代码(约第 190 行): ```java // 原代码: eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); ``` 注释掉或删除这行代码,因为现在版本状态是 PENDING_REVIEW,不应该立即触发 SkillPublishedEvent。该事件将在审核通过后由 ReviewApprovedEvent 监听器触发。 ```java // 13. Publish SkillPublishedEvent - 移除,改为审核通过后触发 // eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); ``` - [ ] **Step 5: 编写测试验证发布流程改造** 创建 `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceReviewTest.java`: ```java package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.storage.ObjectStorageService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import java.io.ByteArrayInputStream; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillPublishServiceReviewTest { @Mock private NamespaceRepository namespaceRepository; @Mock private NamespaceMemberRepository namespaceMemberRepository; @Mock private SkillRepository skillRepository; @Mock private SkillVersionRepository skillVersionRepository; @Mock private SkillFileRepository skillFileRepository; @Mock private ObjectStorageService objectStorageService; @Mock private SkillPackageValidator skillPackageValidator; @Mock private SkillMetadataParser skillMetadataParser; @Mock private PrePublishValidator prePublishValidator; @Mock private ApplicationEventPublisher eventPublisher; @Mock private ReviewTaskRepository reviewTaskRepository; @InjectMocks private SkillPublishService skillPublishService; @Test void publishFromEntries_shouldCreatePendingReviewVersion() { // Arrange String publisherId = 100L; String namespaceSlug = "test-ns"; Namespace namespace = new Namespace(); namespace.setId(1L); namespace.setSlug(namespaceSlug); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, publisherId)) .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.namespace.NamespaceMember.class))); SkillMetadata metadata = new SkillMetadata("test-skill", "1.0.0", "Test Skill", "Description", null, null, null); when(skillMetadataParser.parse(any())).thenReturn(metadata); Skill skill = new Skill(); skill.setId(10L); skill.setSlug("test-skill"); when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(Optional.of(skill)); SkillVersion version = new SkillVersion(); version.setId(20L); version.setStatus(SkillVersionStatus.PENDING_REVIEW); when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(version); PackageEntry entry = new PackageEntry("skill.json", "application/json", 100L, new ByteArrayInputStream("{}".getBytes())); List entries = List.of(entry); // Act SkillVersion result = skillPublishService.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC); // Assert assertEquals(SkillVersionStatus.PENDING_REVIEW, result.getStatus()); verify(reviewTaskRepository, times(1)).save(any(ReviewTask.class)); verify(eventPublisher, never()).publishEvent(any()); // 不应该触发 SkillPublishedEvent } @Test void publishFromEntries_shouldAutoCreateReviewTask() { // Arrange String publisherId = 100L; String namespaceSlug = "test-ns"; Namespace namespace = new Namespace(); namespace.setId(1L); namespace.setSlug(namespaceSlug); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, publisherId)) .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.namespace.NamespaceMember.class))); SkillMetadata metadata = new SkillMetadata("test-skill", "1.0.0", "Test Skill", "Description", null, null, null); when(skillMetadataParser.parse(any())).thenReturn(metadata); Skill skill = new Skill(); skill.setId(10L); skill.setSlug("test-skill"); when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(Optional.of(skill)); SkillVersion version = new SkillVersion(); version.setId(20L); when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(version); PackageEntry entry = new PackageEntry("skill.json", "application/json", 100L, new ByteArrayInputStream("{}".getBytes())); List entries = List.of(entry); // Act skillPublishService.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC); // Assert ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewTask.class); verify(reviewTaskRepository).save(captor.capture()); ReviewTask savedTask = captor.getValue(); assertEquals(20L, savedTask.getSkillVersionId()); assertEquals(1L, savedTask.getNamespaceId()); assertEquals(publisherId, savedTask.getSubmittedBy()); } } ``` - [ ] **Step 6: 运行测试验证** ```bash cd /Users/xudongsun/github/skillhub/server ./mvnw test -Dtest=SkillPublishServiceReviewTest ``` --- ### Task 9: 审核事件监听器(Event Listeners) **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/event/ReviewEventListener.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/event/PromotionEventListener.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditAction.java` - [ ] **Step 1: 创建审核事件类** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java`: ```java package com.iflytek.skillhub.domain.event; public record ReviewApprovedEvent( Long reviewTaskId, Long skillId, Long versionId, String reviewerId, String comment ) {} ``` 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java`: ```java package com.iflytek.skillhub.domain.event; public record ReviewRejectedEvent( Long reviewTaskId, Long skillId, Long versionId, String reviewerId, String comment ) {} ``` 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java`: ```java package com.iflytek.skillhub.domain.event; public record PromotionApprovedEvent( Long promotionRequestId, Long sourceSkillId, Long sourceVersionId, Long targetSkillId, String reviewerId ) {} ``` - [ ] **Step 2: 创建 AuditLog 实体和枚举** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditAction.java`: ```java package com.iflytek.skillhub.domain.audit; public enum AuditAction { REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, REVIEW_WITHDRAWN, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, PROMOTION_WITHDRAWN, SKILL_PUBLISHED, SKILL_ARCHIVED } ``` 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java`: ```java package com.iflytek.skillhub.domain.audit; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity @Table(name = "audit_log") public class AuditLog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 64) private AuditAction action; @Column(name = "entity_type", nullable = false, length = 64) private String entityType; @Column(name = "entity_id", nullable = false) private Long entityId; @Column(name = "user_id", nullable = false) private String userId; @Column(name = "details", columnDefinition = "TEXT") private String details; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); // Constructors public AuditLog() {} public AuditLog(AuditAction action, String entityType, Long entityId, String userId, String details) { this.action = action; this.entityType = entityType; this.entityId = entityId; this.userId = userId; this.details = details; this.createdAt = LocalDateTime.now(); } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public AuditAction getAction() { return action; } public void setAction(AuditAction action) { this.action = action; } public String getEntityType() { return entityType; } public void setEntityType(String entityType) { this.entityType = entityType; } public Long getEntityId() { return entityId; } public void setEntityId(Long entityId) { this.entityId = entityId; } public Long getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public String getDetails() { return details; } public void setDetails(String details) { this.details = details; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } ``` 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java`: ```java package com.iflytek.skillhub.domain.audit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface AuditLogRepository extends JpaRepository { } ``` - [ ] **Step 3: 创建 ReviewEventListener** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/event/ReviewEventListener.java`: ```java package com.iflytek.skillhub.domain.review.event; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditAction; import com.iflytek.skillhub.domain.audit.AuditLog; import com.iflytek.skillhub.domain.audit.AuditLogRepository; import com.iflytek.skillhub.domain.event.ReviewApprovedEvent; import com.iflytek.skillhub.domain.event.ReviewRejectedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; import java.util.HashMap; import java.util.Map; @Component public class ReviewEventListener { private final SkillVersionRepository skillVersionRepository; private final AuditLogRepository auditLogRepository; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; public ReviewEventListener( SkillVersionRepository skillVersionRepository, AuditLogRepository auditLogRepository, ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper) { this.skillVersionRepository = skillVersionRepository; this.auditLogRepository = auditLogRepository; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async("skillhubEventExecutor") @Transactional public void onReviewApproved(ReviewApprovedEvent event) { // 1. Update version status to PUBLISHED SkillVersion version = skillVersionRepository.findById(event.versionId()) .orElseThrow(() -> new IllegalArgumentException("Version not found: " + event.versionId())); version.setStatus(SkillVersionStatus.PUBLISHED); skillVersionRepository.save(version); // 2. Write audit log Map details = new HashMap<>(); details.put("reviewTaskId", event.reviewTaskId()); details.put("versionId", event.versionId()); details.put("comment", event.comment()); try { String detailsJson = objectMapper.writeValueAsString(details); AuditLog log = new AuditLog( AuditAction.REVIEW_APPROVED, "skill", event.skillId(), event.reviewerId(), detailsJson ); auditLogRepository.save(log); } catch (Exception e) { // Log error but don't fail the transaction System.err.println("Failed to write audit log: " + e.getMessage()); } // 3. Trigger SkillPublishedEvent for search indexing eventPublisher.publishEvent(new SkillPublishedEvent( event.skillId(), event.versionId(), event.reviewerId() )); } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async("skillhubEventExecutor") @Transactional public void onReviewRejected(ReviewRejectedEvent event) { // 1. Update version status to REJECTED SkillVersion version = skillVersionRepository.findById(event.versionId()) .orElseThrow(() -> new IllegalArgumentException("Version not found: " + event.versionId())); version.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(version); // 2. Write audit log Map details = new HashMap<>(); details.put("reviewTaskId", event.reviewTaskId()); details.put("versionId", event.versionId()); details.put("comment", event.comment()); try { String detailsJson = objectMapper.writeValueAsString(details); AuditLog log = new AuditLog( AuditAction.REVIEW_REJECTED, "skill", event.skillId(), event.reviewerId(), detailsJson ); auditLogRepository.save(log); } catch (Exception e) { System.err.println("Failed to write audit log: " + e.getMessage()); } } } ``` - [ ] **Step 4: 创建 PromotionEventListener** 创建 `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/event/PromotionEventListener.java`: ```java package com.iflytek.skillhub.domain.promotion.event; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditAction; import com.iflytek.skillhub.domain.audit.AuditLog; import com.iflytek.skillhub.domain.audit.AuditLogRepository; import com.iflytek.skillhub.domain.event.PromotionApprovedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; import java.util.HashMap; import java.util.Map; @Component public class PromotionEventListener { private final AuditLogRepository auditLogRepository; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; public PromotionEventListener( AuditLogRepository auditLogRepository, ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper) { this.auditLogRepository = auditLogRepository; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async("skillhubEventExecutor") @Transactional public void onPromotionApproved(PromotionApprovedEvent event) { // 1. Write audit log Map details = new HashMap<>(); details.put("promotionRequestId", event.promotionRequestId()); details.put("sourceSkillId", event.sourceSkillId()); details.put("sourceVersionId", event.sourceVersionId()); details.put("targetSkillId", event.targetSkillId()); try { String detailsJson = objectMapper.writeValueAsString(details); AuditLog log = new AuditLog( AuditAction.PROMOTION_APPROVED, "skill", event.targetSkillId(), event.reviewerId(), detailsJson ); auditLogRepository.save(log); } catch (Exception e) { System.err.println("Failed to write audit log: " + e.getMessage()); } // 2. Trigger SkillPublishedEvent for search indexing of the new global skill eventPublisher.publishEvent(new SkillPublishedEvent( event.targetSkillId(), event.sourceVersionId(), event.reviewerId() )); } } ``` - [ ] **Step 5: 配置异步执行器(如果尚未配置)** 检查是否存在 `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java`,如果不存在则创建: ```java package com.iflytek.skillhub.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration @EnableAsync public class AsyncConfig { @Bean(name = "skillhubEventExecutor") public Executor skillhubEventExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix("event-"); executor.initialize(); return executor; } } ``` - [ ] **Step 6: 编写测试验证事件监听器** 创建 `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/event/ReviewEventListenerTest.java`: ```java package com.iflytek.skillhub.domain.review.event; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditAction; import com.iflytek.skillhub.domain.audit.AuditLog; import com.iflytek.skillhub.domain.audit.AuditLogRepository; import com.iflytek.skillhub.domain.event.ReviewApprovedEvent; import com.iflytek.skillhub.domain.event.ReviewRejectedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ReviewEventListenerTest { @Mock private SkillVersionRepository skillVersionRepository; @Mock private AuditLogRepository auditLogRepository; @Mock private ApplicationEventPublisher eventPublisher; @Mock private ObjectMapper objectMapper; @InjectMocks private ReviewEventListener listener; @Test void onReviewApproved_shouldUpdateStatusAndTriggerEvents() throws Exception { // Given Long skillId = 1L; Long versionId = 10L; String reviewerId = 5L; SkillVersion version = new SkillVersion(); version.setId(versionId); version.setStatus(SkillVersionStatus.PENDING_REVIEW); when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); when(objectMapper.writeValueAsString(any())).thenReturn("{\"test\":\"data\"}"); ReviewApprovedEvent event = new ReviewApprovedEvent(100L, skillId, versionId, reviewerId, "LGTM"); // When listener.onReviewApproved(event); // Then assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus()); verify(skillVersionRepository).save(version); ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditLog.class); verify(auditLogRepository).save(auditCaptor.capture()); AuditLog savedLog = auditCaptor.getValue(); assertEquals(AuditAction.REVIEW_APPROVED, savedLog.getAction()); assertEquals("skill", savedLog.getEntityType()); assertEquals(skillId, savedLog.getEntityId()); assertEquals(reviewerId, savedLog.getUserId()); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SkillPublishedEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); SkillPublishedEvent publishedEvent = eventCaptor.getValue(); assertEquals(skillId, publishedEvent.skillId()); assertEquals(versionId, publishedEvent.versionId()); } @Test void onReviewRejected_shouldUpdateStatusToRejected() throws Exception { // Given Long skillId = 1L; Long versionId = 10L; String reviewerId = 5L; SkillVersion version = new SkillVersion(); version.setId(versionId); version.setStatus(SkillVersionStatus.PENDING_REVIEW); when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version)); when(objectMapper.writeValueAsString(any())).thenReturn("{\"test\":\"data\"}"); ReviewRejectedEvent event = new ReviewRejectedEvent(100L, skillId, versionId, reviewerId, "Issues found"); // When listener.onReviewRejected(event); // Then assertEquals(SkillVersionStatus.REJECTED, version.getStatus()); verify(skillVersionRepository).save(version); ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditLog.class); verify(auditLogRepository).save(auditCaptor.capture()); AuditLog savedLog = auditCaptor.getValue(); assertEquals(AuditAction.REVIEW_REJECTED, savedLog.getAction()); // Should NOT trigger SkillPublishedEvent for rejected reviews verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); } } ``` ### Task 10: Chunk 1 验收(编译、测试、验证脚本) **Files:** - Create: `server/verify-phase3-chunk1.sh` - [ ] **Step 1: 编译整个项目** 在项目根目录执行: ```bash cd /Users/xudongsun/github/skillhub/server ./mvnw clean compile -DskipTests ``` 验证所有模块编译成功,无错误。 - [ ] **Step 2: 运行所有测试** ```bash ./mvnw test ``` 验证所有测试通过,特别关注: - `ReviewServiceTest` - `PromotionServiceTest` - `SkillPublishServiceReviewTest` - `ReviewEventListenerTest` - `PromotionEventListenerTest` - [ ] **Step 3: 创建验收验证脚本** 创建 `server/verify-phase3-chunk1.sh`: ```bash #!/bin/bash set -e echo "==========================================" echo "Phase 3 Chunk 1 验收脚本" echo "==========================================" echo "" # 颜色定义 GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # 检查函数 check_file() { if [ -f "$1" ]; then echo -e "${GREEN}✓${NC} $1" return 0 else echo -e "${RED}✗${NC} $1 (缺失)" return 1 fi } check_class() { local file="$1" local class_name="$2" if grep -q "class $class_name\|interface $class_name\|enum $class_name\|record $class_name" "$file" 2>/dev/null; then echo -e "${GREEN}✓${NC} $class_name 定义正确" return 0 else echo -e "${RED}✗${NC} $class_name 定义缺失或错误" return 1 fi } FAILED=0 echo "1. 检查数据库迁移脚本..." check_file "skillhub-app/src/main/resources/db/migration/V3__phase3_review_social_tables.sql" || FAILED=1 echo "" echo "2. 检查 Review 实体和 Repository..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewStatus.java" || FAILED=1 echo "" echo "3. 检查 Promotion 实体和 Repository..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionRequest.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionRequestRepository.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionStatus.java" || FAILED=1 echo "" echo "4. 检查权限检查服务..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/service/ReviewPermissionService.java" || FAILED=1 echo "" echo "5. 检查 ReviewService..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/service/ReviewService.java" || FAILED=1 echo "" echo "6. 检查 PromotionService..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/service/PromotionService.java" || FAILED=1 echo "" echo "7. 检查 Controllers 和 DTOs..." check_file "skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java" || FAILED=1 check_file "skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java" || FAILED=1 check_file "skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskRequest.java" || FAILED=1 check_file "skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java" || FAILED=1 check_file "skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRequestDto.java" || FAILED=1 check_file "skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java" || FAILED=1 echo "" echo "8. 检查 SkillPublishService 改造..." if grep -q "PENDING_REVIEW" "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java" 2>/dev/null; then echo -e "${GREEN}✓${NC} SkillPublishService 已改造为创建 PENDING_REVIEW 状态" else echo -e "${RED}✗${NC} SkillPublishService 未改造" FAILED=1 fi if grep -q "ReviewTaskRepository" "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java" 2>/dev/null; then echo -e "${GREEN}✓${NC} SkillPublishService 已集成 ReviewTaskRepository" else echo -e "${RED}✗${NC} SkillPublishService 未集成 ReviewTaskRepository" FAILED=1 fi echo "" echo "9. 检查事件和监听器..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/event/ReviewEventListener.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/event/PromotionEventListener.java" || FAILED=1 echo "" echo "10. 检查 AuditLog..." check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java" || FAILED=1 check_file "skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditAction.java" || FAILED=1 echo "" echo "11. 检查测试文件..." check_file "skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/service/ReviewServiceTest.java" || FAILED=1 check_file "skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/service/PromotionServiceTest.java" || FAILED=1 check_file "skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceReviewTest.java" || FAILED=1 check_file "skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/event/ReviewEventListenerTest.java" || FAILED=1 check_file "skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/event/PromotionEventListenerTest.java" || FAILED=1 echo "" echo "==========================================" echo "12. 编译项目..." echo "==========================================" if ./mvnw clean compile -DskipTests > /tmp/compile.log 2>&1; then echo -e "${GREEN}✓${NC} 编译成功" else echo -e "${RED}✗${NC} 编译失败,查看 /tmp/compile.log" FAILED=1 fi echo "" echo "==========================================" echo "13. 运行测试..." echo "==========================================" if ./mvnw test -Dtest="ReviewServiceTest,PromotionServiceTest,SkillPublishServiceReviewTest,ReviewEventListenerTest,PromotionEventListenerTest" > /tmp/test.log 2>&1; then echo -e "${GREEN}✓${NC} 所有测试通过" else echo -e "${RED}✗${NC} 测试失败,查看 /tmp/test.log" FAILED=1 fi echo "" echo "==========================================" echo "验收结果" echo "==========================================" if [ $FAILED -eq 0 ]; then echo -e "${GREEN}✓ Phase 3 Chunk 1 验收通过!${NC}" echo "" echo "已完成功能:" echo " 1. ✓ 数据库迁移脚本(review_task, promotion_request, audit_log 等 5 张表)" echo " 2. ✓ ReviewTask 实体和 Repository" echo " 3. ✓ PromotionRequest 实体和 Repository" echo " 4. ✓ ReviewPermissionService(分级权限检查)" echo " 5. ✓ ReviewService(提交/审核/拒绝/撤回,乐观锁)" echo " 6. ✓ PromotionService(提交/审核/拒绝,复制技能到全局空间)" echo " 7. ✓ ReviewController + PromotionController(REST API)" echo " 8. ✓ SkillPublishService 改造(PENDING_REVIEW + 自动创建 ReviewTask)" echo " 9. ✓ 审核事件监听器(更新状态 + 触发搜索索引 + 写入 audit_log)" echo " 10. ✓ 所有测试通过" echo "" echo "下一步:" echo " - 启动应用,手动测试审核流程" echo " - 使用 Postman/curl 测试 API 端点" echo " - 验证乐观锁并发控制" echo " - 验证分级权限(团队管理员 vs 平台管理员)" echo " - 开始 Chunk 2: 评分收藏功能" exit 0 else echo -e "${RED}✗ Phase 3 Chunk 1 验收失败${NC}" echo "" echo "请检查上述失败项,修复后重新运行验收脚本。" exit 1 fi ``` - [ ] **Step 4: 赋予脚本执行权限并运行** ```bash chmod +x server/verify-phase3-chunk1.sh cd server ./verify-phase3-chunk1.sh ``` - [ ] **Step 5: 手动验证审核流程** 启动应用后,使用 Postman 或 curl 测试以下场景: **场景 1: 用户发布技能并提交审核** ```bash # 1. 发布技能(自动创建 PENDING_REVIEW 版本和 ReviewTask) curl -X POST http://localhost:8080/api/v1/skills/publish \ -H "Authorization: Bearer " \ -F "file=@skill-package.zip" \ -F "namespace=my-team" \ -F "visibility=PUBLIC" # 2. 查看待审核列表(团队管理员) curl -X GET "http://localhost:8080/api/v1/reviews/pending?namespace=my-team" \ -H "Authorization: Bearer " # 3. 审核通过 curl -X POST http://localhost:8080/api/v1/reviews/123/approve \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"comment": "LGTM"}' # 4. 验证版本状态变为 PUBLISHED curl -X GET http://localhost:8080/api/v1/skills/my-team/my-skill \ -H "Authorization: Bearer " ``` **场景 2: 审核拒绝** ```bash # 1. 拒绝审核 curl -X POST http://localhost:8080/api/v1/reviews/124/reject \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"comment": "需要修复安全问题"}' # 2. 验证版本状态变为 REJECTED curl -X GET http://localhost:8080/api/v1/skills/my-team/my-skill/versions/1.0.1 ``` **场景 3: 提升到全局空间** ```bash # 1. 提交提升请求 curl -X POST http://localhost:8080/api/v1/promotions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "sourceSkillId": 456, "sourceVersionId": 789, "targetNamespace": "global" }' # 2. 平台管理员审核通过 curl -X POST http://localhost:8080/api/v1/promotions/10/approve \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"comment": "优秀的技能,批准提升"}' # 3. 验证全局空间中创建了新技能 curl -X GET http://localhost:8080/api/v1/skills/global/my-skill ``` **场景 4: 并发审核(乐观锁验证)** 使用两个终端同时执行审核操作,验证只有一个成功,另一个返回 409 Conflict。 **场景 5: 权限验证** ```bash # 1. 团队管理员尝试审核其他团队的技能(应失败) curl -X POST http://localhost:8080/api/v1/reviews/125/approve \ -H "Authorization: Bearer " # 预期: 403 Forbidden # 2. 平台管理员尝试审核团队空间的技能(应失败) curl -X POST http://localhost:8080/api/v1/reviews/126/approve \ -H "Authorization: Bearer " # 预期: 403 Forbidden(平台管理员只能审核全局空间) # 3. 普通用户尝试审核(应失败) curl -X POST http://localhost:8080/api/v1/reviews/127/approve \ -H "Authorization: Bearer " # 预期: 403 Forbidden ``` - [ ] **Step 6: 验证 audit_log 记录** 连接数据库,检查 audit_log 表: ```sql -- 查看所有审核操作的审计日志 SELECT * FROM audit_log WHERE action IN ('REVIEW_SUBMITTED', 'REVIEW_APPROVED', 'REVIEW_REJECTED', 'REVIEW_WITHDRAWN') ORDER BY created_at DESC; -- 查看所有提升操作的审计日志 SELECT * FROM audit_log WHERE action IN ('PROMOTION_SUBMITTED', 'PROMOTION_APPROVED', 'PROMOTION_REJECTED') ORDER BY created_at DESC; ``` 验证每个审核操作都有对应的审计日志记录。 - [ ] **Step 7: 验证搜索索引更新** ```bash # 1. 审核通过后,搜索新发布的技能 curl -X GET "http://localhost:8080/api/v1/search?q=my-skill" # 2. 验证搜索结果中包含新发布的技能 ``` - [ ] **Step 8: Chunk 1 验收完成确认** 确认以下所有验收标准已满足: 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 1 完成!可以开始 Chunk 2: 评分收藏功能。** --- ## 总结 Phase 3 Chunk 1 实现了完整的审核流程核心功能: **核心组件:** 1. 数据库迁移(5 张新表) 2. ReviewTask + PromotionRequest 实体和 Repository 3. ReviewPermissionService(分级权限检查) 4. ReviewService + PromotionService(核心业务逻辑) 5. ReviewController + PromotionController(REST API) 6. SkillPublishService 改造(PENDING_REVIEW + 自动创建 ReviewTask) 7. 事件监听器(更新状态 + 触发搜索索引 + 写入 audit_log) **关键技术点:** - 乐观锁(@Version)防止并发冲突 - Partial unique index 防止重复提交 - 分级权限控制(团队自治 + 平台管理) - 事件驱动架构(@TransactionalEventListener) - 审计日志(所有操作可追溯) **下一步:** - Chunk 2: 评分收藏功能(rating, favorite, 异步计数器更新) - Chunk 3: CLI API(OAuth Device Flow, API 端点) - Chunk 4: ClawHub 兼容层(Canonical slug 映射) - Chunk 5: 幂等去重 + 管理后台 --- ## Chunk 2: 评分收藏 + 前端审核中心 **范围:** 评分收藏后端 + 审核中心前端 + 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. 前端测试通过 ### Task 1: SkillStar 和 SkillRating 领域实体 **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarRepository.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java` - Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillStarRepository.java` - Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java` - [ ] **Step 1: 创建 SkillStar 实体** ```java package com.iflytek.skillhub.domain.social; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity @Table(name = "skill_star", uniqueConstraints = @UniqueConstraint(columns = {"skill_id", "user_id"})) public class SkillStar { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "skill_id", nullable = false) private Long skillId; @Column(name = "user_id", nullable = false) private String userId; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); protected SkillStar() {} public SkillStar(Long skillId, String userId) { this.skillId = skillId; this.userId = userId; } // getters public Long getId() { return id; } public Long getSkillId() { return skillId; } public Long getUserId() { return userId; } public LocalDateTime getCreatedAt() { return createdAt; } } ``` - [ ] **Step 2: 创建 SkillRating 实体** ```java package com.iflytek.skillhub.domain.social; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity @Table(name = "skill_rating", uniqueConstraints = @UniqueConstraint(columns = {"skill_id", "user_id"})) public class SkillRating { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "skill_id", nullable = false) private Long skillId; @Column(name = "user_id", nullable = false) private String userId; @Column(nullable = false) private Short score; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt = LocalDateTime.now(); protected SkillRating() {} 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; this.score = score; } public void updateScore(short newScore) { if (newScore < 1 || newScore > 5) throw new IllegalArgumentException("Score must be 1-5"); this.score = newScore; this.updatedAt = LocalDateTime.now(); } // getters public Long getId() { return id; } public Long getSkillId() { return skillId; } public Long getUserId() { return userId; } public Short getScore() { return score; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } } ``` - [ ] **Step 3: 创建 Repository 接口** `SkillStarRepository.java`: ```java package com.iflytek.skillhub.domain.social; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface SkillStarRepository { SkillStar save(SkillStar star); Optional findBySkillIdAndUserId(Long skillId, String userId); void delete(SkillStar star); Page findByUserId(String userId, Pageable pageable); long countBySkillId(Long skillId); } ``` `SkillRatingRepository.java`: ```java package com.iflytek.skillhub.domain.social; import java.util.Optional; public interface SkillRatingRepository { SkillRating save(SkillRating rating); Optional findBySkillIdAndUserId(Long skillId, String userId); double averageScoreBySkillId(Long skillId); int countBySkillId(Long skillId); } ``` - [ ] **Step 4: 实现 JPA Repository** `JpaSkillStarRepository.java`: ```java package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.social.SkillStar; import com.iflytek.skillhub.domain.social.SkillStarRepository; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @Repository public interface JpaSkillStarRepository extends JpaRepository, SkillStarRepository { Optional findBySkillIdAndUserId(Long skillId, String userId); Page findByUserId(String userId, Pageable pageable); long countBySkillId(Long skillId); } ``` `JpaSkillRatingRepository.java`: ```java package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.social.SkillRating; import com.iflytek.skillhub.domain.social.SkillRatingRepository; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface JpaSkillRatingRepository extends JpaRepository, SkillRatingRepository { Optional findBySkillIdAndUserId(Long skillId, String userId); @Query("SELECT COALESCE(AVG(r.score), 0) FROM SkillRating r WHERE r.skillId = :skillId") double averageScoreBySkillId(Long skillId); int countBySkillId(Long skillId); } ``` - [ ] **Step 5: 编译验证** 运行:`cd server && ./mvnw compile` 预期:编译成功 - [ ] **Step 6: Commit** ```bash git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/ git add server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillStar* git add server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRating* git commit -m "feat(social): add SkillStar and SkillRating entities and repositories" ``` ### Task 2: SkillStarService 和 SkillRatingService **Files:** - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillStarredEvent.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillUnstarredEvent.java` - Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillRatedEvent.java` - Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java` - Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java` - [ ] **Step 1: 创建领域事件类** `SkillStarredEvent.java`: ```java package com.iflytek.skillhub.domain.social.event; public record SkillStarredEvent(Long skillId, String userId) {} ``` `SkillUnstarredEvent.java`: ```java package com.iflytek.skillhub.domain.social.event; public record SkillUnstarredEvent(Long skillId, String userId) {} ``` `SkillRatedEvent.java`: ```java package com.iflytek.skillhub.domain.social.event; public record SkillRatedEvent(Long skillId, String userId, short score) {} ``` - [ ] **Step 2: 编写 SkillStarService 测试** ```java package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import java.util.Optional; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillStarServiceTest { @Mock SkillStarRepository starRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillStarService service; @Test void star_skill_creates_record_and_publishes_event() { when(starRepository.findBySkillIdAndUserId(1L, 10L)).thenReturn(Optional.empty()); when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); service.star(1L, 10L); verify(starRepository).save(any(SkillStar.class)); verify(eventPublisher).publishEvent(any(SkillStarredEvent.class)); } @Test void star_skill_already_starred_is_idempotent() { when(starRepository.findBySkillIdAndUserId(1L, 10L)) .thenReturn(Optional.of(new SkillStar(1L, 10L))); service.star(1L, 10L); verify(starRepository, never()).save(any()); verify(eventPublisher, never()).publishEvent(any()); } @Test void unstar_skill_deletes_record_and_publishes_event() { SkillStar existing = new SkillStar(1L, 10L); when(starRepository.findBySkillIdAndUserId(1L, 10L)).thenReturn(Optional.of(existing)); service.unstar(1L, 10L); verify(starRepository).delete(existing); verify(eventPublisher).publishEvent(any(SkillUnstarredEvent.class)); } @Test void unstar_skill_not_starred_is_noop() { when(starRepository.findBySkillIdAndUserId(1L, 10L)).thenReturn(Optional.empty()); service.unstar(1L, 10L); verify(starRepository, never()).delete(any()); verify(eventPublisher, never()).publishEvent(any()); } @Test void isStarred_returns_true_when_exists() { when(starRepository.findBySkillIdAndUserId(1L, 10L)) .thenReturn(Optional.of(new SkillStar(1L, 10L))); assertThat(service.isStarred(1L, 10L)).isTrue(); } } ``` - [ ] **Step 3: 运行测试验证失败** 运行:`cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillStarServiceTest` 预期:编译失败,SkillStarService 不存在 - [ ] **Step 4: 实现 SkillStarService** ```java package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class SkillStarService { private final SkillStarRepository starRepository; private final ApplicationEventPublisher eventPublisher; public SkillStarService(SkillStarRepository starRepository, ApplicationEventPublisher eventPublisher) { this.starRepository = starRepository; this.eventPublisher = eventPublisher; } @Transactional public void star(Long skillId, String userId) { if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { return; // idempotent } starRepository.save(new SkillStar(skillId, userId)); eventPublisher.publishEvent(new SkillStarredEvent(skillId, userId)); } @Transactional 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, String userId) { return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); } } ``` - [ ] **Step 5: 运行测试验证通过** 运行:`cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillStarServiceTest` 预期:5 个测试全部 PASS - [ ] **Step 6: 编写 SkillRatingService 测试** ```java package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import java.util.Optional; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillRatingServiceTest { @Mock SkillRatingRepository ratingRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillRatingService service; @Test void rate_creates_new_rating() { when(ratingRepository.findBySkillIdAndUserId(1L, 10L)).thenReturn(Optional.empty()); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); service.rate(1L, 10L, (short) 4); verify(ratingRepository).save(argThat(r -> r.getScore() == 4)); verify(eventPublisher).publishEvent(any(SkillRatedEvent.class)); } @Test void rate_updates_existing_rating() { SkillRating existing = new SkillRating(1L, 10L, (short) 3); when(ratingRepository.findBySkillIdAndUserId(1L, 10L)).thenReturn(Optional.of(existing)); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); service.rate(1L, 10L, (short) 5); assertThat(existing.getScore()).isEqualTo((short) 5); verify(ratingRepository).save(existing); verify(eventPublisher).publishEvent(any(SkillRatedEvent.class)); } @Test void rate_invalid_score_throws() { assertThatThrownBy(() -> service.rate(1L, 10L, (short) 0)) .isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> service.rate(1L, 10L, (short) 6)) .isInstanceOf(IllegalArgumentException.class); } @Test void getUserRating_returns_score() { SkillRating existing = new SkillRating(1L, 10L, (short) 4); when(ratingRepository.findBySkillIdAndUserId(1L, 10L)).thenReturn(Optional.of(existing)); assertThat(service.getUserRating(1L, 10L)).hasValue((short) 4); } } ``` - [ ] **Step 7: 实现 SkillRatingService** ```java package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @Service public class SkillRatingService { private final SkillRatingRepository ratingRepository; private final ApplicationEventPublisher eventPublisher; public SkillRatingService(SkillRatingRepository ratingRepository, ApplicationEventPublisher eventPublisher) { this.ratingRepository = ratingRepository; this.eventPublisher = eventPublisher; } @Transactional public void rate(Long skillId, String userId, short score) { if (score < 1 || score > 5) { throw new IllegalArgumentException("Score must be 1-5"); } Optional existing = ratingRepository.findBySkillIdAndUserId(skillId, userId); if (existing.isPresent()) { existing.get().updateScore(score); ratingRepository.save(existing.get()); } else { ratingRepository.save(new SkillRating(skillId, userId, score)); } eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score)); } public Optional getUserRating(Long skillId, String userId) { return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } } ``` - [ ] **Step 8: 运行测试验证通过** 运行:`cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillRatingServiceTest` 预期:4 个测试全部 PASS - [ ] **Step 9: Commit** ```bash git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/ git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java git add server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/ git commit -m "feat(social): add SkillStarService and SkillRatingService with events" ``` ### Task 3: 异步事件监听器(计数器更新) **Files:** - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/listener/SkillStarEventListener.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/listener/SkillRatingEventListener.java` - Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/listener/SkillStarEventListenerTest.java` - Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/listener/SkillRatingEventListenerTest.java` - [ ] **Step 1: 编写 SkillStarEventListener 测试** ```java package com.iflytek.skillhub.app.listener; import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillStarEventListenerTest { @Mock JdbcTemplate jdbcTemplate; @Mock SkillStarRepository starRepository; @InjectMocks SkillStarEventListener listener; @Test void onStarred_updates_star_count() { when(starRepository.countBySkillId(1L)).thenReturn(42L); listener.onStarred(new SkillStarredEvent(1L, 10L)); verify(jdbcTemplate).update("UPDATE skill SET star_count = ? WHERE id = ?", 42, 1L); } @Test void onUnstarred_updates_star_count() { when(starRepository.countBySkillId(1L)).thenReturn(41L); listener.onUnstarred(new SkillUnstarredEvent(1L, 10L)); verify(jdbcTemplate).update("UPDATE skill SET star_count = ? WHERE id = ?", 41, 1L); } } ``` - [ ] **Step 2: 实现 SkillStarEventListener** ```java package com.iflytek.skillhub.app.listener; import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; @Component public class SkillStarEventListener { private final JdbcTemplate jdbcTemplate; private final SkillStarRepository starRepository; public SkillStarEventListener(JdbcTemplate jdbcTemplate, SkillStarRepository starRepository) { this.jdbcTemplate = jdbcTemplate; this.starRepository = starRepository; } @Async @TransactionalEventListener public void onStarred(SkillStarredEvent event) { updateStarCount(event.skillId()); } @Async @TransactionalEventListener public void onUnstarred(SkillUnstarredEvent event) { updateStarCount(event.skillId()); } private void updateStarCount(Long skillId) { long count = starRepository.countBySkillId(skillId); jdbcTemplate.update("UPDATE skill SET star_count = ? WHERE id = ?", count, skillId); } } ``` - [ ] **Step 3: 编写 SkillRatingEventListener 测试** ```java package com.iflytek.skillhub.app.listener; import com.iflytek.skillhub.domain.social.SkillRatingRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillRatingEventListenerTest { @Mock JdbcTemplate jdbcTemplate; @Mock SkillRatingRepository ratingRepository; @InjectMocks SkillRatingEventListener listener; @Test void onRated_updates_rating_avg_and_count() { when(ratingRepository.averageScoreBySkillId(1L)).thenReturn(4.2); when(ratingRepository.countBySkillId(1L)).thenReturn(10); listener.onRated(new SkillRatedEvent(1L, 10L, (short) 5)); verify(jdbcTemplate).update( "UPDATE skill SET rating_avg = ?, rating_count = ? WHERE id = ?", 4.2, 10, 1L); } } ``` - [ ] **Step 4: 实现 SkillRatingEventListener** ```java package com.iflytek.skillhub.app.listener; import com.iflytek.skillhub.domain.social.SkillRatingRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; @Component public class SkillRatingEventListener { private final JdbcTemplate jdbcTemplate; private final SkillRatingRepository ratingRepository; public SkillRatingEventListener(JdbcTemplate jdbcTemplate, SkillRatingRepository ratingRepository) { this.jdbcTemplate = jdbcTemplate; this.ratingRepository = ratingRepository; } @Async @TransactionalEventListener public void onRated(SkillRatedEvent event) { double avg = ratingRepository.averageScoreBySkillId(event.skillId()); int count = ratingRepository.countBySkillId(event.skillId()); jdbcTemplate.update( "UPDATE skill SET rating_avg = ?, rating_count = ? WHERE id = ?", avg, count, event.skillId()); } } ``` - [ ] **Step 5: 运行测试验证通过** 运行:`cd server && ./mvnw test -pl skillhub-app -Dtest="SkillStarEventListenerTest,SkillRatingEventListenerTest"` 预期:3 个测试全部 PASS - [ ] **Step 6: Commit** ```bash git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/listener/Skill*EventListener.java git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/listener/Skill*EventListenerTest.java git commit -m "feat(social): add async event listeners for star_count and rating_avg" ``` ### Task 4: SkillStarController 和 SkillRatingController **Files:** - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/SkillStarController.java` - Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/SkillRatingController.java` - Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/SkillStarControllerTest.java` - Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/SkillRatingControllerTest.java` - [ ] **Step 1: 编写 SkillStarController 测试** ```java package com.iflytek.skillhub.app.controller; import com.iflytek.skillhub.domain.social.SkillStarService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.bean.MockBean; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(SkillStarController.class) class SkillStarControllerTest { @Autowired MockMvc mockMvc; @MockBean SkillStarService starService; @Test @WithMockUser void star_skill_returns_204() throws Exception { mockMvc.perform(put("/api/v1/skills/1/star")) .andExpect(status().isNoContent()); } @Test @WithMockUser void unstar_skill_returns_204() throws Exception { mockMvc.perform(delete("/api/v1/skills/1/star")) .andExpect(status().isNoContent()); } @Test void star_skill_unauthenticated_returns_401() throws Exception { mockMvc.perform(put("/api/v1/skills/1/star")) .andExpect(status().isUnauthorized()); } } ``` - [ ] **Step 2: 实现 SkillStarController** ```java package com.iflytek.skillhub.app.controller; import com.iflytek.skillhub.domain.social.SkillStarService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/skills/{skillId}/star") public class SkillStarController { private final SkillStarService starService; public SkillStarController(SkillStarService starService) { this.starService = starService; } @PutMapping public ResponseEntity star(@PathVariable Long skillId, @AuthenticationPrincipal String userId) { starService.star(skillId, userId); return ResponseEntity.noContent().build(); } @DeleteMapping public ResponseEntity unstar(@PathVariable Long skillId, @AuthenticationPrincipal String userId) { starService.unstar(skillId, userId); return ResponseEntity.noContent().build(); } @GetMapping public ResponseEntity isStarred(@PathVariable Long skillId, @AuthenticationPrincipal String userId) { return ResponseEntity.ok(starService.isStarred(skillId, userId)); } } ``` - [ ] **Step 3: 编写 SkillRatingController 测试** ```java package com.iflytek.skillhub.app.controller; import com.iflytek.skillhub.domain.social.SkillRatingService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.bean.MockBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.util.Optional; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(SkillRatingController.class) class SkillRatingControllerTest { @Autowired MockMvc mockMvc; @MockBean SkillRatingService ratingService; @Test @WithMockUser void rate_skill_returns_204() throws Exception { mockMvc.perform(put("/api/v1/skills/1/rating") .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4}")) .andExpect(status().isNoContent()); } @Test @WithMockUser void get_user_rating_returns_score() throws Exception { when(ratingService.getUserRating(1L, any())).thenReturn(Optional.of((short) 4)); mockMvc.perform(get("/api/v1/skills/1/rating")) .andExpect(status().isOk()); } @Test void rate_skill_unauthenticated_returns_401() throws Exception { mockMvc.perform(put("/api/v1/skills/1/rating") .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4}")) .andExpect(status().isUnauthorized()); } } ``` - [ ] **Step 4: 实现 SkillRatingController** ```java package com.iflytek.skillhub.app.controller; import com.iflytek.skillhub.domain.social.SkillRatingService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Optional; @RestController @RequestMapping("/api/v1/skills/{skillId}/rating") public class SkillRatingController { private final SkillRatingService ratingService; public SkillRatingController(SkillRatingService ratingService) { this.ratingService = ratingService; } @PutMapping public ResponseEntity rate(@PathVariable Long skillId, @AuthenticationPrincipal String userId, @RequestBody Map body) { short score = body.get("score").shortValue(); ratingService.rate(skillId, userId, score); return ResponseEntity.noContent().build(); } @GetMapping public ResponseEntity getUserRating(@PathVariable Long skillId, @AuthenticationPrincipal String userId) { Optional score = ratingService.getUserRating(skillId, userId); return ResponseEntity.ok(Map.of("score", score.orElse(null), "rated", score.isPresent())); } } ``` - [ ] **Step 5: 运行测试验证通过** 运行:`cd server && ./mvnw test -pl skillhub-app -Dtest="SkillStarControllerTest,SkillRatingControllerTest"` 预期:6 个测试全部 PASS - [ ] **Step 6: Commit** ```bash git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/SkillStar* git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/SkillRating* git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/SkillStar* git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/SkillRating* git commit -m "feat(social): add SkillStar and SkillRating controllers" ``` ### Task 5: 前端审核中心 #### 5.1 创建审核列表 Hook **文件:** `web/src/features/review/use-review-list.ts` ```typescript import { useQuery } from '@tanstack/react-query' export interface ReviewTask { id: number skillVersionId: number skillName: string skillSlug: string namespace: string version: string status: 'PENDING' | 'APPROVED' | 'REJECTED' submittedBy: string submittedAt: string reviewedBy?: string reviewedAt?: string comment?: string } export function useReviewList(status?: string) { return useQuery({ queryKey: ['reviews', status], queryFn: async () => { const url = status ? `/api/v1/reviews?status=${status}` : '/api/v1/reviews' const res = await fetch(url, { credentials: 'include', }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } const json = await res.json() return json.tasks as ReviewTask[] }, }) } ``` **验收:** - [ ] useReviewList hook 创建完成 - [ ] 支持按状态筛选 - [ ] 返回 ReviewTask 数组 #### 5.2 创建审核详情 Hook **文件:** `web/src/features/review/use-review-detail.ts` ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { ReviewTask } from './use-review-list' export function useReviewDetail(taskId: number) { return useQuery({ queryKey: ['review', taskId], queryFn: async () => { const res = await fetch(`/api/v1/reviews/${taskId}`, { credentials: 'include', }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } const json = await res.json() return json as ReviewTask }, enabled: !!taskId, }) } export function useApproveReview() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ taskId, comment }: { taskId: number; comment?: string }) => { const res = await fetch(`/api/v1/reviews/${taskId}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ comment }), }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }) }, }) } export function useRejectReview() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ taskId, comment }: { taskId: number; comment: string }) => { const res = await fetch(`/api/v1/reviews/${taskId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ comment }), }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }) }, }) } ``` **验收:** - [ ] useReviewDetail hook 创建完成 - [ ] useApproveReview mutation 创建完成 - [ ] useRejectReview mutation 创建完成 - [ ] 审核操作后刷新列表 #### 5.3 创建审核列表页面 **文件:** `web/src/pages/dashboard/reviews.tsx` ```typescript import { useState } from 'react' import { Link } from '@tanstack/react-router' import { useReviewList } from '@/features/review/use-review-list' import { Card } from '@/shared/ui/card' import { Button } from '@/shared/ui/button' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' export function ReviewsPage() { const [status, setStatus] = useState('PENDING') const { data: reviews, isLoading } = useReviewList(status) if (isLoading) { return
加载中...
} return (

审核中心

管理技能发布审核

待审核 已通过 已拒绝 {reviews && reviews.length > 0 ? (
{reviews.map((review) => (

{review.namespace}/{review.skillSlug}@{review.version}

提交者: {review.submittedBy} · {new Date(review.submittedAt).toLocaleString('zh-CN')}

{review.reviewedBy && (

审核者: {review.reviewedBy} · {new Date(review.reviewedAt!).toLocaleString('zh-CN')}

)}
))}
) : ( 暂无审核任务 )}
) } ``` **验收:** - [ ] 审核列表页面创建完成 - [ ] 支持按状态切换(待审核/已通过/已拒绝) - [ ] 显示技能名称、版本、提交者、提交时间 - [ ] 点击查看详情跳转到审核详情页 #### 5.4 创建审核详情页面 **文件:** `web/src/pages/dashboard/reviews/[id].tsx` ```typescript import { useState } from 'react' import { useParams, useNavigate } from '@tanstack/react-router' import { useReviewDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail' import { Card } from '@/shared/ui/card' import { Button } from '@/shared/ui/button' import { Textarea } from '@/shared/ui/textarea' import { Label } from '@/shared/ui/label' export function ReviewDetailPage() { const { id } = useParams({ from: '/dashboard/reviews/$id' }) const navigate = useNavigate() const [comment, setComment] = useState('') const { data: review, isLoading } = useReviewDetail(Number(id)) const approveMutation = useApproveReview() const rejectMutation = useRejectReview() const handleApprove = async () => { try { await approveMutation.mutateAsync({ taskId: Number(id), comment }) alert('审核通过') navigate({ to: '/dashboard/reviews' }) } catch (error) { alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误')) } } const handleReject = async () => { if (!comment.trim()) { alert('拒绝时必须填写原因') return } try { await rejectMutation.mutateAsync({ taskId: Number(id), comment }) alert('审核拒绝') navigate({ to: '/dashboard/reviews' }) } catch (error) { alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误')) } } if (isLoading) { return
加载中...
} if (!review) { return
审核任务不存在
} return (

审核详情

{review.namespace}/{review.skillSlug}@{review.version}

状态
{review.status}
提交者
{review.submittedBy}
提交时间
{new Date(review.submittedAt).toLocaleString('zh-CN')}
{review.reviewedBy && ( <>
审核者
{review.reviewedBy}
审核时间
{new Date(review.reviewedAt!).toLocaleString('zh-CN')}
)} {review.comment && (
审核意见
{review.comment}
)}
{review.status === 'PENDING' && (