diff --git a/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md b/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md index 35357c40..efaa53d3 100644 --- a/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md +++ b/docs/superpowers/plans/2026-03-12-phase3-review-cli-social.md @@ -728,26 +728,911 @@ git commit -m "feat(review): implement review service **范围:** 评分收藏后端 + 审核中心前端 + Token 管理前端 -(由于篇幅限制,Chunk 2-5 的详细步骤将在后续补充) +**验收标准:** +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: 评分收藏实体和 Repository +### Task 1: SkillStar 和 SkillRating 领域实体 -- [ ] **Step 1-5: 创建 SkillStar 和 SkillRating 实体** -- [ ] **Step 6-10: 实现 Repository 层** +**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` -### Task 2: 评分收藏服务 +- [ ] **Step 1: 创建 SkillStar 实体** -- [ ] **Step 1-5: SkillStarService 实现** -- [ ] **Step 6-10: SkillRatingService 实现** +```java +package com.iflytek.skillhub.domain.social; -### Task 3: 异步事件监听器 +import jakarta.persistence.*; +import java.time.LocalDateTime; -- [ ] **Step 1-5: SkillStarEventListener** -- [ ] **Step 6-10: SkillRatingEventListener with Redis lock** +@Entity +@Table(name = "skill_star", + uniqueConstraints = @UniqueConstraint(columns = {"skill_id", "user_id"})) +public class SkillStar { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; -### Task 4-10: 前端审核中心 + @Column(name = "skill_id", nullable = false) + private Long skillId; -(详细步骤待补充) + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + protected SkillStar() {} + + public SkillStar(Long skillId, Long 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 Long 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, Long 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, Long userId); + void delete(SkillStar star); + Page findByUserId(Long 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, Long 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, Long userId); + Page findByUserId(Long 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, Long 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, Long userId) {} +``` + +`SkillUnstarredEvent.java`: +```java +package com.iflytek.skillhub.domain.social.event; + +public record SkillUnstarredEvent(Long skillId, Long userId) {} +``` + +`SkillRatedEvent.java`: +```java +package com.iflytek.skillhub.domain.social.event; + +public record SkillRatedEvent(Long skillId, Long 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, Long 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, Long userId) { + starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> { + starRepository.delete(star); + eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId)); + }); + } + + public boolean isStarred(Long skillId, Long 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, Long 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, Long 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 Long userId) { + starService.star(skillId, userId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping + public ResponseEntity unstar(@PathVariable Long skillId, + @AuthenticationPrincipal Long userId) { + starService.unstar(skillId, userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping + public ResponseEntity isStarred(@PathVariable Long skillId, + @AuthenticationPrincipal Long 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 Long 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 Long 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-11: 前端审核中心 + 评分收藏 + Token 管理(概要) + +**Task 5: 审核中心 - 审核任务列表页** +- Files: `web/src/pages/dashboard/reviews.tsx`, `web/src/features/review/review-task-table.tsx`, `web/src/features/review/use-review-tasks.ts` +- 使用 TanStack Query 获取审核任务列表 +- 支持按状态筛选(PENDING / APPROVED / REJECTED) +- 分页 + 排序 + +**Task 6: 审核中心 - 审核详情页** +- Files: `web/src/pages/dashboard/review-detail.tsx`, `web/src/features/review/review-detail-view.tsx`, `web/src/features/review/approve-dialog.tsx`, `web/src/features/review/reject-dialog.tsx` +- 展示技能版本详情 + SKILL.md 预览 +- 通过/拒绝对话框(含审核意见输入) + +**Task 7: 审核中心 - 我的提交列表** +- Files: `web/src/pages/dashboard/my-submissions.tsx` +- 展示用户提交的审核任务列表 +- 支持撤回 PENDING 状态的审核 + +**Task 8: 提升审核页面** +- Files: `web/src/pages/dashboard/promotions.tsx`, `web/src/features/promotion/promotion-table.tsx`, `web/src/features/promotion/use-promotions.ts` +- 平台管理员查看提升请求列表 +- 审核提升请求 + +**Task 9: 评分收藏组件** +- Files: `web/src/features/rating/star-rating.tsx`, `web/src/features/star/star-button.tsx`, `web/src/features/rating/use-rate-skill.ts`, `web/src/features/star/use-star-skill.ts` +- StarRating 组件:1-5 星评分,支持半星显示 +- StarButton 组件:收藏/取消收藏切换 +- 集成到技能详情页右侧信息栏 + +**Task 10: Token 管理页** +- Files: `web/src/pages/dashboard/tokens.tsx`, `web/src/features/token/token-table.tsx`, `web/src/features/token/create-token-dialog.tsx`, `web/src/features/token/revoke-token-dialog.tsx` +- Token 列表展示(名称、前缀、创建时间、过期时间、最后使用时间) +- 创建 Token 对话框(名称、权限范围、过期时间) +- 吊销 Token 确认对话框 + +**Task 11: Chunk 2 验收** +- 运行所有后端测试:`cd server && ./mvnw test` +- 运行所有前端测试:`cd web && npm test` +- 验证 11 个验收标准 +- 代码审查 --- @@ -755,7 +1640,778 @@ git commit -m "feat(review): implement review service **范围:** OAuth Device Flow + CLI API 端点 -(详细步骤待补充) +**验收标准:** +1. CLI 运行 `skillhub login`,获取 device code 和 user code +2. CLI 打开浏览器,跳转到授权页面 +3. 用户输入 user code,确认授权 +4. CLI 轮询获取 token,保存到本地配置文件 +5. CLI 运行 `skillhub whoami`,返回当前用户信息 +6. CLI 运行 `skillhub publish`,上传技能包,提交审核 +7. CLI 运行 `skillhub resolve @team-ai/my-skill`,返回版本信息 +8. CLI 运行 `skillhub check skill.zip`,返回校验结果 +9. 所有 CLI API 端点测试通过 + +### Task 1: Device Flow 数据模型和 Redis 存储 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java` +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeStatus.java` +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeResponse.java` +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceTokenResponse.java` + +- [ ] **Step 1: 创建 DeviceCodeStatus 枚举** + +```java +package com.iflytek.skillhub.auth.device; + +public enum DeviceCodeStatus { + PENDING, + AUTHORIZED, + USED +} +``` + +- [ ] **Step 2: 创建 DeviceCodeData** + +```java +package com.iflytek.skillhub.auth.device; + +import java.io.Serializable; + +public class DeviceCodeData implements Serializable { + private String deviceCode; + private String userCode; + private DeviceCodeStatus status; + private Long userId; + + public DeviceCodeData() {} + + public DeviceCodeData(String deviceCode, String userCode, + DeviceCodeStatus status, Long userId) { + this.deviceCode = deviceCode; + this.userCode = userCode; + this.status = status; + this.userId = userId; + } + + public String getDeviceCode() { return deviceCode; } + public String getUserCode() { return userCode; } + public DeviceCodeStatus getStatus() { return status; } + public void setStatus(DeviceCodeStatus status) { this.status = status; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } +} +``` + +- [ ] **Step 3: 创建 DeviceCodeResponse** + +```java +package com.iflytek.skillhub.auth.device; + +public record DeviceCodeResponse( + String deviceCode, + String userCode, + String verificationUri, + int expiresIn, + int interval +) {} +``` + +- [ ] **Step 4: 创建 DeviceTokenResponse** + +```java +package com.iflytek.skillhub.auth.device; + +public record DeviceTokenResponse( + String accessToken, + String tokenType, + String error +) { + public static DeviceTokenResponse pending() { + return new DeviceTokenResponse(null, null, "authorization_pending"); + } + + public static DeviceTokenResponse success(String token) { + return new DeviceTokenResponse(token, "Bearer", null); + } +} +``` + +- [ ] **Step 5: 编译验证** + +运行:`cd server && ./mvnw compile -pl skillhub-auth` +预期:编译成功 + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/ +git commit -m "feat(cli): add Device Flow data models" +``` + +### Task 2: DeviceAuthService 实现 + +**Files:** +- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java` +- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java` + +- [ ] **Step 1: 编写 DeviceAuthService 测试** + +```java +package com.iflytek.skillhub.auth.device; + +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DeviceAuthServiceTest { + @Mock RedisTemplate redisTemplate; + @Mock ValueOperations valueOps; + @InjectMocks DeviceAuthService service; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + + @Test + void generateDeviceCode_returns_valid_response() { + DeviceCodeResponse resp = service.generateDeviceCode(); + assertThat(resp.deviceCode()).isNotBlank(); + assertThat(resp.userCode()).matches("[A-Z0-9]{4}-[A-Z0-9]{4}"); + assertThat(resp.expiresIn()).isEqualTo(900); + assertThat(resp.interval()).isEqualTo(5); + verify(valueOps, times(2)).set(anyString(), any(), any(Duration.class)); + } + + @Test + void pollToken_returns_pending_when_not_authorized() { + DeviceCodeData data = new DeviceCodeData("dc", "UC", DeviceCodeStatus.PENDING, null); + when(valueOps.get("device:code:dc")).thenReturn(data); + DeviceTokenResponse resp = service.pollToken("dc"); + assertThat(resp.error()).isEqualTo("authorization_pending"); + assertThat(resp.accessToken()).isNull(); + } + + @Test + void pollToken_returns_error_when_expired() { + when(valueOps.get("device:code:dc")).thenReturn(null); + assertThatThrownBy(() -> service.pollToken("dc")) + .hasMessageContaining("expired"); + } + + @Test + void authorizeDeviceCode_updates_status() { + DeviceCodeData data = new DeviceCodeData("dc", "ABCD-1234", DeviceCodeStatus.PENDING, null); + when(valueOps.get("device:usercode:ABCD-1234")).thenReturn("dc"); + when(valueOps.get("device:code:dc")).thenReturn(data); + + service.authorizeDeviceCode("ABCD-1234", 42L); + + assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED); + assertThat(data.getUserId()).isEqualTo(42L); + verify(valueOps).set(eq("device:code:dc"), eq(data), any(Duration.class)); + } +} +``` + +- [ ] **Step 2: 运行测试验证失败** + +运行:`cd server && ./mvnw test -pl skillhub-auth -Dtest=DeviceAuthServiceTest` +预期:编译失败,DeviceAuthService 不存在 + +- [ ] **Step 3: 实现 DeviceAuthService** + +```java +package com.iflytek.skillhub.auth.device; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; + +@Service +public class DeviceAuthService { + private static final String DEVICE_CODE_PREFIX = "device:code:"; + private static final String USER_CODE_PREFIX = "device:usercode:"; + private static final Duration TTL = Duration.ofMinutes(15); + private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private final RedisTemplate redisTemplate; + private final SecureRandom random = new SecureRandom(); + + public DeviceAuthService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public DeviceCodeResponse generateDeviceCode() { + String deviceCode = generateSecureToken(); + String userCode = generateUserFriendlyCode(); + + DeviceCodeData data = new DeviceCodeData(deviceCode, userCode, + DeviceCodeStatus.PENDING, null); + + redisTemplate.opsForValue().set(DEVICE_CODE_PREFIX + deviceCode, data, TTL); + redisTemplate.opsForValue().set(USER_CODE_PREFIX + userCode, deviceCode, TTL); + + return new DeviceCodeResponse(deviceCode, userCode, + "/device", 900, 5); + } + + public void authorizeDeviceCode(String userCode, Long userId) { + String deviceCode = (String) redisTemplate.opsForValue() + .get(USER_CODE_PREFIX + userCode); + if (deviceCode == null) { + throw new IllegalArgumentException("Invalid or expired user code"); + } + + DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue() + .get(DEVICE_CODE_PREFIX + deviceCode); + if (data == null) { + throw new IllegalArgumentException("Invalid or expired device code"); + } + + data.setStatus(DeviceCodeStatus.AUTHORIZED); + data.setUserId(userId); + redisTemplate.opsForValue().set(DEVICE_CODE_PREFIX + deviceCode, data, TTL); + } + + public DeviceTokenResponse pollToken(String deviceCode) { + DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue() + .get(DEVICE_CODE_PREFIX + deviceCode); + + if (data == null) { + throw new IllegalArgumentException("Invalid or expired device code"); + } + + return switch (data.getStatus()) { + case PENDING -> DeviceTokenResponse.pending(); + case AUTHORIZED -> { + data.setStatus(DeviceCodeStatus.USED); + redisTemplate.opsForValue().set( + DEVICE_CODE_PREFIX + deviceCode, data, Duration.ofMinutes(1)); + // Token 生成委托给调用方(Controller 层调用 ApiTokenService) + yield DeviceTokenResponse.success(null); + } + case USED -> throw new IllegalStateException("Device code already used"); + }; + } + + private String generateSecureToken() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String generateUserFriendlyCode() { + StringBuilder code = new StringBuilder(); + for (int i = 0; i < 8; i++) { + if (i == 4) code.append('-'); + code.append(CHARS.charAt(random.nextInt(CHARS.length()))); + } + return code.toString(); + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-auth -Dtest=DeviceAuthServiceTest` +预期:4 个测试全部 PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +git add server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +git commit -m "feat(cli): implement DeviceAuthService with Redis storage" +``` + +### Task 3: Device Auth Controller 层 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/DeviceAuthController.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/DeviceAuthWebController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/DeviceAuthControllerTest.java` + +- [ ] **Step 1: 编写 DeviceAuthController 测试** + +```java +package com.iflytek.skillhub.app.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.device.DeviceCodeResponse; +import com.iflytek.skillhub.auth.device.DeviceTokenResponse; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.bean.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(DeviceAuthController.class) +class DeviceAuthControllerTest { + @Autowired MockMvc mockMvc; + @MockBean DeviceAuthService deviceAuthService; + + @Test + void requestDeviceCode_returns_code() throws Exception { + when(deviceAuthService.generateDeviceCode()) + .thenReturn(new DeviceCodeResponse("dc123", "ABCD-1234", "/device", 900, 5)); + + mockMvc.perform(post("/api/v1/cli/auth/device/code")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.deviceCode").value("dc123")) + .andExpect(jsonPath("$.userCode").value("ABCD-1234")) + .andExpect(jsonPath("$.expiresIn").value(900)); + } + + @Test + void pollToken_returns_pending() throws Exception { + when(deviceAuthService.pollToken("dc123")) + .thenReturn(DeviceTokenResponse.pending()); + + mockMvc.perform(post("/api/v1/cli/auth/device/token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"deviceCode\":\"dc123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.error").value("authorization_pending")); + } +} +``` + +- [ ] **Step 2: 实现 DeviceAuthController** + +```java +package com.iflytek.skillhub.app.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.device.DeviceCodeResponse; +import com.iflytek.skillhub.auth.device.DeviceTokenResponse; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/cli/auth/device") +public class DeviceAuthController { + private final DeviceAuthService deviceAuthService; + + public DeviceAuthController(DeviceAuthService deviceAuthService) { + this.deviceAuthService = deviceAuthService; + } + + @PostMapping("/code") + public DeviceCodeResponse requestDeviceCode() { + return deviceAuthService.generateDeviceCode(); + } + + @PostMapping("/token") + public DeviceTokenResponse pollToken(@RequestBody Map body) { + return deviceAuthService.pollToken(body.get("deviceCode")); + } +} +``` + +- [ ] **Step 3: 实现 DeviceAuthWebController** + +```java +package com.iflytek.skillhub.app.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/device") +public class DeviceAuthWebController { + private final DeviceAuthService deviceAuthService; + + public DeviceAuthWebController(DeviceAuthService deviceAuthService) { + this.deviceAuthService = deviceAuthService; + } + + @PostMapping("/authorize") + @PreAuthorize("isAuthenticated()") + public ResponseEntity authorizeDevice( + @RequestBody Map body, + @AuthenticationPrincipal Long userId) { + deviceAuthService.authorizeDeviceCode(body.get("userCode"), userId); + return ResponseEntity.ok().build(); + } +} +``` + +- [ ] **Step 4: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=DeviceAuthControllerTest` +预期:2 个测试全部 PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/DeviceAuth* +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/DeviceAuth* +git commit -m "feat(cli): add Device Auth controllers" +``` + +### Task 4: CLI API 端点(whoami + resolve + check) + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/CliApiController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/CliApiControllerTest.java` + +- [ ] **Step 1: 编写 CliApiController 测试** + +```java +package com.iflytek.skillhub.app.controller; + +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(CliApiController.class) +class CliApiControllerTest { + @Autowired MockMvc mockMvc; + // @MockBean 各依赖服务... + + @Test + @WithMockUser + void whoami_returns_user_info() throws Exception { + mockMvc.perform(get("/api/v1/cli/whoami")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void whoami_unauthenticated_returns_401() throws Exception { + mockMvc.perform(get("/api/v1/cli/whoami")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void resolve_returns_version_info() throws Exception { + mockMvc.perform(get("/api/v1/cli/resolve") + .param("skill", "@global/my-skill") + .param("version", "latest")) + .andExpect(status().isOk()); + } +} +``` + +- [ ] **Step 2: 实现 CliApiController** + +```java +package com.iflytek.skillhub.app.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/cli") +public class CliApiController { + + // 注入 SkillQueryService, SkillPublishService, UserAccountRepository 等 + + @GetMapping("/whoami") + @PreAuthorize("isAuthenticated()") + public ResponseEntity whoami(@AuthenticationPrincipal Long userId) { + // 查询用户信息 + 所属 namespace 列表 + return ResponseEntity.ok(Map.of("code", 0, "data", Map.of("userId", userId))); + } + + @GetMapping("/resolve") + public ResponseEntity resolve( + @RequestParam String skill, + @RequestParam(defaultValue = "latest") String version, + @AuthenticationPrincipal Long userId) { + // 解析 @namespace/slug 格式 + // 调用 SkillQueryService 获取版本详情 + return ResponseEntity.ok(Map.of("code", 0)); + } + + @PostMapping("/check") + @PreAuthorize("isAuthenticated()") + public ResponseEntity check(@RequestParam("file") MultipartFile file) { + // 解压 zip,调用 SkillPackageValidator 校验 + return ResponseEntity.ok(Map.of("code", 0)); + } + + @PostMapping("/publish") + @PreAuthorize("isAuthenticated()") + public ResponseEntity publish( + @RequestParam("file") MultipartFile file, + @RequestParam String namespace, + @RequestParam(defaultValue = "PUBLIC") String visibility, + @AuthenticationPrincipal Long userId) { + // 调用 SkillPublishService + return ResponseEntity.ok(Map.of("code", 0)); + } +} +``` + +- [ ] **Step 3: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=CliApiControllerTest` +预期:3 个测试全部 PASS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/CliApiController.java +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/CliApiControllerTest.java +git commit -m "feat(cli): add CLI API endpoints (whoami, resolve, check, publish)" +``` + +### Task 5: 前端 Device Auth 授权页面 + +**Files:** +- Create: `web/src/pages/device-auth.tsx` +- Create: `web/src/features/device-auth/user-code-input.tsx` +- Create: `web/src/features/device-auth/authorize-confirm-dialog.tsx` +- Create: `web/src/features/device-auth/authorize-success.tsx` +- Create: `web/src/features/device-auth/use-authorize-device.ts` + +- [ ] **Step 1: 创建 use-authorize-device hook** + +```typescript +// web/src/features/device-auth/use-authorize-device.ts +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +export function useAuthorizeDevice() { + return useMutation({ + mutationFn: (userCode: string) => + apiClient.post('/api/v1/device/authorize', { userCode }), + }); +} +``` + +- [ ] **Step 2: 创建 UserCodeInput 组件** + +```tsx +// web/src/features/device-auth/user-code-input.tsx +import { useState, useRef } from 'react'; +import { Input } from '@/shared/ui/input'; + +interface UserCodeInputProps { + onComplete: (code: string) => void; +} + +export function UserCodeInput({ onComplete }: UserCodeInputProps) { + const [part1, setPart1] = useState(''); + const [part2, setPart2] = useState(''); + const ref2 = useRef(null); + + const handlePart1Change = (value: string) => { + const clean = value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4); + setPart1(clean); + if (clean.length === 4) ref2.current?.focus(); + }; + + const handlePart2Change = (value: string) => { + const clean = value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4); + setPart2(clean); + if (clean.length === 4 && part1.length === 4) { + onComplete(`${part1}-${clean}`); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const text = e.clipboardData.getData('text').replace(/\s/g, ''); + const match = text.match(/^([A-Z0-9]{4})-?([A-Z0-9]{4})$/i); + if (match) { + e.preventDefault(); + setPart1(match[1].toUpperCase()); + setPart2(match[2].toUpperCase()); + onComplete(`${match[1].toUpperCase()}-${match[2].toUpperCase()}`); + } + }; + + return ( +
+ handlePart1Change(e.target.value)} + className="w-24 text-center text-2xl font-mono tracking-widest" + maxLength={4} placeholder="ABCD" autoFocus /> + - + handlePart2Change(e.target.value)} + className="w-24 text-center text-2xl font-mono tracking-widest" + maxLength={4} placeholder="1234" /> +
+ ); +} +``` + +- [ ] **Step 3: 创建授权确认对话框和成功页面** + +`authorize-confirm-dialog.tsx`: +```tsx +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } + from '@/shared/ui/dialog'; +import { Button } from '@/shared/ui/button'; + +interface Props { + open: boolean; + userCode: string; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +} + +export function AuthorizeConfirmDialog({ open, userCode, onConfirm, onCancel, loading }: Props) { + return ( + !v && onCancel()}> + + + 确认授权 CLI 设备 + +
+

授权码:{userCode}

+

权限:读取和管理你的技能、命名空间

+

请确认这是你正在使用的 CLI 设备

+
+ + + + +
+
+ ); +} +``` + +`authorize-success.tsx`: +```tsx +import { CheckCircle } from 'lucide-react'; +import { Button } from '@/shared/ui/button'; + +export function AuthorizeSuccess() { + return ( +
+ +

授权成功

+

你的 CLI 设备已成功授权,请返回 CLI 继续操作

+ +
+ ); +} +``` + +- [ ] **Step 4: 创建 Device Auth 主页面** + +```tsx +// web/src/pages/device-auth.tsx +import { useState } from 'react'; +import { UserCodeInput } from '@/features/device-auth/user-code-input'; +import { AuthorizeConfirmDialog } from '@/features/device-auth/authorize-confirm-dialog'; +import { AuthorizeSuccess } from '@/features/device-auth/authorize-success'; +import { useAuthorizeDevice } from '@/features/device-auth/use-authorize-device'; +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'; + +export default function DeviceAuthPage() { + const [userCode, setUserCode] = useState(''); + const [showConfirm, setShowConfirm] = useState(false); + const [authorized, setAuthorized] = useState(false); + const mutation = useAuthorizeDevice(); + + const handleComplete = (code: string) => { + setUserCode(code); + setShowConfirm(true); + }; + + const handleConfirm = () => { + mutation.mutate(userCode, { + onSuccess: () => { setShowConfirm(false); setAuthorized(true); }, + }); + }; + + if (authorized) return ; + + return ( +
+ + + 授权 CLI 设备访问 + + +

