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

106 KiB
Raw Blame History

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: 编写迁移脚本

-- 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
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: 编写失败测试

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
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
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 实体

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 接口
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 实现
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
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: 编写注册测试

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
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 中追加:

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

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
package com.iflytek.skillhub.dto;

import jakarta.validation.constraints.NotBlank;

public record LocalLoginRequest(
    @NotBlank String username,
    @NotBlank String password
) {}
  • Step 3: 创建 ChangePasswordRequest
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
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 测试

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
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:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
  1. 在 authorizeHttpRequests 的 .permitAll() 列表中添加:
.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()
  1. 添加 BCrypt Bean:
@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
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 实体

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 接口
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 实现
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
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: 编写测试 — 发起合并

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

// MergeInitiateRequest.java
package com.iflytek.skillhub.dto;

import jakarta.validation.constraints.NotNull;

public record MergeInitiateRequest(
    @NotNull Long secondaryUserId
) {}
// MergeVerifyRequest.java
package com.iflytek.skillhub.dto;

import jakarta.validation.constraints.NotBlank;

public record MergeVerifyRequest(
    @NotBlank String token
) {}
  • Step 2: 编写 Controller 测试
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
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
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
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 中添加:

---
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
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: 写失败测试
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
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
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 实现:

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
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: 写失败测试
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
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

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: 创建迁移脚本
-- 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
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: 写失败测试
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 中添加:

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
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
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> 中添加:

<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 中添加:

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  endpoint:
    health:
      show-details: when-authorized
  metrics:
    tags:
      application: skillhub
  • Step 3: 实现自定义指标
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
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
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
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
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: 写失败测试
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
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
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: 写失败测试
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
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
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
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
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 中添加:

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
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: 写失败测试
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 中添加:

@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
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: 创建索引迁移脚本
-- 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 中添加分页支持
// 在 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
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
# ---- 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
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
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
# ---- 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
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
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
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
.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
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
# SkillHub

AI Skill 共享平台 — 发布、发现、管理 AI 技能包。

## 快速开始

### 前置条件
- Docker & Docker Compose
- JDK 21+ (开发模式)
- Node.js 20+ (前端开发)

### 一键启动

```bash
make up

访问:

开发模式

# 启动基础设施
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                # 常用命令

常用命令

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 配置
# 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 文件
# 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 启动成功

访问:

git add monitoring/
git commit -m "feat(ops): add Prometheus + Grafana monitoring stack"

Task 4.7: 端到端冒烟测试脚本

文件:

  • scripts/smoke-test.sh

步骤:

  • Step 1: 创建冒烟测试脚本
#!/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
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 — 指标正常
  • 前端页面可正常访问和操作
git tag v0.4.0
git push origin feature/project-init --tags