skillhub/docs/superpowers/plans/2026-03-12-phase4-ops-polish.md

3204 lines
106 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 4: 运维增强 + 打磨 + 开源就绪 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 扩展认证体系(本地密码登录 + 多账号合并)、完善治理(技能隐藏/撤回 + 审计日志查询)、提升可观测性(Prometheus)、优化性能(索引 + 预签名 URL + 前端代码分割)、加固安全、实现 Docker 一键启动和 K8s 部署,建立开源项目基础设施。
**Architecture:** 后端沿用 Spring Boot 3.x 分层架构(skillhub-auth / skillhub-domain / skillhub-app / skillhub-infra / skillhub-storage / skillhub-search),前端沿用 React 19 + TanStack Router + TanStack Query。新增本地认证独立于 OAuth 体系,通过 `local_credential` 表存在性判断认证来源。4 个 Chunk 渐进交付。
**身份主键约束:** 用户身份主键全链路统一使用 `string`。本计划里所有 `userId`、`primaryUserId`、`secondaryUserId`、`hiddenBy`、`yankedBy`、`actorUserId` 等用户标识字段均按字符串实现;历史 `Long` / `BIGINT` 描述不再有效。
**Tech Stack:** Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA, Flyway, PostgreSQL 16, Redis 7, BCrypt, Micrometer/Prometheus, React 19, TypeScript, Vite, TanStack Router/Query, shadcn/ui, Docker, Kubernetes
**Spec:** `docs/superpowers/specs/2026-03-12-phase4-ops-polish-design.md`
---
## File Structure
### Chunk 1: 本地认证 + 多账号合并
**New files (backend):**
- `server/skillhub-app/src/main/resources/db/migration/V4__phase4_auth_governance.sql` — Flyway 迁移
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java` — 本地凭证实体
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java` — 凭证仓储接口
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java` — 密码策略校验
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java` — 本地认证服务
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java` — 合并请求实体
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java` — 合并仓储接口
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java` — 合并服务
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java` — 本地认证 Controller
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java` — 合并 Controller
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java` — 注册请求 DTO
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java` — 登录请求 DTO
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java` — 修改密码 DTO
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java` — 发起合并 DTO
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java` — 验证合并 DTO
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SeedDataRunner.java` — 种子数据初始化
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LocalCredentialJpaRepository.java` — JPA 实现
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AccountMergeRequestJpaRepository.java` — JPA 实现
**New files (backend tests):**
- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java`
- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java`
- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java`
**Modified files (backend):**
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java` — 添加本地认证端点 permitAll + BCrypt Bean
- `server/skillhub-app/src/main/resources/messages.properties` — 新增 i18n 消息
- `server/skillhub-app/src/main/resources/messages_zh.properties` — 新增中文消息
**New files (frontend):**
- `web/src/pages/register.tsx` — 注册页
- `web/src/pages/settings/security.tsx` — 密码修改页
- `web/src/pages/settings/accounts.tsx` — 账号合并页
- `web/src/features/auth/use-local-auth.ts` — 本地认证 Hook
- `web/src/features/auth/use-account-merge.ts` — 账号合并 Hook
**Modified files (frontend):**
- `web/src/pages/login.tsx` — 添加用户名密码 Tab
- `web/src/app/router.tsx` — 添加新路由
### Chunk 2: 技能治理 + 审计日志 + 可观测性
**New files (backend):**
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java` — 治理服务
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java` — 审计日志实体(如不存在)
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java` — 审计日志仓储
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java` — 审计日志查询
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java` — 技能治理 Controller
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java` — 审计日志 Controller
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/MetricsConfig.java` — Prometheus 指标配置
**New files (backend tests):**
- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java`
- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogQueryServiceTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java`
**Modified files (backend):**
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java` — 添加 hidden/hiddenAt/hiddenBy 字段
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java` — 添加 yankedAt/yankedBy/yankReason 字段
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java` — 添加 YANKED 枚举值
- `server/skillhub-app/src/main/resources/application.yml` — Actuator + Prometheus 配置
**New files (frontend):**
- `web/src/pages/admin/audit-logs.tsx` — 审计日志页
- `web/src/features/admin/use-audit-logs.ts` — 审计日志 Hook
- `web/src/features/skill/skill-governance-actions.tsx` — 隐藏/撤回操作组件
### Chunk 3: 性能优化 + 安全加固
**Modified files (backend):**
- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java` — 添加 generatePresignedUrl 方法
- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java` — 实现预签名 URL
- `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java` — 返回 null(降级)
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java` — 下载 302 重定向
- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java` — 安全响应头
- `server/skillhub-app/src/main/resources/application.yml` — Session Cookie 安全配置
**New files (backend tests):**
- `server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServicePresignedUrlTest.java`
**Modified files (frontend):**
- `web/src/app/router.tsx` — lazy routes 改造
- `web/src/features/skill/markdown-renderer.tsx` — rehype-sanitize 集成
### Chunk 4: Docker 一键启动 + K8s + 开源基础设施
**New files:**
- `Dockerfile` — 多阶段构建(根目录)
- `deploy/nginx/default.conf` — Nginx SPA 配置
- `deploy/k8s/backend-deployment.yaml`
- `deploy/k8s/frontend-deployment.yaml`
- `deploy/k8s/services.yaml`
- `deploy/k8s/ingress.yaml`
- `deploy/k8s/configmap.yaml`
- `deploy/k8s/secret.yaml.example`
- `README.md` — 项目 README
- `CONTRIBUTING.md` — 贡献指南
- `LICENSE` — Apache 2.0
- `CODE_OF_CONDUCT.md` — 行为准则
- `.github/ISSUE_TEMPLATE/bug_report.md`
- `.github/ISSUE_TEMPLATE/feature_request.md`
- `.github/pull_request_template.md`
**Modified files:**
- `docker-compose.yml` — 添加 backend/frontend 服务 + 健康检查
---
## Chunk 1: 本地认证 + 多账号合并
### Task 1.1: Flyway 迁移 — local_credential + account_merge_request
**Files:**
- Create: `server/skillhub-app/src/main/resources/db/migration/V4__phase4_auth_governance.sql`
- [ ] **Step 1: 编写迁移脚本**
```sql
-- V4__phase4_auth_governance.sql
-- 本地密码凭证
CREATE TABLE local_credential (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_local_credential_username ON local_credential(username);
CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential(user_id);
-- 账号合并请求
CREATE TABLE account_merge_request (
id BIGSERIAL PRIMARY KEY,
primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
verification_token VARCHAR(255),
token_expires_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_merge_primary_status ON account_merge_request(primary_user_id, status);
CREATE UNIQUE INDEX idx_merge_secondary_pending ON account_merge_request(secondary_user_id) WHERE status = 'PENDING';
CREATE INDEX idx_merge_token_pending ON account_merge_request(verification_token) WHERE status = 'PENDING';
```
- [ ] **Step 2: 验证迁移**
Run: `cd server && mvn flyway:migrate -pl skillhub-app`
Expected: 迁移成功,V4 脚本执行无错误
- [ ] **Step 3: Commit**
```bash
git add server/skillhub-app/src/main/resources/db/migration/V4__phase4_auth_governance.sql
git commit -m "feat(db): add V4 migration for local_credential and account_merge_request"
```
### Task 1.2: PasswordPolicyValidator — 密码策略校验
**Files:**
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java`
- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java`
- [ ] **Step 1: 编写失败测试**
```java
package com.iflytek.skillhub.auth.local;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.*;
class PasswordPolicyValidatorTest {
private final PasswordPolicyValidator validator = new PasswordPolicyValidator();
@Test
void validPassword_passes() {
assertThat(validator.validate("Abcdef1!")).isEmpty();
}
@Test
void tooShort_fails() {
assertThat(validator.validate("Ab1!xyz")).isNotEmpty();
}
@Test
void tooLong_fails() {
assertThat(validator.validate("A".repeat(129))).isNotEmpty();
}
@Test
void onlyOneCharType_fails() {
assertThat(validator.validate("abcdefgh")).isNotEmpty();
}
@Test
void twoCharTypes_fails() {
assertThat(validator.validate("abcdefg1")).isNotEmpty();
}
@ParameterizedTest
@ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"})
void threeCharTypes_passes(String password) {
assertThat(validator.validate(password)).isEmpty();
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd server && mvn test -pl skillhub-auth -Dtest=PasswordPolicyValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: FAIL — 类不存在
- [ ] **Step 3: 实现 PasswordPolicyValidator**
```java
package com.iflytek.skillhub.auth.local;
import java.util.ArrayList;
import java.util.List;
public class PasswordPolicyValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 128;
private static final int MIN_CHAR_TYPES = 3;
public List<String> validate(String password) {
var errors = new ArrayList<String>();
if (password == null || password.length() < MIN_LENGTH) {
errors.add("password.too_short");
return errors;
}
if (password.length() > MAX_LENGTH) {
errors.add("password.too_long");
return errors;
}
int types = 0;
if (password.chars().anyMatch(Character::isUpperCase)) types++;
if (password.chars().anyMatch(Character::isLowerCase)) types++;
if (password.chars().anyMatch(Character::isDigit)) types++;
if (password.chars().anyMatch(c -> !Character.isLetterOrDigit(c))) types++;
if (types < MIN_CHAR_TYPES) {
errors.add("password.insufficient_complexity");
}
return errors;
}
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `cd server && mvn test -pl skillhub-auth -Dtest=PasswordPolicyValidatorTest`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java \
server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java
git commit -m "feat(auth): add PasswordPolicyValidator with TDD"
```
### Task 1.3: LocalCredential 实体 + Repository
**Files:**
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java`
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java`
- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LocalCredentialJpaRepository.java`
- [ ] **Step 1: 创建 LocalCredential 实体**
```java
package com.iflytek.skillhub.auth.local;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "local_credential")
public class LocalCredential {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private String userId;
@Column(nullable = false, length = 64)
private String username;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "failed_attempts", nullable = false)
private int failedAttempts = 0;
@Column(name = "locked_until")
private LocalDateTime lockedUntil;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected LocalCredential() {}
public LocalCredential(String userId, String username, String passwordHash) {
this.userId = userId;
this.username = username;
this.passwordHash = passwordHash;
}
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public Long getUserId() { return userId; }
public String getUsername() { return username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public int getFailedAttempts() { return failedAttempts; }
public void setFailedAttempts(int failedAttempts) { this.failedAttempts = failedAttempts; }
public LocalDateTime getLockedUntil() { return lockedUntil; }
public void setLockedUntil(LocalDateTime lockedUntil) { this.lockedUntil = lockedUntil; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public boolean isLocked() {
return lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now());
}
public void recordFailedAttempt(int maxAttempts, int lockMinutes) {
this.failedAttempts++;
if (this.failedAttempts >= maxAttempts) {
this.lockedUntil = LocalDateTime.now().plusMinutes(lockMinutes);
}
}
public void resetFailedAttempts() {
this.failedAttempts = 0;
this.lockedUntil = null;
}
}
```
- [ ] **Step 2: 创建 Repository 接口**
```java
package com.iflytek.skillhub.auth.local;
import java.util.Optional;
public interface LocalCredentialRepository {
Optional<LocalCredential> findByUsername(String username);
Optional<LocalCredential> findByUserId(String userId);
LocalCredential save(LocalCredential credential);
boolean existsByUsername(String username);
}
```
- [ ] **Step 3: 创建 JPA 实现**
```java
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.auth.local.LocalCredential;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface LocalCredentialJpaRepository extends JpaRepository<LocalCredential, Long>, LocalCredentialRepository {
Optional<LocalCredential> findByUsername(String username);
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsername(String username);
}
```
- [ ] **Step 4: 编译验证**
Run: `cd server && mvn compile -pl skillhub-auth,skillhub-infra`
Expected: BUILD SUCCESS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java \
server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java \
server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/LocalCredentialJpaRepository.java
git commit -m "feat(auth): add LocalCredential entity and repository"
```
### Task 1.4: LocalAuthService — 注册/登录/改密
**Files:**
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java`
- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java`
- [ ] **Step 1: 编写注册测试**
```java
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LocalAuthServiceTest {
@Mock private LocalCredentialRepository credentialRepo;
@Mock private UserAccountRepository userRepo;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); // 低 strength 加速测试
private final PasswordPolicyValidator policyValidator = new PasswordPolicyValidator();
private LocalAuthService service;
@BeforeEach
void setUp() {
service = new LocalAuthService(credentialRepo, userRepo, passwordEncoder, policyValidator);
}
@Test
void register_success() {
when(credentialRepo.existsByUsername("testuser")).thenReturn(false);
when(userRepo.save(any())).thenAnswer(inv -> {
var u = inv.getArgument(0, UserAccount.class);
return u; // 模拟保存
});
when(credentialRepo.save(any())).thenAnswer(inv -> inv.getArgument(0));
var result = service.register("testuser", "Test@1234", null);
assertThat(result).isNotNull();
verify(credentialRepo).save(argThat(c -> c.getUsername().equals("testuser")));
}
@Test
void register_duplicateUsername_throws() {
when(credentialRepo.existsByUsername("taken")).thenReturn(true);
assertThatThrownBy(() -> service.register("taken", "Test@1234", null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void register_weakPassword_throws() {
assertThatThrownBy(() -> service.register("user", "weak", null))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd server && mvn test -pl skillhub-auth -Dtest=LocalAuthServiceTest`
Expected: FAIL — LocalAuthService 不存在
- [ ] **Step 3: 实现 LocalAuthService**
```java
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
public class LocalAuthService {
private static final int MAX_FAILED_ATTEMPTS = 5;
private static final int LOCK_MINUTES = 15;
private final LocalCredentialRepository credentialRepo;
private final UserAccountRepository userRepo;
private final PasswordEncoder passwordEncoder;
private final PasswordPolicyValidator policyValidator;
public LocalAuthService(LocalCredentialRepository credentialRepo,
UserAccountRepository userRepo,
PasswordEncoder passwordEncoder,
PasswordPolicyValidator policyValidator) {
this.credentialRepo = credentialRepo;
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
this.policyValidator = policyValidator;
}
@Transactional
public UserAccount register(String username, String password, String email) {
var errors = policyValidator.validate(password);
if (!errors.isEmpty()) {
throw new IllegalArgumentException("Password policy violation: " + errors);
}
if (credentialRepo.existsByUsername(username)) {
throw new IllegalArgumentException("Username already taken");
}
var user = new UserAccount(username, email, null);
user.setStatus(UserStatus.ACTIVE);
user = userRepo.save(user);
var credential = new LocalCredential(user.getId(), username, passwordEncoder.encode(password));
credentialRepo.save(credential);
return user;
}
@Transactional
public UserAccount login(String username, String password) {
var credential = credentialRepo.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("Invalid username or password"));
var user = userRepo.findById(credential.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found"));
if (user.getStatus() == UserStatus.DISABLED) {
throw new IllegalStateException("Account is disabled");
}
if (credential.isLocked()) {
throw new IllegalStateException("Account is locked");
}
if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
credential.recordFailedAttempt(MAX_FAILED_ATTEMPTS, LOCK_MINUTES);
credentialRepo.save(credential);
throw new IllegalArgumentException("Invalid username or password");
}
credential.resetFailedAttempts();
credentialRepo.save(credential);
return user;
}
@Transactional
public void changePassword(String userId, String oldPassword, String newPassword) {
var credential = credentialRepo.findByUserId(userId)
.orElseThrow(() -> new IllegalStateException("No local credential"));
if (!passwordEncoder.matches(oldPassword, credential.getPasswordHash())) {
throw new IllegalArgumentException("Old password is incorrect");
}
var errors = policyValidator.validate(newPassword);
if (!errors.isEmpty()) {
throw new IllegalArgumentException("Password policy violation: " + errors);
}
credential.setPasswordHash(passwordEncoder.encode(newPassword));
credentialRepo.save(credential);
}
}
```
- [ ] **Step 4: 添加登录测试**
在 `LocalAuthServiceTest` 中追加:
```java
@Test
void login_success() {
var cred = new LocalCredential(1L, "testuser", passwordEncoder.encode("Test@1234"));
var user = new UserAccount("testuser", null, null);
when(credentialRepo.findByUsername("testuser")).thenReturn(java.util.Optional.of(cred));
when(userRepo.findById(1L)).thenReturn(java.util.Optional.of(user));
var result = service.login("testuser", "Test@1234");
assertThat(result).isNotNull();
verify(credentialRepo).save(argThat(c -> c.getFailedAttempts() == 0));
}
@Test
void login_wrongPassword_incrementsFailedAttempts() {
var cred = new LocalCredential(1L, "testuser", passwordEncoder.encode("Test@1234"));
var user = new UserAccount("testuser", null, null);
when(credentialRepo.findByUsername("testuser")).thenReturn(java.util.Optional.of(cred));
when(userRepo.findById(1L)).thenReturn(java.util.Optional.of(user));
assertThatThrownBy(() -> service.login("testuser", "wrong"))
.isInstanceOf(IllegalArgumentException.class);
verify(credentialRepo).save(argThat(c -> c.getFailedAttempts() == 1));
}
@Test
void login_lockedAccount_throws() {
var cred = new LocalCredential(1L, "testuser", passwordEncoder.encode("Test@1234"));
cred.setLockedUntil(LocalDateTime.now().plusMinutes(10));
var user = new UserAccount("testuser", null, null);
when(credentialRepo.findByUsername("testuser")).thenReturn(java.util.Optional.of(cred));
when(userRepo.findById(1L)).thenReturn(java.util.Optional.of(user));
assertThatThrownBy(() -> service.login("testuser", "Test@1234"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("locked");
}
```
- [ ] **Step 5: 运行全部测试**
Run: `cd server && mvn test -pl skillhub-auth -Dtest=LocalAuthServiceTest`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java \
server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java
git commit -m "feat(auth): add LocalAuthService with register/login/changePassword"
```
### Task 1.5: DTO 类 — 请求/响应对象
**Files:**
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java`
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java`
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java`
- [ ] **Step 1: 创建 LocalRegisterRequest**
```java
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record LocalRegisterRequest(
@NotBlank @Size(min = 3, max = 64)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username must contain only letters, digits, underscores")
String username,
@NotBlank @Size(min = 8, max = 128)
String password,
String email
) {}
```
- [ ] **Step 2: 创建 LocalLoginRequest**
```java
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record LocalLoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
```
- [ ] **Step 3: 创建 ChangePasswordRequest**
```java
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ChangePasswordRequest(
@NotBlank String oldPassword,
@NotBlank @Size(min = 8, max = 128) String newPassword
) {}
```
- [ ] **Step 4: 编译验证**
Run: `cd server && mvn compile -pl skillhub-app`
Expected: BUILD SUCCESS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java \
server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java \
server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java
git commit -m "feat(auth): add local auth request DTOs"
```
### Task 1.6: LocalAuthController — 本地认证端点
**Files:**
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java`
- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java`
- [ ] **Step 1: 编写 Controller 测试**
```java
package com.iflytek.skillhub.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.dto.LocalLoginRequest;
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.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(LocalAuthController.class)
class LocalAuthControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private LocalAuthService localAuthService;
@Test
void register_success_returns200() throws Exception {
var user = new UserAccount("testuser", null, null);
when(localAuthService.register(eq("testuser"), eq("Test@1234"), isNull()))
.thenReturn(user);
mockMvc.perform(post("/api/v1/auth/local/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new LocalRegisterRequest("testuser", "Test@1234", null))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void register_invalidUsername_returns400() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"\",\"password\":\"Test@1234\"}"))
.andExpect(status().isBadRequest());
}
@Test
void login_success_returns200() throws Exception {
var user = new UserAccount("testuser", null, null);
when(localAuthService.login("testuser", "Test@1234")).thenReturn(user);
mockMvc.perform(post("/api/v1/auth/local/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new LocalLoginRequest("testuser", "Test@1234"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void login_wrongPassword_returns401() throws Exception {
when(localAuthService.login("testuser", "wrong"))
.thenThrow(new IllegalArgumentException("Invalid username or password"));
mockMvc.perform(post("/api/v1/auth/local/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new LocalLoginRequest("testuser", "wrong"))))
.andExpect(status().isUnauthorized());
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd server && mvn test -pl skillhub-app -Dtest=LocalAuthControllerTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: FAIL — LocalAuthController 不存在
- [ ] **Step 3: 实现 LocalAuthController**
```java
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.dto.ChangePasswordRequest;
import com.iflytek.skillhub.dto.LocalLoginRequest;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.domain.user.UserAccount;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth/local")
public class LocalAuthController {
private final LocalAuthService localAuthService;
public LocalAuthController(LocalAuthService localAuthService) {
this.localAuthService = localAuthService;
}
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody LocalRegisterRequest req,
HttpSession session) {
try {
var user = localAuthService.register(req.username(), req.password(), req.email());
session.setAttribute("userId", user.getId());
return ResponseEntity.ok(Map.of("code", 0, "msg", "注册成功", "data", Map.of(
"userId", user.getId(), "displayName", user.getDisplayName())));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage()));
}
}
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LocalLoginRequest req,
HttpSession session) {
try {
var user = localAuthService.login(req.username(), req.password());
session.setAttribute("userId", user.getId());
return ResponseEntity.ok(Map.of("code", 0, "msg", "登录成功", "data", Map.of(
"userId", user.getId(), "displayName", user.getDisplayName())));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", 401, "msg", e.getMessage()));
} catch (IllegalStateException e) {
if (e.getMessage().contains("locked")) {
return ResponseEntity.status(423)
.body(Map.of("code", 423, "msg", e.getMessage()));
}
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("code", 403, "msg", e.getMessage()));
}
}
@PostMapping("/change-password")
public ResponseEntity<?> changePassword(@Valid @RequestBody ChangePasswordRequest req,
HttpSession session) {
var userId = (Long) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", 401, "msg", "请先登录"));
}
try {
localAuthService.changePassword(userId, req.oldPassword(), req.newPassword());
return ResponseEntity.ok(Map.of("code", 0, "msg", "密码修改成功"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage()));
}
}
}
```
- [ ] **Step 4: 修改 SecurityConfig — 添加本地认证端点 permitAll + BCrypt Bean**
在 `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java` 中:
1. 添加 import:
```java
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
```
2. 在 `authorizeHttpRequests` 的 `.permitAll()` 列表中添加:
```java
.requestMatchers(
"/api/v1/health",
"/api/v1/auth/providers",
"/api/v1/auth/me",
"/api/v1/auth/local/register", // 新增
"/api/v1/auth/local/login", // 新增
"/actuator/health",
// ... 其余不变
).permitAll()
```
3. 添加 BCrypt Bean:
```java
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
```
- [ ] **Step 5: 运行测试**
Run: `cd server && mvn test -pl skillhub-app -Dtest=LocalAuthControllerTest`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java \
server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java \
server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java
git commit -m "feat(auth): add LocalAuthController + SecurityConfig local auth endpoints"
```
### Task 1.7: AccountMergeRequest 实体 + Repository
**Files:**
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java`
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java`
- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AccountMergeRequestJpaRepository.java`
- [ ] **Step 1: 创建 AccountMergeRequest 实体**
```java
package com.iflytek.skillhub.auth.merge;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "account_merge_request")
public class AccountMergeRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "primary_user_id", nullable = false)
private Long primaryUserId;
@Column(name = "secondary_user_id", nullable = false)
private Long secondaryUserId;
@Column(nullable = false, length = 32)
private String status = "PENDING";
@Column(name = "verification_token")
private String verificationToken;
@Column(name = "token_expires_at")
private LocalDateTime tokenExpiresAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
protected AccountMergeRequest() {}
public AccountMergeRequest(Long primaryUserId, Long secondaryUserId) {
this.primaryUserId = primaryUserId;
this.secondaryUserId = secondaryUserId;
}
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
}
// Getters
public Long getId() { return id; }
public Long getPrimaryUserId() { return primaryUserId; }
public Long getSecondaryUserId() { return secondaryUserId; }
public String getStatus() { return status; }
public String getVerificationToken() { return verificationToken; }
public LocalDateTime getTokenExpiresAt() { return tokenExpiresAt; }
public LocalDateTime getCompletedAt() { return completedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
// Status transitions
public void setVerificationToken(String token, LocalDateTime expiresAt) {
this.verificationToken = token;
this.tokenExpiresAt = expiresAt;
}
public void verify() {
this.status = "VERIFIED";
}
public void complete() {
this.status = "COMPLETED";
this.completedAt = LocalDateTime.now();
}
public void cancel() {
this.status = "CANCELLED";
}
public boolean isExpired() {
return tokenExpiresAt != null && tokenExpiresAt.isBefore(LocalDateTime.now());
}
}
```
- [ ] **Step 2: 创建 Repository 接口**
```java
package com.iflytek.skillhub.auth.merge;
import java.util.List;
import java.util.Optional;
public interface AccountMergeRequestRepository {
AccountMergeRequest save(AccountMergeRequest request);
Optional<AccountMergeRequest> findById(Long id);
List<AccountMergeRequest> findByPrimaryUserId(Long primaryUserId);
Optional<AccountMergeRequest> findBySecondaryUserIdAndStatus(Long secondaryUserId, String status);
}
```
- [ ] **Step 3: 创建 JPA 实现**
```java
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.auth.merge.AccountMergeRequest;
import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface AccountMergeRequestJpaRepository
extends JpaRepository<AccountMergeRequest, Long>, AccountMergeRequestRepository {
List<AccountMergeRequest> findByPrimaryUserId(Long primaryUserId);
Optional<AccountMergeRequest> findBySecondaryUserIdAndStatus(Long secondaryUserId, String status);
}
```
- [ ] **Step 4: 编译验证**
Run: `cd server && mvn compile -pl skillhub-auth,skillhub-infra`
Expected: BUILD SUCCESS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java \
server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java \
server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AccountMergeRequestJpaRepository.java
git commit -m "feat(auth): add AccountMergeRequest entity and repository"
```
### Task 1.8: AccountMergeService — 合并服务
**Files:**
- Create: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java`
- Test: `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java`
- [ ] **Step 1: 编写测试 — 发起合并**
```java
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AccountMergeServiceTest {
@Mock private AccountMergeRequestRepository mergeRepo;
@Mock private UserAccountRepository userRepo;
@Mock private LocalCredentialRepository credentialRepo;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4);
private AccountMergeService service;
@BeforeEach
void setUp() {
service = new AccountMergeService(mergeRepo, userRepo, credentialRepo, passwordEncoder);
}
@Test
void initiate_success() {
when(userRepo.findById(2L)).thenReturn(Optional.of(new UserAccount("secondary", null, null)));
when(mergeRepo.findBySecondaryUserIdAndStatus(2L, "PENDING")).thenReturn(Optional.empty());
when(mergeRepo.save(any())).thenAnswer(inv -> inv.getArgument(0));
var result = service.initiate(1L, 2L);
assertThat(result).isNotNull();
assertThat(result.getPrimaryUserId()).isEqualTo(1L);
assertThat(result.getSecondaryUserId()).isEqualTo(2L);
assertThat(result.getStatus()).isEqualTo("PENDING");
}
@Test
void initiate_selfMerge_throws() {
assertThatThrownBy(() -> service.initiate(1L, 1L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Cannot merge");
}
@Test
void initiate_duplicatePending_throws() {
when(userRepo.findById(2L)).thenReturn(Optional.of(new UserAccount("secondary", null, null)));
when(mergeRepo.findBySecondaryUserIdAndStatus(2L, "PENDING"))
.thenReturn(Optional.of(new AccountMergeRequest(3L, 2L)));
assertThatThrownBy(() -> service.initiate(1L, 2L))
.isInstanceOf(IllegalStateException.class);
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd server && mvn test -pl skillhub-auth -Dtest=AccountMergeServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: FAIL — AccountMergeService 不存在
- [ ] **Step 3: 实现 AccountMergeService**
```java
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
public class AccountMergeService {
private static final int TOKEN_EXPIRY_MINUTES = 30;
private final AccountMergeRequestRepository mergeRepo;
private final UserAccountRepository userRepo;
private final LocalCredentialRepository credentialRepo;
private final PasswordEncoder passwordEncoder;
public AccountMergeService(AccountMergeRequestRepository mergeRepo,
UserAccountRepository userRepo,
LocalCredentialRepository credentialRepo,
PasswordEncoder passwordEncoder) {
this.mergeRepo = mergeRepo;
this.userRepo = userRepo;
this.credentialRepo = credentialRepo;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public AccountMergeRequest initiate(Long primaryUserId, Long secondaryUserId) {
if (primaryUserId.equals(secondaryUserId)) {
throw new IllegalArgumentException("Cannot merge account with itself");
}
userRepo.findById(secondaryUserId)
.orElseThrow(() -> new IllegalArgumentException("Secondary user not found"));
mergeRepo.findBySecondaryUserIdAndStatus(secondaryUserId, "PENDING")
.ifPresent(r -> { throw new IllegalStateException("Pending merge already exists"); });
var rawToken = UUID.randomUUID().toString();
var request = new AccountMergeRequest(primaryUserId, secondaryUserId);
request.setVerificationToken(
passwordEncoder.encode(rawToken),
LocalDateTime.now().plusMinutes(TOKEN_EXPIRY_MINUTES));
return mergeRepo.save(request);
}
@Transactional
public void verify(Long requestId, String token) {
var request = mergeRepo.findById(requestId)
.orElseThrow(() -> new IllegalArgumentException("Merge request not found"));
if (!"PENDING".equals(request.getStatus())) {
throw new IllegalStateException("Request is not pending");
}
if (request.isExpired()) {
request.cancel();
mergeRepo.save(request);
throw new IllegalStateException("Verification token expired");
}
if (!passwordEncoder.matches(token, request.getVerificationToken())) {
throw new IllegalArgumentException("Invalid verification token");
}
request.verify();
mergeRepo.save(request);
}
@Transactional
public void confirm(Long requestId) {
var request = mergeRepo.findById(requestId)
.orElseThrow(() -> new IllegalArgumentException("Merge request not found"));
if (!"VERIFIED".equals(request.getStatus())) {
throw new IllegalStateException("Request is not verified");
}
// 数据迁移逻辑(技能、收藏、角色等)在此扩展
var secondaryUser = userRepo.findById(request.getSecondaryUserId())
.orElseThrow(() -> new IllegalStateException("Secondary user not found"));
secondaryUser.setStatus(UserStatus.MERGED);
secondaryUser.setMergedToUserId(request.getPrimaryUserId());
userRepo.save(secondaryUser);
request.complete();
mergeRepo.save(request);
}
@Transactional
public void cancel(Long requestId, String userId) {
var request = mergeRepo.findById(requestId)
.orElseThrow(() -> new IllegalArgumentException("Merge request not found"));
if (!request.getPrimaryUserId().equals(userId)) {
throw new IllegalArgumentException("Only primary user can cancel");
}
if ("COMPLETED".equals(request.getStatus())) {
throw new IllegalStateException("Cannot cancel completed merge");
}
request.cancel();
mergeRepo.save(request);
}
}
```
- [ ] **Step 4: 运行测试**
Run: `cd server && mvn test -pl skillhub-auth -Dtest=AccountMergeServiceTest`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java \
server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java
git commit -m "feat(auth): add AccountMergeService with initiate/verify/confirm/cancel"
```
### Task 1.9: AccountMergeController + 合并 DTO
**Files:**
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java`
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java`
- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java`
- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java`
- [ ] **Step 1: 创建合并 DTO**
```java
// MergeInitiateRequest.java
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotNull;
public record MergeInitiateRequest(
@NotNull Long secondaryUserId
) {}
```
```java
// MergeVerifyRequest.java
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record MergeVerifyRequest(
@NotBlank String token
) {}
```
- [ ] **Step 2: 编写 Controller 测试**
```java
package com.iflytek.skillhub.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.merge.AccountMergeRequest;
import com.iflytek.skillhub.auth.merge.AccountMergeService;
import com.iflytek.skillhub.dto.MergeInitiateRequest;
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.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(AccountMergeController.class)
class AccountMergeControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private AccountMergeService mergeService;
@Test
void initiate_success() throws Exception {
var session = new MockHttpSession();
session.setAttribute("userId", 1L);
var mergeReq = new AccountMergeRequest(1L, 2L);
when(mergeService.initiate(1L, 2L)).thenReturn(mergeReq);
mockMvc.perform(post("/api/v1/auth/merge/initiate")
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new MergeInitiateRequest(2L))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void initiate_notLoggedIn_returns401() throws Exception {
mockMvc.perform(post("/api/v1/auth/merge/initiate")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new MergeInitiateRequest(2L))))
.andExpect(status().isUnauthorized());
}
}
```
- [ ] **Step 3: 运行测试确认失败**
Run: `cd server && mvn test -pl skillhub-app -Dtest=AccountMergeControllerTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: FAIL — AccountMergeController 不存在
- [ ] **Step 4: 实现 AccountMergeController**
```java
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.merge.AccountMergeService;
import com.iflytek.skillhub.dto.MergeInitiateRequest;
import com.iflytek.skillhub.dto.MergeVerifyRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth/merge")
public class AccountMergeController {
private final AccountMergeService mergeService;
public AccountMergeController(AccountMergeService mergeService) {
this.mergeService = mergeService;
}
@PostMapping("/initiate")
public ResponseEntity<?> initiate(@Valid @RequestBody MergeInitiateRequest req,
HttpSession session) {
var userId = (Long) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", 401, "msg", "请先登录"));
}
try {
var result = mergeService.initiate(userId, req.secondaryUserId());
return ResponseEntity.ok(Map.of("code", 0, "msg", "合并请求已创建",
"data", Map.of("requestId", result.getId())));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage()));
}
}
@PostMapping("/{requestId}/verify")
public ResponseEntity<?> verify(@PathVariable Long requestId,
@Valid @RequestBody MergeVerifyRequest req) {
try {
mergeService.verify(requestId, req.token());
return ResponseEntity.ok(Map.of("code", 0, "msg", "验证成功"));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage()));
}
}
@PostMapping("/{requestId}/confirm")
public ResponseEntity<?> confirm(@PathVariable Long requestId, HttpSession session) {
var userId = (Long) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", 401, "msg", "请先登录"));
}
try {
mergeService.confirm(requestId);
return ResponseEntity.ok(Map.of("code", 0, "msg", "合并完成"));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage()));
}
}
@PostMapping("/{requestId}/cancel")
public ResponseEntity<?> cancel(@PathVariable Long requestId, HttpSession session) {
var userId = (Long) session.getAttribute("userId");
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", 401, "msg", "请先登录"));
}
try {
mergeService.cancel(requestId, userId);
return ResponseEntity.ok(Map.of("code", 0, "msg", "合并已取消"));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", e.getMessage()));
}
}
}
```
- [ ] **Step 5: 运行测试**
Run: `cd server && mvn test -pl skillhub-app -Dtest=AccountMergeControllerTest`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java \
server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java \
server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java \
server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java
git commit -m "feat(auth): add AccountMergeController with initiate/verify/confirm/cancel"
```
---
### Task 1.10: SeedDataRunner — Docker 环境种子数据
**文件:**
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SeedDataRunner.java`
**步骤:**
- [ ] **Step 1: 实现 SeedDataRunner**
```java
package com.iflytek.skillhub.config;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
@Profile("docker")
public class SeedDataRunner implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(SeedDataRunner.class);
private final UserAccountRepository userRepo;
private final PasswordEncoder encoder;
public SeedDataRunner(UserAccountRepository userRepo, PasswordEncoder encoder) {
this.userRepo = userRepo;
this.encoder = encoder;
}
@Override
public void run(String... args) {
if (userRepo.count() > 0) {
log.info("Seed data already exists, skipping.");
return;
}
UserAccount admin = new UserAccount();
admin.setUsername("admin");
admin.setPasswordHash(encoder.encode("Admin@2026"));
admin.setDisplayName("Administrator");
admin.setStatus("ACTIVE");
userRepo.save(admin);
UserAccount demo = new UserAccount();
demo.setUsername("demo");
demo.setPasswordHash(encoder.encode("Demo@2026"));
demo.setDisplayName("Demo User");
demo.setStatus("ACTIVE");
userRepo.save(demo);
log.info("Seed data created: admin, demo");
}
}
```
- [ ] **Step 2: 添加 docker profile 配置**
在 `application.yml` 中添加:
```yaml
---
spring:
config:
activate:
on-profile: docker
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:skillhub}
username: ${DB_USER:skillhub}
password: ${DB_PASS:skillhub}
```
- [ ] **Step 3: 验证编译通过**
Run: `cd server && mvn compile -pl skillhub-app`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SeedDataRunner.java \
server/skillhub-app/src/main/resources/application.yml
git commit -m "feat(ops): add SeedDataRunner for docker profile with admin/demo users"
```
---
## Chunk 2: Skill 治理 + 审计日志 + Prometheus 指标
### Task 2.1: AuditLog Entity + Repository
**文件:**
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java`
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java`
- `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java`
- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogTest.java`
**步骤:**
- [ ] **Step 1: 写失败测试**
```java
package com.iflytek.skillhub.domain.audit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class AuditLogTest {
@Test
void shouldCreateAuditLog() {
AuditLog log = AuditLog.of("user-1", "SKILL_PUBLISH",
"skill-1", "Skill", "Published version 1.0.0");
assertThat(log.getUserId()).isEqualTo("user-1");
assertThat(log.getAction()).isEqualTo("SKILL_PUBLISH");
assertThat(log.getTargetId()).isEqualTo("skill-1");
assertThat(log.getTargetType()).isEqualTo("Skill");
assertThat(log.getDetail()).isEqualTo("Published version 1.0.0");
assertThat(log.getCreatedAt()).isNotNull();
}
@Test
void shouldCreateWithoutDetail() {
AuditLog log = AuditLog.of("user-1", "USER_LOGIN", null, null, null);
assertThat(log.getAction()).isEqualTo("USER_LOGIN");
assertThat(log.getTargetId()).isNull();
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditLogTest`
Expected: COMPILATION FAILURE
- [ ] **Step 3: 实现 AuditLog Entity**
```java
package com.iflytek.skillhub.domain.audit;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "audit_log")
public class AuditLog {
@Id
private String id;
private String userId;
private String action;
private String targetId;
private String targetType;
@Column(length = 2000)
private String detail;
private Instant createdAt;
protected AuditLog() {}
public static AuditLog of(String userId, String action,
String targetId, String targetType, String detail) {
AuditLog log = new AuditLog();
log.id = UUID.randomUUID().toString();
log.userId = userId;
log.action = action;
log.targetId = targetId;
log.targetType = targetType;
log.detail = detail;
log.createdAt = Instant.now();
return log;
}
public String getId() { return id; }
public String getUserId() { return userId; }
public String getAction() { return action; }
public String getTargetId() { return targetId; }
public String getTargetType() { return targetType; }
public String getDetail() { return detail; }
public Instant getCreatedAt() { return createdAt; }
}
```
- [ ] **Step 4: 实现 Repository**
```java
package com.iflytek.skillhub.domain.audit;
import java.time.Instant;
import java.util.List;
public interface AuditLogRepository {
AuditLog save(AuditLog log);
List<AuditLog> findByUserId(String userId);
List<AuditLog> findByTargetId(String targetId);
List<AuditLog> findByActionAndCreatedAtAfter(String action, Instant after);
}
```
JPA 实现:
```java
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.audit.AuditLog;
import com.iflytek.skillhub.domain.audit.AuditLogRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public interface AuditLogJpaRepository
extends JpaRepository<AuditLog, String>, AuditLogRepository {
List<AuditLog> findByUserId(String userId);
List<AuditLog> findByTargetId(String targetId);
List<AuditLog> findByActionAndCreatedAtAfter(String action, Instant after);
}
```
- [ ] **Step 5: 运行测试**
Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditLogTest`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/ \
server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java \
server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogTest.java
git commit -m "feat(audit): add AuditLog entity and repository"
```
---
### Task 2.2: AuditService + 关键操作埋点
**文件:**
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditService.java`
- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditServiceTest.java`
**步骤:**
- [ ] **Step 1: 写失败测试**
```java
package com.iflytek.skillhub.domain.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AuditServiceTest {
private AuditLogRepository repo;
private AuditService service;
@BeforeEach
void setUp() {
repo = mock(AuditLogRepository.class);
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
service = new AuditService(repo);
}
@Test
void shouldLogAction() {
service.log("user-1", "SKILL_PUBLISH", "skill-1", "Skill", "v1.0");
ArgumentCaptor<AuditLog> cap = ArgumentCaptor.forClass(AuditLog.class);
verify(repo).save(cap.capture());
assertThat(cap.getValue().getAction()).isEqualTo("SKILL_PUBLISH");
}
@Test
void shouldLogWithoutTarget() {
service.log("user-1", "USER_LOGIN", null, null, null);
verify(repo).save(any());
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditServiceTest`
Expected: COMPILATION FAILURE
- [ ] **Step 3: 实现 AuditService**
```java
package com.iflytek.skillhub.domain.audit;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
private final AuditLogRepository repository;
public AuditService(AuditLogRepository repository) {
this.repository = repository;
}
public void log(String userId, String action,
String targetId, String targetType, String detail) {
repository.save(AuditLog.of(userId, action, targetId, targetType, detail));
}
}
```
- [ ] **Step 4: 运行测试**
Run: `cd server && mvn test -pl skillhub-domain -Dtest=AuditServiceTest`
Expected: ALL PASS
- [ ] **Step 5: 在关键 Controller 中埋点**
在以下位置添加 `auditService.log(...)` 调用:
- `LocalAuthController.register()` → action: `USER_REGISTER`
- `LocalAuthController.login()` → action: `USER_LOGIN`
- `SkillPublishController.publish()` → action: `SKILL_PUBLISH`
- `AccountMergeController.confirm()` → action: `ACCOUNT_MERGE`
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditService.java \
server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditServiceTest.java
git commit -m "feat(audit): add AuditService and embed audit logging in key operations"
```
---
### Task 2.3: Flyway V4 — audit_log + skill_deprecation 表
**文件:**
- `server/skillhub-app/src/main/resources/db/migration/V4__phase4_audit_governance.sql`
**步骤:**
- [ ] **Step 1: 创建迁移脚本**
```sql
-- V4__phase4_audit_governance.sql
-- 审计日志表
CREATE TABLE audit_log (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36),
action VARCHAR(64) NOT NULL,
target_id VARCHAR(36),
target_type VARCHAR(64),
detail VARCHAR(2000),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_action ON audit_log(action, created_at);
CREATE INDEX idx_audit_log_target ON audit_log(target_id);
-- Skill 废弃标记字段
ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecated BOOLEAN DEFAULT FALSE;
ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecated_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecated_by VARCHAR(36);
ALTER TABLE skill ADD COLUMN IF NOT EXISTS deprecation_reason VARCHAR(500);
-- 账号合并请求表
CREATE TABLE account_merge_request (
id VARCHAR(36) PRIMARY KEY,
primary_user_id VARCHAR(36) NOT NULL,
secondary_user_id VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
verification_token VARCHAR(255),
token_expires_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- 用户表扩展: 本地认证字段
ALTER TABLE user_account ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255);
ALTER TABLE user_account ADD COLUMN IF NOT EXISTS failed_attempts INT DEFAULT 0;
ALTER TABLE user_account ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP WITH TIME ZONE;
ALTER TABLE user_account ADD COLUMN IF NOT EXISTS merged_to_user_id VARCHAR(36);
```
- [ ] **Step 2: 验证迁移**
Run: `cd server && mvn flyway:migrate -pl skillhub-app -Dflyway.configFiles=src/main/resources/application.yml`
或在应用启动时自动执行。
- [ ] **Step 3: Commit**
```bash
git add server/skillhub-app/src/main/resources/db/migration/V4__phase4_audit_governance.sql
git commit -m "feat(db): add V4 migration for audit_log, skill deprecation, account merge"
```
---
### Task 2.4: Skill 废弃 (Deprecation) 功能
**文件:**
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java`
- `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java`
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillGovernanceController.java`
**步骤:**
- [ ] **Step 1: 写失败测试**
```java
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.audit.AuditService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class SkillGovernanceServiceTest {
private SkillRepository skillRepo;
private AuditService auditService;
private SkillGovernanceService service;
@BeforeEach
void setUp() {
skillRepo = mock(SkillRepository.class);
auditService = mock(AuditService.class);
service = new SkillGovernanceService(skillRepo, auditService);
}
@Test
void shouldDeprecateSkill() {
Skill skill = mock(Skill.class);
when(skill.getId()).thenReturn("s1");
when(skillRepo.findById("s1")).thenReturn(Optional.of(skill));
service.deprecate("s1", "user-1", "Replaced by new-skill");
verify(skill).deprecate("user-1", "Replaced by new-skill");
verify(skillRepo).save(skill);
verify(auditService).log(eq("user-1"), eq("SKILL_DEPRECATE"),
eq("s1"), eq("Skill"), anyString());
}
@Test
void shouldUndeprecateSkill() {
Skill skill = mock(Skill.class);
when(skill.getId()).thenReturn("s1");
when(skillRepo.findById("s1")).thenReturn(Optional.of(skill));
service.undeprecate("s1", "user-1");
verify(skill).undeprecate();
verify(skillRepo).save(skill);
}
@Test
void shouldThrowWhenSkillNotFound() {
when(skillRepo.findById("s1")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.deprecate("s1", "u1", "reason"))
.isInstanceOf(IllegalArgumentException.class);
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cd server && mvn test -pl skillhub-domain -Dtest=SkillGovernanceServiceTest`
Expected: COMPILATION FAILURE
- [ ] **Step 3: 在 Skill Entity 中添加废弃方法**
在 `Skill.java` 中添加:
```java
private boolean deprecated;
private Instant deprecatedAt;
private String deprecatedBy;
private String deprecationReason;
public void deprecate(String userId, String reason) {
this.deprecated = true;
this.deprecatedAt = Instant.now();
this.deprecatedBy = userId;
this.deprecationReason = reason;
}
public void undeprecate() {
this.deprecated = false;
this.deprecatedAt = null;
this.deprecatedBy = null;
this.deprecationReason = null;
}
public boolean isDeprecated() { return deprecated; }
public String getDeprecationReason() { return deprecationReason; }
```
- [ ] **Step 4: 实现 SkillGovernanceService**
```java
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.audit.AuditService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SkillGovernanceService {
private final SkillRepository skillRepo;
private final AuditService auditService;
public SkillGovernanceService(SkillRepository skillRepo, AuditService auditService) {
this.skillRepo = skillRepo;
this.auditService = auditService;
}
@Transactional
public void deprecate(String skillId, String userId, String reason) {
Skill skill = skillRepo.findById(skillId)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillId));
skill.deprecate(userId, reason);
skillRepo.save(skill);
auditService.log(userId, "SKILL_DEPRECATE", skillId, "Skill", reason);
}
@Transactional
public void undeprecate(String skillId, String userId) {
Skill skill = skillRepo.findById(skillId)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillId));
skill.undeprecate();
skillRepo.save(skill);
auditService.log(userId, "SKILL_UNDEPRECATE", skillId, "Skill", null);
}
}
```
- [ ] **Step 5: 运行测试**
Run: `cd server && mvn test -pl skillhub-domain -Dtest=SkillGovernanceServiceTest`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java \
server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java \
server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java
git commit -m "feat(governance): add skill deprecation/undeprecation with audit logging"
```
---
### Task 2.5: Prometheus 指标 + Actuator 配置
**文件:**
- `server/skillhub-app/pom.xml` (添加 micrometer 依赖)
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java`
- `server/skillhub-app/src/main/resources/application.yml` (actuator 配置)
**步骤:**
- [ ] **Step 1: 添加 Maven 依赖**
在 `skillhub-app/pom.xml` 的 `<dependencies>` 中添加:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
```
- [ ] **Step 2: 配置 Actuator**
在 `application.yml` 中添加:
```yaml
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
show-details: when-authorized
metrics:
tags:
application: skillhub
```
- [ ] **Step 3: 实现自定义指标**
```java
package com.iflytek.skillhub.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
@Component
public class SkillHubMetrics {
private final Counter skillPublishCounter;
private final Counter userRegisterCounter;
private final Counter loginSuccessCounter;
private final Counter loginFailureCounter;
public SkillHubMetrics(MeterRegistry registry) {
this.skillPublishCounter = Counter.builder("skillhub.skill.publish")
.description("Number of skill publications")
.register(registry);
this.userRegisterCounter = Counter.builder("skillhub.user.register")
.description("Number of user registrations")
.register(registry);
this.loginSuccessCounter = Counter.builder("skillhub.auth.login.success")
.description("Successful logins")
.register(registry);
this.loginFailureCounter = Counter.builder("skillhub.auth.login.failure")
.description("Failed logins")
.register(registry);
}
public void incrementSkillPublish() { skillPublishCounter.increment(); }
public void incrementUserRegister() { userRegisterCounter.increment(); }
public void incrementLoginSuccess() { loginSuccessCounter.increment(); }
public void incrementLoginFailure() { loginFailureCounter.increment(); }
}
```
- [ ] **Step 4: 在 Controller 中埋点**
在 `LocalAuthController` 的 login/register 方法中调用对应 metrics 方法。
在 `SkillPublishController` 的 publish 方法中调用 `metrics.incrementSkillPublish()`。
- [ ] **Step 5: 验证 Actuator 端点**
启动应用后访问: `GET /actuator/prometheus`
Expected: 返回 Prometheus 格式的指标数据,包含 `skillhub_skill_publish_total` 等。
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-app/pom.xml \
server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java \
server/skillhub-app/src/main/resources/application.yml
git commit -m "feat(ops): add Prometheus metrics with Actuator and custom counters"
```
---
### Task 2.6: SkillGovernanceController REST API
**文件:**
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillGovernanceController.java`
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DeprecateRequest.java`
**步骤:**
- [ ] **Step 1: 实现 DTO**
```java
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record DeprecateRequest(
@NotBlank @Size(max = 500) String reason
) {}
```
- [ ] **Step 2: 实现 Controller**
```java
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.DeprecateRequest;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/skills/{skillId}/governance")
public class SkillGovernanceController extends BaseApiController {
private final SkillGovernanceService governanceService;
public SkillGovernanceController(SkillGovernanceService governanceService) {
this.governanceService = governanceService;
}
@PostMapping("/deprecate")
public ResponseEntity<Map<String, Object>> deprecate(
@PathVariable String skillId,
@Valid @RequestBody DeprecateRequest req,
HttpSession session) {
String userId = (String) session.getAttribute("userId");
governanceService.deprecate(skillId, userId, req.reason());
return ok(Map.of("deprecated", true));
}
@PostMapping("/undeprecate")
public ResponseEntity<Map<String, Object>> undeprecate(
@PathVariable String skillId,
HttpSession session) {
String userId = (String) session.getAttribute("userId");
governanceService.undeprecate(skillId, userId);
return ok(Map.of("deprecated", false));
}
}
```
- [ ] **Step 3: 验证编译**
Run: `cd server && mvn compile -pl skillhub-app`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DeprecateRequest.java \
server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillGovernanceController.java
git commit -m "feat(governance): add SkillGovernanceController with deprecate/undeprecate endpoints"
```
---
## Chunk 3: 性能优化 + 安全加固
### Task 3.1: HTTP 缓存头 + ETag 支持
**文件:**
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/CacheControlFilter.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/CacheControlFilterTest.java`
**步骤:**
- [ ] **Step 1: 写失败测试**
```java
package com.iflytek.skillhub.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class CacheControlFilterTest {
@Test
void shouldAddCacheHeadersForSkillDownload() throws Exception {
CacheControlFilter filter = new CacheControlFilter();
HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse resp = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
when(req.getRequestURI()).thenReturn("/api/v1/skills/s1/versions/1.0/download");
when(req.getMethod()).thenReturn("GET");
filter.doFilterInternal(req, resp, chain);
verify(resp).setHeader("Cache-Control", "public, max-age=86400, immutable");
verify(chain).doFilter(req, resp);
}
@Test
void shouldNotCacheApiMutations() throws Exception {
CacheControlFilter filter = new CacheControlFilter();
HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse resp = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
when(req.getRequestURI()).thenReturn("/api/v1/skills");
when(req.getMethod()).thenReturn("POST");
filter.doFilterInternal(req, resp, chain);
verify(resp, never()).setHeader(eq("Cache-Control"), anyString());
verify(chain).doFilter(req, resp);
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cd server && mvn test -pl skillhub-app -Dtest=CacheControlFilterTest`
Expected: COMPILATION FAILURE
- [ ] **Step 3: 实现 CacheControlFilter**
```java
package com.iflytek.skillhub.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class CacheControlFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp,
FilterChain chain)
throws ServletException, IOException {
String uri = req.getRequestURI();
String method = req.getMethod();
if ("GET".equals(method) && uri.contains("/download")) {
resp.setHeader("Cache-Control", "public, max-age=86400, immutable");
} else if ("GET".equals(method) && uri.startsWith("/api/")) {
resp.setHeader("Cache-Control", "no-cache");
}
chain.doFilter(req, resp);
}
}
```
- [ ] **Step 4: 运行测试**
Run: `cd server && mvn test -pl skillhub-app -Dtest=CacheControlFilterTest`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/CacheControlFilter.java \
server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/CacheControlFilterTest.java
git commit -m "feat(perf): add CacheControlFilter with immutable download caching"
```
---
### Task 3.2: CORS 配置 + Security Headers
**文件:**
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/CorsConfig.java`
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/SecurityHeadersFilter.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/SecurityHeadersFilterTest.java`
**步骤:**
- [ ] **Step 1: 写失败测试**
```java
package com.iflytek.skillhub.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class SecurityHeadersFilterTest {
@Test
void shouldAddSecurityHeaders() throws Exception {
SecurityHeadersFilter filter = new SecurityHeadersFilter();
HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse resp = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
filter.doFilterInternal(req, resp, chain);
verify(resp).setHeader("X-Content-Type-Options", "nosniff");
verify(resp).setHeader("X-Frame-Options", "DENY");
verify(resp).setHeader("X-XSS-Protection", "1; mode=block");
verify(resp).setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
verify(chain).doFilter(req, resp);
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cd server && mvn test -pl skillhub-app -Dtest=SecurityHeadersFilterTest`
Expected: COMPILATION FAILURE
- [ ] **Step 3: 实现 SecurityHeadersFilter**
```java
package com.iflytek.skillhub.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class SecurityHeadersFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp,
FilterChain chain)
throws ServletException, IOException {
resp.setHeader("X-Content-Type-Options", "nosniff");
resp.setHeader("X-Frame-Options", "DENY");
resp.setHeader("X-XSS-Protection", "1; mode=block");
resp.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
chain.doFilter(req, resp);
}
}
```
- [ ] **Step 4: 实现 CorsConfig**
```java
package com.iflytek.skillhub.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${skillhub.cors.allowed-origins:http://localhost:5173}")
private String allowedOrigins;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(allowedOrigins.split(",")));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
```
- [ ] **Step 5: 运行测试**
Run: `cd server && mvn test -pl skillhub-app -Dtest=SecurityHeadersFilterTest`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/config/CorsConfig.java \
server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/SecurityHeadersFilter.java \
server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/SecurityHeadersFilterTest.java
git commit -m "feat(security): add CORS config and security response headers"
```
---
### Task 3.3: Redis RateLimiter 增强 + 配置外部化
**文件:**
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitConfig.java`
- `server/skillhub-app/src/main/resources/application.yml` (rate limit 配置)
**步骤:**
- [ ] **Step 1: 实现 RateLimitConfig**
```java
package com.iflytek.skillhub.ratelimit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
@ConfigurationProperties(prefix = "skillhub.rate-limit")
public class RateLimitConfig {
private int defaultMaxRequests = 60;
private int defaultWindowSeconds = 60;
private int publishMaxRequests = 10;
private int publishWindowSeconds = 3600;
@Bean
public RateLimiter rateLimiter(StringRedisTemplate redisTemplate) {
try {
redisTemplate.getConnectionFactory().getConnection().ping();
return new RedisSlidingWindowRateLimiter(redisTemplate);
} catch (Exception e) {
return new InMemorySlidingWindowRateLimiter();
}
}
public int getDefaultMaxRequests() { return defaultMaxRequests; }
public void setDefaultMaxRequests(int v) { this.defaultMaxRequests = v; }
public int getDefaultWindowSeconds() { return defaultWindowSeconds; }
public void setDefaultWindowSeconds(int v) { this.defaultWindowSeconds = v; }
public int getPublishMaxRequests() { return publishMaxRequests; }
public void setPublishMaxRequests(int v) { this.publishMaxRequests = v; }
public int getPublishWindowSeconds() { return publishWindowSeconds; }
public void setPublishWindowSeconds(int v) { this.publishWindowSeconds = v; }
}
```
- [ ] **Step 2: 添加配置项**
在 `application.yml` 中添加:
```yaml
skillhub:
rate-limit:
default-max-requests: 60
default-window-seconds: 60
publish-max-requests: 10
publish-window-seconds: 3600
cors:
allowed-origins: http://localhost:5173
```
- [ ] **Step 3: 验证编译**
Run: `cd server && mvn compile -pl skillhub-app`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitConfig.java \
server/skillhub-app/src/main/resources/application.yml
git commit -m "feat(ops): externalize rate limit config with Redis/InMemory fallback"
```
---
### Task 3.4: 请求参数校验 + 全局异常处理增强
**文件:**
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java` (增强)
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java`
**步骤:**
- [ ] **Step 1: 写失败测试**
```java
package com.iflytek.skillhub.exception;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void shouldHandleValidationErrors() {
MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class);
BindingResult br = mock(BindingResult.class);
when(ex.getBindingResult()).thenReturn(br);
when(br.getFieldErrors()).thenReturn(List.of(
new FieldError("req", "username", "must not be blank")
));
ResponseEntity<Map<String, Object>> resp = handler.handleValidation(ex);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
Map<String, Object> body = resp.getBody();
assertThat(body.get("code")).isEqualTo(400);
assertThat(body.get("msg")).asString().contains("username");
}
@Test
void shouldHandleDomainBadRequest() {
var ex = new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException("bad");
ResponseEntity<Map<String, Object>> resp = handler.handleDomainBadRequest(ex);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
}
@Test
void shouldHandleDomainForbidden() {
var ex = new com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException("forbidden");
ResponseEntity<Map<String, Object>> resp = handler.handleDomainForbidden(ex);
assertThat(resp.getStatusCode().value()).isEqualTo(403);
}
}
```
- [ ] **Step 2: 验证测试失败**
Run: `cd server && mvn test -pl skillhub-app -Dtest=GlobalExceptionHandlerTest`
Expected: COMPILATION FAILURE (新方法不存在)
- [ ] **Step 3: 增强 GlobalExceptionHandler**
在现有 `GlobalExceptionHandler.java` 中添加:
```java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
String errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest().body(Map.of(
"code", 400, "msg", errors, "timestamp", Instant.now().toString()));
}
@ExceptionHandler(DomainBadRequestException.class)
public ResponseEntity<Map<String, Object>> handleDomainBadRequest(DomainBadRequestException ex) {
return ResponseEntity.badRequest().body(Map.of(
"code", 400, "msg", ex.getMessage(), "timestamp", Instant.now().toString()));
}
@ExceptionHandler(DomainForbiddenException.class)
public ResponseEntity<Map<String, Object>> handleDomainForbidden(DomainForbiddenException ex) {
return ResponseEntity.status(403).body(Map.of(
"code", 403, "msg", ex.getMessage(), "timestamp", Instant.now().toString()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(500).body(Map.of(
"code", 500, "msg", "Internal server error", "timestamp", Instant.now().toString()));
}
```
- [ ] **Step 4: 运行测试**
Run: `cd server && mvn test -pl skillhub-app -Dtest=GlobalExceptionHandlerTest`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
git add server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java \
server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java
git commit -m "feat(security): enhance GlobalExceptionHandler with validation and domain errors"
```
---
### Task 3.5: 数据库查询优化 — 索引 + 分页
**文件:**
- `server/skillhub-app/src/main/resources/db/migration/V5__performance_indexes.sql`
- `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java` (增加分页方法)
**步骤:**
- [ ] **Step 1: 创建索引迁移脚本**
```sql
-- V5__performance_indexes.sql
-- Skill 查询优化
CREATE INDEX IF NOT EXISTS idx_skill_namespace_id ON skill(namespace_id);
CREATE INDEX IF NOT EXISTS idx_skill_name ON skill(name);
CREATE INDEX IF NOT EXISTS idx_skill_deprecated ON skill(deprecated) WHERE deprecated = true;
-- SkillVersion 查询优化
CREATE INDEX IF NOT EXISTS idx_skill_version_skill_id ON skill_version(skill_id);
CREATE INDEX IF NOT EXISTS idx_skill_version_created ON skill_version(created_at DESC);
-- NamespaceMember 查询优化
CREATE INDEX IF NOT EXISTS idx_ns_member_user ON namespace_member(user_id);
CREATE INDEX IF NOT EXISTS idx_ns_member_ns ON namespace_member(namespace_id);
-- ReviewTask 查询优化
CREATE INDEX IF NOT EXISTS idx_review_task_status ON review_task(status);
CREATE INDEX IF NOT EXISTS idx_review_task_reviewer ON review_task(reviewer_id);
-- ApiToken 查询优化
CREATE INDEX IF NOT EXISTS idx_api_token_user ON api_token(user_id);
CREATE INDEX IF NOT EXISTS idx_api_token_hash ON api_token(token_hash);
-- 全文搜索优化
CREATE INDEX IF NOT EXISTS idx_search_doc_tsv ON skill_search_document USING gin(search_vector);
```
- [ ] **Step 2: 在 SkillRepository 中添加分页支持**
```java
// 在 SkillRepository 接口中添加:
Page<Skill> findByNamespaceId(String namespaceId, Pageable pageable);
Page<Skill> findByDeprecatedFalse(Pageable pageable);
```
- [ ] **Step 3: 验证迁移**
Run: `cd server && mvn compile -pl skillhub-app`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add server/skillhub-app/src/main/resources/db/migration/V5__performance_indexes.sql \
server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java
git commit -m "feat(perf): add database indexes and pagination support"
```
---
## Chunk 4: Docker 一键启动 + 开源基础设施
### Task 4.1: Dockerfile — 多阶段构建
**文件:**
- `server/Dockerfile`
**步骤:**
- [ ] **Step 1: 创建 Dockerfile**
```dockerfile
# ---- Build Stage ----
FROM maven:3.9-eclipse-temurin-21-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY skillhub-domain/pom.xml skillhub-domain/
COPY skillhub-auth/pom.xml skillhub-auth/
COPY skillhub-search/pom.xml skillhub-search/
COPY skillhub-infra/pom.xml skillhub-infra/
COPY skillhub-app/pom.xml skillhub-app/
RUN mvn dependency:go-offline -B
COPY . .
RUN mvn package -DskipTests -B
# ---- Runtime Stage ----
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/skillhub-app/target/skillhub-app-*.jar app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
```
- [ ] **Step 2: 创建 .dockerignore**
```
**/target/
**/.idea/
**/*.iml
.git/
```
- [ ] **Step 3: 验证构建**
Run: `cd server && docker build -t skillhub-server:dev .`
Expected: 构建成功
- [ ] **Step 4: Commit**
```bash
git add server/Dockerfile server/.dockerignore
git commit -m "feat(ops): add multi-stage Dockerfile for server"
```
---
### Task 4.2: Web 前端 Dockerfile
**文件:**
- `web/Dockerfile`
- `web/nginx.conf`
**步骤:**
- [ ] **Step 1: 创建 nginx.conf**
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://server:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /actuator/ {
deny all;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
}
```
- [ ] **Step 2: 创建 Dockerfile**
```dockerfile
# ---- Build Stage ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime Stage ----
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost/ || exit 1
```
- [ ] **Step 3: 验证构建**
Run: `cd web && docker build -t skillhub-web:dev .`
Expected: 构建成功
- [ ] **Step 4: Commit**
```bash
git add web/Dockerfile web/nginx.conf
git commit -m "feat(ops): add multi-stage Dockerfile for web frontend with nginx"
```
---
### Task 4.3: docker-compose.yml — 一键启动
**文件:**
- `docker-compose.yml`
**步骤:**
- [ ] **Step 1: 创建 docker-compose.yml**
```yaml
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: skillhub
POSTGRES_USER: skillhub
POSTGRES_PASSWORD: skillhub
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U skillhub"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
server:
build: ./server
environment:
SPRING_PROFILES_ACTIVE: docker
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: skillhub
DB_USER: skillhub
DB_PASS: skillhub
SPRING_DATA_REDIS_HOST: redis
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
web:
build: ./web
ports:
- "80:80"
depends_on:
- server
volumes:
pgdata:
```
- [ ] **Step 2: 验证启动**
Run: `docker compose up -d --build`
Expected: 所有服务启动成功,`docker compose ps` 显示 4 个 running 容器
- [ ] **Step 3: 验证健康检查**
Run: `curl http://localhost:8080/actuator/health`
Expected: `{"status":"UP"}`
Run: `curl http://localhost/`
Expected: 返回前端 HTML 页面
- [ ] **Step 4: Commit**
```bash
git add docker-compose.yml
git commit -m "feat(ops): add docker-compose for one-click local deployment"
```
---
### Task 4.4: Makefile — 常用命令封装
**文件:**
- `Makefile`
**步骤:**
- [ ] **Step 1: 创建 Makefile**
```makefile
.PHONY: help dev up down test build clean
help: ## 显示帮助
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
dev: ## 启动开发环境 (仅 postgres + redis)
docker compose up -d postgres redis
up: ## 一键启动全部服务
docker compose up -d --build
down: ## 停止全部服务
docker compose down
test: ## 运行全部后端测试
cd server && mvn test
build: ## 构建后端
cd server && mvn package -DskipTests
clean: ## 清理构建产物
cd server && mvn clean
docker compose down -v
logs: ## 查看服务日志
docker compose logs -f server
db-reset: ## 重置数据库
docker compose down -v
docker compose up -d postgres
@echo "Waiting for postgres..."
@sleep 3
cd server && mvn flyway:migrate -pl skillhub-app
```
- [ ] **Step 2: 验证**
Run: `make help`
Expected: 显示所有可用命令
- [ ] **Step 3: Commit**
```bash
git add Makefile
git commit -m "feat(ops): add Makefile with dev/up/down/test/build commands"
```
---
### Task 4.5: README.md — 项目文档
**文件:**
- `README.md`
**步骤:**
- [ ] **Step 1: 创建 README.md**
```markdown
# SkillHub
AI Skill 共享平台 — 发布、发现、管理 AI 技能包。
## 快速开始
### 前置条件
- Docker & Docker Compose
- JDK 21+ (开发模式)
- Node.js 20+ (前端开发)
### 一键启动
```bash
make up
```
访问:
- 前端: http://localhost
- API: http://localhost:8080
- Prometheus 指标: http://localhost:8080/actuator/prometheus
### 开发模式
```bash
# 启动基础设施
make dev
# 启动后端
cd server && mvn spring-boot:run
# 启动前端
cd web && npm run dev
```
### 默认账号
| 用户名 | 密码 | 角色 |
|--------|------|------|
| admin | Admin@2026 | 管理员 |
| demo | Demo@2026 | 普通用户 |
## 项目结构
```
skillhub/
├── server/ # Spring Boot 后端
│ ├── skillhub-app/ # 应用层 (Controller, DTO, Config)
│ ├── skillhub-domain/ # 领域层 (Entity, Service, Repository)
│ ├── skillhub-auth/ # 认证授权模块
│ ├── skillhub-search/ # 搜索模块
│ └── skillhub-infra/ # 基础设施层 (JPA 实现)
├── web/ # React 前端
├── docker-compose.yml # 一键部署
└── Makefile # 常用命令
```
## 常用命令
```bash
make help # 查看所有命令
make test # 运行测试
make build # 构建项目
make logs # 查看日志
make db-reset # 重置数据库
```
## 技术栈
- **后端:** Spring Boot 3, JDK 21, PostgreSQL, Redis, Flyway
- **前端:** React, TypeScript, Vite
- **运维:** Docker, Prometheus, Actuator
## License
MIT
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add comprehensive README with quickstart guide"
```
---
### Task 4.6: Prometheus + Grafana 监控栈 (可选)
**文件:**
- `monitoring/prometheus.yml`
- `monitoring/docker-compose.monitoring.yml`
**步骤:**
- [ ] **Step 1: 创建 Prometheus 配置**
```yaml
# monitoring/prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'skillhub-server'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['server:8080']
```
- [ ] **Step 2: 创建监控 compose 文件**
```yaml
# monitoring/docker-compose.monitoring.yml
version: "3.9"
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
extra_hosts:
- "host.docker.internal:host-gateway"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
depends_on:
- prometheus
```
- [ ] **Step 3: 验证**
Run: `cd monitoring && docker compose -f docker-compose.monitoring.yml up -d`
Expected: Prometheus 和 Grafana 启动成功
访问:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000 (admin/admin)
- [ ] **Step 4: Commit**
```bash
git add monitoring/
git commit -m "feat(ops): add Prometheus + Grafana monitoring stack"
```
---
### Task 4.7: 端到端冒烟测试脚本
**文件:**
- `scripts/smoke-test.sh`
**步骤:**
- [ ] **Step 1: 创建冒烟测试脚本**
```bash
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
PASS=0
FAIL=0
check() {
local desc="$1" url="$2" expected="$3"
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [ "$status" = "$expected" ]; then
echo " PASS: $desc (HTTP $status)"
((PASS++))
else
echo " FAIL: $desc (expected $expected, got $status)"
((FAIL++))
fi
}
echo "=== SkillHub Smoke Test ==="
echo "Target: $BASE_URL"
echo ""
# Health
check "Health endpoint" "$BASE_URL/actuator/health" "200"
# Prometheus metrics
check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200"
# Public API
check "Skill search" "$BASE_URL/api/v1/skills/search?q=test" "200"
# Auth required
check "Auth required (401)" "$BASE_URL/api/v1/me" "401"
# Register
REG_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/register" \
-H "Content-Type: application/json" \
-d '{"username":"smoketest","password":"Smoke@2026"}')
if [ "$REG_STATUS" = "200" ] || [ "$REG_STATUS" = "409" ]; then
echo " PASS: Register (HTTP $REG_STATUS)"
((PASS++))
else
echo " FAIL: Register (got $REG_STATUS)"
((FAIL++))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
```
- [ ] **Step 2: 设置可执行权限**
Run: `chmod +x scripts/smoke-test.sh`
- [ ] **Step 3: Commit**
```bash
git add scripts/smoke-test.sh
git commit -m "feat(ops): add end-to-end smoke test script"
```
---
## 完成标志
所有 Chunk 完成后,执行最终验证:
- [ ] `make up` — 一键启动全部服务
- [ ] `make test` — 全部后端测试通过
- [ ] `./scripts/smoke-test.sh` — 冒烟测试通过
- [ ] `curl localhost:8080/actuator/prometheus` — 指标正常
- [ ] 前端页面可正常访问和操作
```bash
git tag v0.4.0
git push origin feature/project-init --tags
```