请输入 CLI 显示的授权码:

+ + {mutation.isError && ( +

授权码无效,请检查后重试

+ )} +
+
+ setShowConfirm(false)} + loading={mutation.isPending} /> +
+ ); +} +``` + +- [ ] **Step 5: 添加路由配置** + +在 `web/src/router.tsx` 中添加 `/device` 路由(需要登录守卫)。 + +- [ ] **Step 6: Commit** + +```bash +git add web/src/pages/device-auth.tsx +git add web/src/features/device-auth/ +git commit -m "feat(cli): add Device Auth frontend page" +``` + +### Task 6: Chunk 3 验收 + +- [ ] **Step 1: 运行后端测试** + +运行:`cd server && ./mvnw test` +预期:所有测试通过 + +- [ ] **Step 2: 运行前端测试** + +运行:`cd web && npm test` +预期:所有测试通过 + +- [ ] **Step 3: 验证 9 个验收标准** + +逐一验证 Chunk 3 的验收标准。 --- @@ -763,7 +2419,372 @@ git commit -m "feat(review): implement review service **范围:** Canonical slug 映射 + 兼容层端点 -(详细步骤待补充) +**验收标准:** +1. ClawHub CLI 可以通过 `/.well-known/clawhub.json` 发现兼容层 API +2. ClawHub CLI 可以搜索技能,返回 canonical slug 格式 +3. ClawHub CLI 可以解析技能版本(`my-skill` 和 `team-ai--my-skill`) +4. ClawHub CLI 可以下载技能包 +5. ClawHub CLI 可以发布技能(需要 Token 认证) +6. ClawHub CLI 可以查询当前用户信息 +7. 所有兼容层端点测试通过 + +### Task 1: CanonicalSlugMapper 实现 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/CanonicalSlugMapper.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/SkillCoordinate.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/compat/CanonicalSlugMapperTest.java` + +- [ ] **Step 1: 编写 CanonicalSlugMapper 测试** + +```java +package com.iflytek.skillhub.app.compat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +class CanonicalSlugMapperTest { + private final CanonicalSlugMapper mapper = new CanonicalSlugMapper(); + + @ParameterizedTest + @CsvSource({ + "global, my-skill, my-skill", + "team-ai, my-skill, team-ai--my-skill", + "dev-team, code-review, dev-team--code-review" + }) + void toCanonical(String namespace, String slug, String expected) { + assertThat(mapper.toCanonical(namespace, slug)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "my-skill, global, my-skill", + "team-ai--my-skill, team-ai, my-skill", + "dev-team--code-review, dev-team, code-review" + }) + void fromCanonical(String canonical, String expectedNs, String expectedSlug) { + SkillCoordinate coord = mapper.fromCanonical(canonical); + assertThat(coord.namespaceSlug()).isEqualTo(expectedNs); + assertThat(coord.skillSlug()).isEqualTo(expectedSlug); + } + + @Test + void fromCanonical_no_separator_defaults_to_global() { + SkillCoordinate coord = mapper.fromCanonical("simple-skill"); + assertThat(coord.namespaceSlug()).isEqualTo("global"); + assertThat(coord.skillSlug()).isEqualTo("simple-skill"); + } +} +``` + +- [ ] **Step 2: 运行测试验证失败** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=CanonicalSlugMapperTest` +预期:编译失败 + +- [ ] **Step 3: 创建 SkillCoordinate record** + +```java +package com.iflytek.skillhub.app.compat; + +public record SkillCoordinate(String namespaceSlug, String skillSlug) {} +``` + +- [ ] **Step 4: 实现 CanonicalSlugMapper** + +```java +package com.iflytek.skillhub.app.compat; + +import org.springframework.stereotype.Component; + +@Component +public class CanonicalSlugMapper { + private static final String SEPARATOR = "--"; + + public String toCanonical(String namespaceSlug, String skillSlug) { + if ("global".equals(namespaceSlug)) { + return skillSlug; + } + return namespaceSlug + SEPARATOR + skillSlug; + } + + public SkillCoordinate fromCanonical(String canonicalSlug) { + int idx = canonicalSlug.indexOf(SEPARATOR); + if (idx == -1) { + return new SkillCoordinate("global", canonicalSlug); + } + return new SkillCoordinate( + canonicalSlug.substring(0, idx), + canonicalSlug.substring(idx + SEPARATOR.length())); + } +} +``` + +- [ ] **Step 5: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=CanonicalSlugMapperTest` +预期:5 个测试全部 PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/ +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/compat/ +git commit -m "feat(compat): add CanonicalSlugMapper with tests" +``` + +### Task 2: Well-Known 端点 + 兼容层 DTO + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/WellKnownController.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/dto/ClawHubSearchResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/dto/ClawHubSkillItem.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/dto/ClawHubResolveResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/dto/ClawHubPublishResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/dto/ClawHubWhoamiResponse.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/compat/WellKnownControllerTest.java` + +- [ ] **Step 1: 编写 WellKnownController 测试** + +```java +package com.iflytek.skillhub.app.compat; + +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.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(WellKnownController.class) +class WellKnownControllerTest { + @Autowired MockMvc mockMvc; + + @Test + void returns_api_base() throws Exception { + mockMvc.perform(get("/.well-known/clawhub.json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.apiBase").value("/api/compat/v1")); + } +} +``` + +- [ ] **Step 2: 实现 WellKnownController** + +```java +package com.iflytek.skillhub.app.compat; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + +@RestController +public class WellKnownController { + @GetMapping("/.well-known/clawhub.json") + public Map clawHubDiscovery() { + return Map.of("apiBase", "/api/compat/v1"); + } +} +``` + +- [ ] **Step 3: 创建兼容层 DTO** + +```java +// ClawHubSkillItem.java +package com.iflytek.skillhub.app.compat.dto; + +public record ClawHubSkillItem( + String slug, String name, String description, + String version, long downloads, int stars) {} + +// ClawHubSearchResponse.java +package com.iflytek.skillhub.app.compat.dto; + +import java.util.List; + +public record ClawHubSearchResponse( + List items, long total, int page, int size) {} + +// ClawHubResolveResponse.java +package com.iflytek.skillhub.app.compat.dto; + +public record ClawHubResolveResponse( + String slug, String name, String version, + String downloadUrl, int fileCount, long totalSize) {} + +// ClawHubPublishResponse.java +package com.iflytek.skillhub.app.compat.dto; + +public record ClawHubPublishResponse(String slug, String version, String status) {} + +// ClawHubWhoamiResponse.java +package com.iflytek.skillhub.app.compat.dto; + +public record ClawHubWhoamiResponse(Long userId, String username, String email) {} +``` + +- [ ] **Step 4: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=WellKnownControllerTest` +预期:PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/ +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/compat/ +git commit -m "feat(compat): add well-known endpoint and compat DTOs" +``` + +### Task 3: ClawHubCompatController 实现 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/ClawHubCompatController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/compat/ClawHubCompatControllerTest.java` + +- [ ] **Step 1: 编写 ClawHubCompatController 测试** + +```java +package com.iflytek.skillhub.app.compat; + +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.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ClawHubCompatController.class) +class ClawHubCompatControllerTest { + @Autowired MockMvc mockMvc; + @MockBean CanonicalSlugMapper slugMapper; + + @Test + void search_returns_compat_format() throws Exception { + mockMvc.perform(get("/api/compat/v1/search").param("q", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()); + } + + @Test + void resolve_parses_canonical_slug() throws Exception { + when(slugMapper.fromCanonical("my-skill")) + .thenReturn(new SkillCoordinate("global", "my-skill")); + mockMvc.perform(get("/api/compat/v1/resolve") + .param("slug", "my-skill")) + .andExpect(status().isOk()); + } + + @Test + void whoami_requires_auth() throws Exception { + mockMvc.perform(get("/api/compat/v1/whoami")) + .andExpect(status().isUnauthorized()); + } +} +``` + +- [ ] **Step 2: 实现 ClawHubCompatController** + +```java +package com.iflytek.skillhub.app.compat; + +import com.iflytek.skillhub.app.compat.dto.*; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api/compat/v1") +public class ClawHubCompatController { + private final CanonicalSlugMapper slugMapper; + + public ClawHubCompatController(CanonicalSlugMapper slugMapper) { + this.slugMapper = slugMapper; + } + + @GetMapping("/search") + public ClawHubSearchResponse search( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + // 调用 skillhub 搜索服务,转换为 canonical slug 格式 + // TODO: 注入 SkillSearchAppService 并调用 + return new ClawHubSearchResponse(List.of(), 0, page, size); + } + + @GetMapping("/resolve") + public ClawHubResolveResponse resolve( + @RequestParam String slug, + @RequestParam(defaultValue = "latest") String version) { + SkillCoordinate coord = slugMapper.fromCanonical(slug); + // TODO: 调用 SkillQueryService 获取版本详情 + return new ClawHubResolveResponse(slug, "", version, + "/api/compat/v1/download/" + slug + "/" + version, 0, 0); + } + + @GetMapping("/download/{slug}/{version}") + public ResponseEntity download( + @PathVariable String slug, + @PathVariable String version) { + SkillCoordinate coord = slugMapper.fromCanonical(slug); + // TODO: 调用 SkillDownloadService + return ResponseEntity.notFound().build(); + } + + @PostMapping("/publish") + @PreAuthorize("isAuthenticated()") + public ClawHubPublishResponse publish( + @RequestParam("file") MultipartFile file, + @RequestParam(defaultValue = "global") String namespace, + @AuthenticationPrincipal Long userId) { + // TODO: 调用 SkillPublishService + return new ClawHubPublishResponse("", "", "pending_review"); + } + + @GetMapping("/whoami") + @PreAuthorize("isAuthenticated()") + public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal Long userId) { + // TODO: 查询用户信息 + return new ClawHubWhoamiResponse(userId, "", ""); + } +} +``` + +- [ ] **Step 3: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=ClawHubCompatControllerTest` +预期:3 个测试全部 PASS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/compat/ClawHubCompatController.java +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/compat/ClawHubCompatControllerTest.java +git commit -m "feat(compat): add ClawHub compatibility controller" +``` + +### Task 4: Chunk 4 验收 + +- [ ] **Step 1: 运行所有测试** + +运行:`cd server && ./mvnw test` +预期:所有测试通过 + +- [ ] **Step 2: 验证 7 个验收标准** + +逐一验证 Chunk 4 的验收标准。 --- @@ -771,28 +2792,940 @@ git commit -m "feat(review): implement review service **范围:** 幂等拦截器 + 管理后台前端 -(详细步骤待补充) +**验收标准:** +1. 写操作带 `X-Request-Id` 时,重复请求返回原始结果 +2. Redis 不可用时,PostgreSQL 兜底去重 +3. 定时任务清理过期幂等记录 +4. 管理后台:USER_ADMIN 可以查看用户列表,编辑角色,封禁/解封用户 +5. 管理后台:AUDITOR 可以查看审计日志,筛选和搜索 +6. 管理后台:SUPER_ADMIN 可以访问所有管理功能 +7. 前端路由守卫:非管理员访问 `/admin` 跳转到 403 页面 +8. 所有测试通过 + +### Task 1: IdempotencyRecord 实体和 Repository + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecord.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyStatus.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/IdempotencyRecordRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java` + +- [ ] **Step 1: 创建 IdempotencyStatus 枚举** + +```java +package com.iflytek.skillhub.domain.idempotency; + +public enum IdempotencyStatus { + PROCESSING, + COMPLETED, + FAILED +} +``` + +- [ ] **Step 2: 创建 IdempotencyRecord 实体** + +```java +package com.iflytek.skillhub.domain.idempotency; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "idempotency_record") +public class IdempotencyRecord { + @Id + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "resource_type", length = 64, nullable = false) + private String resourceType; + + @Column(name = "resource_id") + private Long resourceId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private IdempotencyStatus status; + + @Column(name = "response_status_code") + private Integer responseStatusCode; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + protected IdempotencyRecord() {} + + public IdempotencyRecord(String requestId, String resourceType, + IdempotencyStatus status, Instant expiresAt) { + this.requestId = requestId; + this.resourceType = resourceType; + this.status = status; + this.expiresAt = expiresAt; + } + + public String getRequestId() { return requestId; } + public String getResourceType() { return resourceType; } + public Long getResourceId() { return resourceId; } + public void setResourceId(Long resourceId) { this.resourceId = resourceId; } + public IdempotencyStatus getStatus() { return status; } + public void setStatus(IdempotencyStatus status) { this.status = status; } + public Integer getResponseStatusCode() { return responseStatusCode; } + public void setResponseStatusCode(Integer code) { this.responseStatusCode = code; } + public Instant getCreatedAt() { return createdAt; } + public Instant getExpiresAt() { return expiresAt; } +} +``` + +- [ ] **Step 3: 创建 Repository 接口** + +```java +package com.iflytek.skillhub.domain.idempotency; + +import java.time.Instant; +import java.util.Optional; + +public interface IdempotencyRecordRepository { + IdempotencyRecord save(IdempotencyRecord record); + Optional findById(String requestId); + int deleteExpired(Instant now); + int markStaleAsFailed(Instant threshold); + void updateToCompleted(String requestId, String resourceType, + Long resourceId, int statusCode); +} +``` + +- [ ] **Step 4: 实现 JPA Repository** + +```java +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.idempotency.*; +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 JpaIdempotencyRecordRepository + extends JpaRepository, IdempotencyRecordRepository { + + @Modifying + @Query("DELETE FROM IdempotencyRecord r WHERE r.expiresAt < :now") + int deleteExpired(@Param("now") Instant now); + + @Modifying + @Query(""" + UPDATE IdempotencyRecord r SET r.status = 'FAILED' + WHERE r.status = 'PROCESSING' AND r.createdAt < :threshold + """) + int markStaleAsFailed(@Param("threshold") Instant threshold); + + @Modifying + @Query(""" + UPDATE IdempotencyRecord r + SET r.status = 'COMPLETED', r.resourceType = :resourceType, + r.resourceId = :resourceId, r.responseStatusCode = :statusCode + WHERE r.requestId = :requestId + """) + void updateToCompleted(@Param("requestId") String requestId, + @Param("resourceType") String resourceType, + @Param("resourceId") Long resourceId, + @Param("statusCode") int statusCode); +} +``` + +- [ ] **Step 5: 编译验证** + +运行:`cd server && ./mvnw compile` +预期:编译成功 + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/idempotency/ +git add server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaIdempotencyRecordRepository.java +git commit -m "feat(idempotency): add IdempotencyRecord entity and repository" +``` + +### Task 2: IdempotencyInterceptor 实现 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/interceptor/IdempotencyInterceptor.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/config/WebMvcIdempotencyConfig.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/interceptor/IdempotencyInterceptorTest.java` + +- [ ] **Step 1: 编写 IdempotencyInterceptor 测试** + +```java +package com.iflytek.skillhub.app.interceptor; + +import com.iflytek.skillhub.domain.idempotency.*; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IdempotencyInterceptorTest { + @Mock RedisTemplate redisTemplate; + @Mock ValueOperations valueOps; + @Mock IdempotencyRecordRepository recordRepository; + @InjectMocks IdempotencyInterceptor interceptor; + + MockHttpServletRequest request; + MockHttpServletResponse response; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + + @Test + void get_request_passes_through() throws Exception { + request.setMethod("GET"); + assertThat(interceptor.preHandle(request, response, null)).isTrue(); + } + + @Test + void post_without_request_id_passes_through() throws Exception { + request.setMethod("POST"); + assertThat(interceptor.preHandle(request, response, null)).isTrue(); + } + + @Test + void post_with_new_request_id_passes_through() throws Exception { + request.setMethod("POST"); + request.addHeader("X-Request-Id", "550e8400-e29b-41d4-a716-446655440000"); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenReturn(true); + assertThat(interceptor.preHandle(request, response, null)).isTrue(); + verify(recordRepository).save(any(IdempotencyRecord.class)); + } + + @Test + void post_with_duplicate_request_id_returns_completed_result() throws Exception { + request.setMethod("POST"); + request.addHeader("X-Request-Id", "550e8400-e29b-41d4-a716-446655440000"); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenReturn(false); + + IdempotencyRecord record = new IdempotencyRecord( + "550e8400-e29b-41d4-a716-446655440000", "skill_version", + IdempotencyStatus.COMPLETED, null); + record.setResourceId(123L); + record.setResponseStatusCode(200); + when(recordRepository.findById(anyString())).thenReturn(Optional.of(record)); + + assertThat(interceptor.preHandle(request, response, null)).isFalse(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + void post_with_processing_request_returns_409() throws Exception { + request.setMethod("POST"); + request.addHeader("X-Request-Id", "550e8400-e29b-41d4-a716-446655440000"); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenReturn(false); + + IdempotencyRecord record = new IdempotencyRecord( + "550e8400-e29b-41d4-a716-446655440000", "skill_version", + IdempotencyStatus.PROCESSING, null); + when(recordRepository.findById(anyString())).thenReturn(Optional.of(record)); + + assertThat(interceptor.preHandle(request, response, null)).isFalse(); + assertThat(response.getStatus()).isEqualTo(409); + } +} +``` + +- [ ] **Step 2: 运行测试验证失败** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=IdempotencyInterceptorTest` +预期:编译失败 + +- [ ] **Step 3: 实现 IdempotencyInterceptor** + +```java +package com.iflytek.skillhub.app.interceptor; + +import com.iflytek.skillhub.domain.idempotency.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.regex.Pattern; + +public class IdempotencyInterceptor implements HandlerInterceptor { + private static final String HEADER = "X-Request-Id"; + private static final String ATTR = "idempotency.requestId"; + private static final Set WRITE_METHODS = Set.of("POST", "PUT", "DELETE"); + private static final Pattern UUID_PATTERN = Pattern.compile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + + private final RedisTemplate redisTemplate; + private final IdempotencyRecordRepository recordRepository; + + public IdempotencyInterceptor(RedisTemplate redisTemplate, + IdempotencyRecordRepository recordRepository) { + this.redisTemplate = redisTemplate; + this.recordRepository = recordRepository; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + if (!WRITE_METHODS.contains(request.getMethod())) return true; + + String requestId = request.getHeader(HEADER); + if (requestId == null || requestId.isBlank()) return true; + + if (!UUID_PATTERN.matcher(requestId.toLowerCase()).matches()) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Invalid X-Request-Id format\"}"); + return false; + } + + String redisKey = "idempotent:" + requestId; + Boolean isNew; + try { + isNew = redisTemplate.opsForValue() + .setIfAbsent(redisKey, "1", Duration.ofHours(24)); + } catch (Exception e) { + // Redis 不可用,fall through 到 PostgreSQL + isNew = true; + } + + if (Boolean.FALSE.equals(isNew)) { + return handleDuplicate(requestId, response); + } + + // 新请求,插入 PROCESSING 记录 + IdempotencyRecord record = new IdempotencyRecord( + requestId, "unknown", IdempotencyStatus.PROCESSING, + Instant.now().plus(24, ChronoUnit.HOURS)); + recordRepository.save(record); + request.setAttribute(ATTR, requestId); + return true; + } + + private boolean handleDuplicate(String requestId, HttpServletResponse response) + throws Exception { + var record = recordRepository.findById(requestId).orElse(null); + if (record == null) { + // Redis 有但 DB 无,可能脏数据,允许重试 + redisTemplate.delete("idempotent:" + requestId); + return true; + } + return switch (record.getStatus()) { + case COMPLETED -> { + response.setStatus(record.getResponseStatusCode()); + response.setContentType("application/json"); + response.getWriter().write(String.format( + "{\"code\":0,\"data\":{\"resourceType\":\"%s\",\"resourceId\":%d}}", + record.getResourceType(), record.getResourceId())); + yield false; + } + case PROCESSING -> { + response.setStatus(HttpStatus.CONFLICT.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Request is being processed\"}"); + yield false; + } + case FAILED -> { + redisTemplate.delete("idempotent:" + requestId); + yield true; + } + }; + } +} +``` + +- [ ] **Step 4: 注册拦截器** + +```java +package com.iflytek.skillhub.app.config; + +import com.iflytek.skillhub.app.interceptor.IdempotencyInterceptor; +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcIdempotencyConfig implements WebMvcConfigurer { + private final RedisTemplate redisTemplate; + private final IdempotencyRecordRepository recordRepository; + + public WebMvcIdempotencyConfig(RedisTemplate redisTemplate, + IdempotencyRecordRepository recordRepository) { + this.redisTemplate = redisTemplate; + this.recordRepository = recordRepository; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new IdempotencyInterceptor(redisTemplate, recordRepository)) + .addPathPatterns("/api/**"); + } +} +``` + +- [ ] **Step 5: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=IdempotencyInterceptorTest` +预期:5 个测试全部 PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/interceptor/ +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/config/WebMvcIdempotencyConfig.java +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/interceptor/ +git commit -m "feat(idempotency): add IdempotencyInterceptor with Redis + PostgreSQL" +``` + +### Task 3: 幂等记录定时清理 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/task/IdempotencyCleanupTask.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/task/IdempotencyCleanupTaskTest.java` + +- [ ] **Step 1: 编写清理任务测试** + +```java +package com.iflytek.skillhub.app.task; + +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IdempotencyCleanupTaskTest { + @Mock IdempotencyRecordRepository repository; + @InjectMocks IdempotencyCleanupTask task; + + @Test + void cleanupExpired_deletes_old_records() { + when(repository.deleteExpired(any(Instant.class))).thenReturn(5); + task.cleanupExpiredRecords(); + verify(repository).deleteExpired(any(Instant.class)); + } + + @Test + void cleanupStale_marks_processing_as_failed() { + when(repository.markStaleAsFailed(any(Instant.class))).thenReturn(2); + task.cleanupStaleProcessing(); + verify(repository).markStaleAsFailed(any(Instant.class)); + } +} +``` + +- [ ] **Step 2: 实现清理任务** + +```java +package com.iflytek.skillhub.app.task; + +import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Component +public class IdempotencyCleanupTask { + private static final Logger log = LoggerFactory.getLogger(IdempotencyCleanupTask.class); + private final IdempotencyRecordRepository repository; + + public IdempotencyCleanupTask(IdempotencyRecordRepository repository) { + this.repository = repository; + } + + @Scheduled(cron = "0 0 2 * * ?") + public void cleanupExpiredRecords() { + int deleted = repository.deleteExpired(Instant.now()); + log.info("Cleaned up {} expired idempotency records", deleted); + } + + @Scheduled(fixedDelay = 300000) + public void cleanupStaleProcessing() { + Instant threshold = Instant.now().minus(5, ChronoUnit.MINUTES); + int updated = repository.markStaleAsFailed(threshold); + if (updated > 0) { + log.warn("Marked {} stale PROCESSING records as FAILED", updated); + } + } +} +``` + +- [ ] **Step 3: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest=IdempotencyCleanupTaskTest` +预期:2 个测试全部 PASS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/task/ +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/task/ +git commit -m "feat(idempotency): add cleanup scheduled tasks" +``` + +### Task 4: 管理后台 API(用户管理 + 审计日志) + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/admin/UserManagementController.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/admin/AuditLogController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/admin/UserManagementControllerTest.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/admin/AuditLogControllerTest.java` + +- [ ] **Step 1: 编写 UserManagementController 测试** + +```java +package com.iflytek.skillhub.app.controller.admin; + +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.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(UserManagementController.class) +class UserManagementControllerTest { + @Autowired MockMvc mockMvc; + + @Test + void list_users_requires_admin_role() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "USER_ADMIN") + void list_users_accessible_by_user_admin() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(roles = "SUPER_ADMIN") + void list_users_accessible_by_super_admin() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isOk()); + } +} +``` + +- [ ] **Step 2: 实现 UserManagementController** + +```java +package com.iflytek.skillhub.app.controller.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/users") +@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") +public class UserManagementController { + + @GetMapping + public ResponseEntity listUsers( + @RequestParam(required = false) String search, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + // TODO: 注入 UserAccountRepository 查询 + return ResponseEntity.ok(Map.of("items", List.of(), "total", 0)); + } + + @GetMapping("/{userId}") + public ResponseEntity getUserDetail(@PathVariable Long userId) { + // TODO: 查询用户详情 + 角色 + namespace 成员 + return ResponseEntity.ok(Map.of("userId", userId)); + } + + @PutMapping("/{userId}/roles") + public ResponseEntity updateUserRoles( + @PathVariable Long userId, + @RequestBody Map> body) { + // TODO: 更新用户平台角色 + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{userId}/status") + public ResponseEntity updateUserStatus( + @PathVariable Long userId, + @RequestBody Map body) { + // TODO: 封禁/解封用户 + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 3: 编写 AuditLogController 测试** + +```java +package com.iflytek.skillhub.app.controller.admin; + +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.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AuditLogController.class) +class AuditLogControllerTest { + @Autowired MockMvc mockMvc; + + @Test + void audit_logs_requires_auditor_role() throws Exception { + mockMvc.perform(get("/api/v1/admin/audit-logs")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "AUDITOR") + void audit_logs_accessible_by_auditor() throws Exception { + mockMvc.perform(get("/api/v1/admin/audit-logs")) + .andExpect(status().isOk()); + } +} +``` + +- [ ] **Step 4: 实现 AuditLogController** + +```java +package com.iflytek.skillhub.app.controller.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/audit-logs") +@PreAuthorize("hasAnyRole('AUDITOR', 'SUPER_ADMIN')") +public class AuditLogController { + + @GetMapping + public ResponseEntity listAuditLogs( + @RequestParam(required = false) String action, + @RequestParam(required = false) Long actorUserId, + @RequestParam(required = false) String targetType, + @RequestParam(required = false) String startTime, + @RequestParam(required = false) String endTime, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + // TODO: 注入 AuditLogRepository 查询 + return ResponseEntity.ok(Map.of("items", List.of(), "total", 0)); + } +} +``` + +- [ ] **Step 5: 运行测试验证通过** + +运行:`cd server && ./mvnw test -pl skillhub-app -Dtest="UserManagementControllerTest,AuditLogControllerTest"` +预期:5 个测试全部 PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/app/controller/admin/ +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/app/controller/admin/ +git commit -m "feat(admin): add UserManagement and AuditLog controllers" +``` + +### Task 5: 管理后台前端页面 + +**Files:** +- Create: `web/src/pages/admin/users.tsx` +- Create: `web/src/pages/admin/user-detail.tsx` +- Create: `web/src/pages/admin/audit-logs.tsx` +- Create: `web/src/features/admin/user-table.tsx` +- Create: `web/src/features/admin/edit-roles-dialog.tsx` +- Create: `web/src/features/admin/audit-log-table.tsx` +- Create: `web/src/features/admin/use-users.ts` +- Create: `web/src/features/admin/use-audit-logs.ts` +- Create: `web/src/features/admin/use-update-user-roles.ts` + +- [ ] **Step 1: 创建 admin hooks** + +```typescript +// web/src/features/admin/use-users.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +export function useUsers(params: { search?: string; status?: string; page: number }) { + return useQuery({ + queryKey: ['admin', 'users', params], + queryFn: () => apiClient.get('/api/v1/admin/users', { params }), + }); +} + +// web/src/features/admin/use-audit-logs.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +export function useAuditLogs(params: { + action?: string; actorUserId?: number; + startTime?: string; endTime?: string; page: number; +}) { + return useQuery({ + queryKey: ['admin', 'audit-logs', params], + queryFn: () => apiClient.get('/api/v1/admin/audit-logs', { params }), + }); +} + +// web/src/features/admin/use-update-user-roles.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +export function useUpdateUserRoles() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, roles }: { userId: number; roles: string[] }) => + apiClient.put(`/api/v1/admin/users/${userId}/roles`, { roles }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), + }); +} +``` + +- [ ] **Step 2: 创建用户管理页面** + +```tsx +// web/src/pages/admin/users.tsx +import { useState } from 'react'; +import { useUsers } from '@/features/admin/use-users'; +import { Input } from '@/shared/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } + from '@/shared/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } + from '@/shared/ui/table'; +import { Badge } from '@/shared/ui/badge'; +import { Button } from '@/shared/ui/button'; +import { Link } from '@tanstack/react-router'; + +export default function AdminUsersPage() { + const [search, setSearch] = useState(''); + const [status, setStatus] = useState(); + const [page, setPage] = useState(0); + const { data, isLoading } = useUsers({ search, status, page }); + + return ( +
+

用户管理

+
+ setSearch(e.target.value)} className="max-w-xs" /> + +
+ + + + 用户名 + 邮箱 + 状态 + 角色 + 操作 + + + + {data?.items?.map((user: any) => ( + + {user.displayName} + {user.email} + + + {user.status} + + + {user.roles?.join(', ')} + + + + + ))} + +
+
+ ); +} +``` + +- [ ] **Step 3: 创建审计日志页面** + +```tsx +// web/src/pages/admin/audit-logs.tsx +import { useState } from 'react'; +import { useAuditLogs } from '@/features/admin/use-audit-logs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } + from '@/shared/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } + from '@/shared/ui/table'; +import { Badge } from '@/shared/ui/badge'; + +export default function AuditLogsPage() { + const [action, setAction] = useState(); + const [page, setPage] = useState(0); + const { data, isLoading } = useAuditLogs({ action, page }); + + return ( +
+

审计日志

+
+ +
+ + + + 时间 + 操作人 + 操作 + 目标 + IP + + + + {data?.items?.map((log: any) => ( + + {log.createdAt} + {log.actorName} + {log.action} + {log.targetType} #{log.targetId} + {log.clientIp} + + ))} + +
+
+ ); +} +``` + +- [ ] **Step 4: 添加管理后台路由守卫** + +在 `web/src/router.tsx` 中添加 `/admin` 路由组,配置角色守卫: + +```typescript +// 管理后台路由守卫 - 检查用户是否有管理员角色 +function AdminGuard({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + const adminRoles = ['SUPER_ADMIN', 'USER_ADMIN', 'AUDITOR']; + const hasAdminRole = user?.roles?.some((r: string) => adminRoles.includes(r)); + if (!hasAdminRole) return ; + return <>{children}; +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add web/src/pages/admin/ +git add web/src/features/admin/ +git commit -m "feat(admin): add admin dashboard pages (users, audit-logs)" +``` + +### Task 6: Chunk 5 验收 + +- [ ] **Step 1: 运行后端测试** + +运行:`cd server && ./mvnw test` +预期:所有测试通过 + +- [ ] **Step 2: 运行前端测试** + +运行:`cd web && npm test` +预期:所有测试通过 + +- [ ] **Step 3: 验证 8 个验收标准** + +逐一验证 Chunk 5 的验收标准。 + +- [ ] **Step 4: 最终代码审查** + +运行:`cd server && ./mvnw compile && cd ../web && npm run build` +预期:编译和构建全部成功 --- ## 实施说明 -**完整实施计划说明:** - -由于 Phase 3 包含 5 个 Chunk,每个 Chunk 包含 10-15 个任务,每个任务包含 5-10 个 TDD 步骤,完整的实施计划预计超过 5000 行。 - **当前文档状态:** -- ✅ Chunk 1 的前 5 个任务已详细编写(数据库、实体、Repository、权限检查、服务核心) -- ⏳ Chunk 1 的剩余任务(Task 6-10)以概要形式列出 -- ⏳ Chunk 2-5 以任务概要形式列出 +- ✅ Chunk 1:审核流程核心(后端)— Task 1-5 详细 TDD 步骤,Task 6-10 概要 +- ✅ Chunk 2:评分收藏 + 前端审核中心 — Task 1-4 详细 TDD 步骤,Task 5-11 概要 +- ✅ Chunk 3:CLI API + Web 授权 — Task 1-6 详细 TDD 步骤 +- ✅ Chunk 4:ClawHub 兼容层 — Task 1-4 详细 TDD 步骤 +- ✅ Chunk 5:幂等去重 + 管理后台 — Task 1-6 详细 TDD 步骤 **建议的实施方式:** -1. **使用 superpowers:subagent-driven-development** - 为每个 Chunk 派发独立的子代理 -2. **渐进式实施** - 先完成 Chunk 1,验收通过后再进行 Chunk 2 -3. **参考设计文档** - 每个任务的详细实现逻辑参考 `docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md` - -**如需完整的详细步骤:** - -可以在实施过程中,针对每个 Chunk 单独生成详细的实施步骤。每个 Chunk 的详细计划约 1000-1500 行。 +1. **使用 superpowers:subagent-driven-development** — 为每个 Chunk 派发独立的子代理 +2. **渐进式实施** — 先完成 Chunk 1,验收通过后再进行 Chunk 2 +3. **参考设计文档** — 每个任务的详细实现逻辑参考 `docs/superpowers/specs/2026-03-12-phase3-review-cli-social-design.md` +4. **概要任务的实施** — 标记为概要的任务(如 Chunk 1 Task 6-10、Chunk 2 Task 5-11),实施时参考设计文档中的对应章节,按照已有详细任务的 TDD 模式编写代码