skillhub/docs/superpowers/plans/2026-03-11-phase1-foundation-auth.md
2026-03-15 20:34:36 +08:00

137 KiB
Raw Blame History

Phase 1: 工程骨架 + 认证打通 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: 建立可运行的前后端工程骨架,完成 GitHub OAuth 登录和 API Token 认证,满足 Phase 1 验收标准

Architecture: Maven 多模块后端(6 模块)+ React 前端 + Docker Compose 本地开发环境 + Spring Security OAuth2 + RBAC

Tech Stack:

  • Backend: Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Security OAuth2 Client + Spring Data JPA + Flyway
  • Frontend: React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui + Tailwind CSS
  • DevOps: Docker Compose + Maven Wrapper + Makefile

Chunk 1: 后端工程骨架 + 基础设施

本块建立 Maven 多模块项目结构、数据库迁移、基础配置、健康检查和 OpenAPI 文档,产出可启动的后端应用。

文件结构映射

skillhub/
├── server/
│   ├── pom.xml                           # 父 POM
│   ├── .mvn/wrapper/                     # Maven Wrapper
│   ├── mvnw, mvnw.cmd
│   ├── skillhub-app/
│   │   ├── pom.xml
│   │   └── src/main/
│   │       ├── java/com/skillhub/
│   │       │   ├── SkillhubApplication.java
│   │       │   ├── config/
│   │       │   │   ├── OpenApiConfig.java
│   │       │   │   └── WebMvcConfig.java
│   │       │   ├── controller/
│   │       │   │   └── HealthController.java
│   │       │   └── filter/
│   │       │       └── RequestIdFilter.java
│   │       └── resources/
│   │           ├── application.yml
│   │           ├── application-local.yml
│   │           └── db/migration/
│   │               └── V1__init_schema.sql
│   ├── skillhub-domain/
│   │   ├── pom.xml
│   │   └── src/main/java/com/skillhub/domain/
│   ├── skillhub-auth/
│   │   ├── pom.xml
│   │   └── src/main/java/com/skillhub/auth/
│   ├── skillhub-search/
│   │   ├── pom.xml
│   │   └── src/main/java/com/skillhub/search/
│   ├── skillhub-storage/
│   │   ├── pom.xml
│   │   └── src/main/java/com/skillhub/storage/
│   └── skillhub-infra/
│       ├── pom.xml
│       └── src/main/java/com/skillhub/infra/
├── docker-compose.yml
├── .gitignore
└── Makefile

Task 1: 初始化 Monorepo 和 Maven 多模块项目

Files:

  • Create: server/pom.xml

  • Create: server/skillhub-app/pom.xml

  • Create: server/skillhub-domain/pom.xml

  • Create: server/skillhub-auth/pom.xml

  • Create: server/skillhub-search/pom.xml

  • Create: server/skillhub-storage/pom.xml

  • Create: server/skillhub-infra/pom.xml

  • Create: .gitignore

  • Step 1: 创建根 .gitignore

cat > .gitignore << 'EOF'
# Maven
target/
!.mvn/wrapper/maven-wrapper.jar
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties

# IDE
.idea/
*.iml
.vscode/
.DS_Store

# Logs
*.log

# Environment
.env
.env.local

# Node
node_modules/
dist/
.pnpm-store/
EOF
  • Step 2: 创建父 POM (server/pom.xml)
mkdir -p server && cat > server/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.3</version>
        <relativePath/>
    </parent>

    <groupId>com.skillhub</groupId>
    <artifactId>skillhub-parent</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <properties>
        <java.version>21</java.version>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <modules>
        <module>skillhub-app</module>
        <module>skillhub-domain</module>
        <module>skillhub-auth</module>
        <module>skillhub-search</module>
        <module>skillhub-storage</module>
        <module>skillhub-infra</module>
    </modules>

    <dependencyManagement>
        <dependencies>
            <!-- Internal modules -->
            <dependency>
                <groupId>com.skillhub</groupId>
                <artifactId>skillhub-domain</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.skillhub</groupId>
                <artifactId>skillhub-auth</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.skillhub</groupId>
                <artifactId>skillhub-search</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.skillhub</groupId>
                <artifactId>skillhub-storage</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>com.skillhub</groupId>
                <artifactId>skillhub-infra</artifactId>
                <version>${project.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>
EOF
  • Step 3: 创建 skillhub-app 模块 POM
mkdir -p server/skillhub-app && cat > server/skillhub-app/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.skillhub</groupId>
        <artifactId>skillhub-parent</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </parent>

    <artifactId>skillhub-app</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.skillhub</groupId>
            <artifactId>skillhub-domain</artifactId>
        </dependency>
        <dependency>
            <groupId>com.skillhub</groupId>
            <artifactId>skillhub-auth</artifactId>
        </dependency>
        <dependency>
            <groupId>com.skillhub</groupId>
            <artifactId>skillhub-infra</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
EOF
  • Step 4: 创建其他模块的 POM(domain, auth, search, storage, infra)
# skillhub-domain
mkdir -p server/skillhub-domain/src/main/java/com/skillhub/domain
cat > server/skillhub-domain/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.skillhub</groupId>
        <artifactId>skillhub-parent</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </parent>
    <artifactId>skillhub-domain</artifactId>
</project>
EOF

# skillhub-auth
mkdir -p server/skillhub-auth/src/main/java/com/skillhub/auth
cat > server/skillhub-auth/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.skillhub</groupId>
        <artifactId>skillhub-parent</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </parent>
    <artifactId>skillhub-auth</artifactId>
    <dependencies>
        <dependency>
            <groupId>com.skillhub</groupId>
            <artifactId>skillhub-domain</artifactId>
        </dependency>
    </dependencies>
</project>
EOF

# skillhub-search
mkdir -p server/skillhub-search/src/main/java/com/skillhub/search
cat > server/skillhub-search/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.skillhub</groupId>
        <artifactId>skillhub-parent</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </parent>
    <artifactId>skillhub-search</artifactId>
    <dependencies>
        <dependency>
            <groupId>com.skillhub</groupId>
            <artifactId>skillhub-domain</artifactId>
        </dependency>
    </dependencies>
</project>
EOF

# skillhub-storage
mkdir -p server/skillhub-storage/src/main/java/com/skillhub/storage
cat > server/skillhub-storage/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.skillhub</groupId>
        <artifactId>skillhub-parent</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </parent>
    <artifactId>skillhub-storage</artifactId>
</project>
EOF

# skillhub-infra
mkdir -p server/skillhub-infra/src/main/java/com/skillhub/infra
cat > server/skillhub-infra/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.skillhub</groupId>
        <artifactId>skillhub-parent</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </parent>
    <artifactId>skillhub-infra</artifactId>
    <dependencies>
        <dependency>
            <groupId>com.skillhub</groupId>
            <artifactId>skillhub-domain</artifactId>
        </dependency>
    </dependencies>
</project>
EOF
  • Step 5: 安装 Maven Wrapper

Run: cd server && mvn wrapper:wrapper

Expected: Maven Wrapper 文件生成在 server/.mvn/wrapper/

  • Step 6: 验证项目结构

Run: cd server && ./mvnw clean compile

Expected: BUILD SUCCESS,所有模块编译通过

  • Step 7: Commit
git add .gitignore server/
git commit -m "feat: initialize Maven multi-module project structure

- Add parent POM with 6 modules (app, domain, auth, search, storage, infra)
- Configure Spring Boot 3.2.3 + JDK 21
- Add Maven Wrapper for reproducible builds
- Set up module dependency graph (app depends on all, infra/auth/search depend on domain)"

Task 2: 创建 Spring Boot 应用入口和基础配置

Files:

  • Create: server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java

  • Create: server/skillhub-app/src/main/resources/application.yml

  • Create: server/skillhub-app/src/main/resources/application-local.yml

  • Create: server/skillhub-app/src/test/java/com/skillhub/ApplicationContextStartsTest.java

  • Step 1: 编写失败的 ApplicationContext 启动测试

mkdir -p server/skillhub-app/src/test/java/com/skillhub
cat > server/skillhub-app/src/test/java/com/skillhub/ApplicationContextStartsTest.java << 'EOF'
package com.skillhub;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ApplicationContextStartsTest {

    @Test
    void contextLoads() {
        // ApplicationContext should start successfully
    }
}
EOF
  • Step 2: 运行测试确认失败

Run: cd server && ./mvnw test -Dtest=ApplicationContextStartsTest

Expected: FAIL - "Unable to find a @SpringBootConfiguration"

  • Step 3: 创建 SkillhubApplication 主类
mkdir -p server/skillhub-app/src/main/java/com/skillhub
cat > server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java << 'EOF'
package com.skillhub;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SkillhubApplication {

    public static void main(String[] args) {
        SpringApplication.run(SkillhubApplication.java, args);
    }
}
EOF
  • Step 4: 创建基础配置文件
mkdir -p server/skillhub-app/src/main/resources
cat > server/skillhub-app/src/main/resources/application.yml << 'EOF'
spring:
  application:
    name: skillhub
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
  flyway:
    enabled: true
    locations: classpath:db/migration

server:
  shutdown: graceful

spring.lifecycle.timeout-per-shutdown-phase: 30s

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: when-authorized
EOF

cat > server/skillhub-app/src/main/resources/application-local.yml << 'EOF'
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/skillhub
    username: skillhub
    password: skillhub_dev
  data:
    redis:
      host: localhost
      port: 6379
  jpa:
    show-sql: true

logging:
  level:
    com.skillhub: DEBUG
EOF
  • Step 5: 运行测试确认通过

Run: cd server && ./mvnw test -Dtest=ApplicationContextStartsTest

Expected: FAIL - "Failed to configure a DataSource" (预期,因为还没有数据库)

  • Step 6: Commit
git add server/skillhub-app/
git commit -m "feat: add Spring Boot application entry point and base configuration

- Create SkillhubApplication main class
- Add application.yml with JPA, Flyway, graceful shutdown config
- Add application-local.yml for local development profile
- Add ApplicationContextStartsTest (will pass after DB setup)"

Task 3: 添加 Docker Compose 本地开发环境

Files:

  • Create: docker-compose.yml

  • Step 1: 创建 docker-compose.yml

cat > docker-compose.yml << 'EOF'
services:
  postgres:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: skillhub
      POSTGRES_USER: skillhub
      POSTGRES_PASSWORD: skillhub_dev
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U skillhub"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  minio_data:
EOF
  • Step 2: 启动依赖服务

Run: docker compose up -d

Expected: PostgreSQL, Redis, MinIO 启动成功,健康检查通过

  • Step 3: 验证服务可访问

Run: docker compose ps

Expected: 所有服务状态为 healthy

  • Step 4: Commit
git add docker-compose.yml
git commit -m "feat: add Docker Compose for local development dependencies

- Add PostgreSQL 16, Redis 7, MinIO services
- Configure health checks for all services
- Use named volumes for data persistence"

Task 4: 添加 Flyway 数据库迁移和 Phase 1 核心表

Files:

  • Create: server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql

  • Update: server/skillhub-app/pom.xml (添加 Flyway 和 PostgreSQL 驱动依赖)

  • Step 1: 更新 skillhub-app POM 添加数据库依赖

# 在 skillhub-app/pom.xml 的 <dependencies> 中添加:
cat >> server/skillhub-app/pom.xml.tmp << 'EOF'
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
EOF
# 手动编辑 server/skillhub-app/pom.xml,在 </dependencies> 前插入上述依赖
  • Step 2: 创建 Flyway 迁移脚本 V1__init_schema.sql
mkdir -p server/skillhub-app/src/main/resources/db/migration
cat > server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql << 'EOF'
-- Phase 1 核心表:认证与授权

-- 用户账号表
CREATE TABLE user_account (
    id BIGSERIAL PRIMARY KEY,
    display_name VARCHAR(128) NOT NULL,
    email VARCHAR(256),
    avatar_url VARCHAR(512),
    status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    merged_to_user_id VARCHAR(128),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_user_account_email ON user_account(email);
CREATE INDEX idx_user_account_status ON user_account(status);

-- OAuth 身份绑定表
CREATE TABLE identity_binding (
    id BIGSERIAL PRIMARY KEY,
    user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
    provider_code VARCHAR(64) NOT NULL,
    subject VARCHAR(256) NOT NULL,
    login_name VARCHAR(128),
    extra_json JSONB,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(provider_code, subject)
);

CREATE INDEX idx_identity_binding_user_id ON identity_binding(user_id);

-- API Token 表
CREATE TABLE api_token (
    id BIGSERIAL PRIMARY KEY,
    subject_type VARCHAR(32) NOT NULL DEFAULT 'USER',
    subject_id VARCHAR(128) NOT NULL,
    user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
    name VARCHAR(128) NOT NULL,
    token_prefix VARCHAR(16) NOT NULL,
    token_hash VARCHAR(64) NOT NULL UNIQUE,
    scope_json JSONB NOT NULL,
    expires_at TIMESTAMP,
    last_used_at TIMESTAMP,
    revoked_at TIMESTAMP,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_api_token_user_id ON api_token(user_id);
CREATE INDEX idx_api_token_hash ON api_token(token_hash);

-- 角色表
CREATE TABLE role (
    id BIGSERIAL PRIMARY KEY,
    code VARCHAR(64) NOT NULL UNIQUE,
    name VARCHAR(128) NOT NULL,
    description VARCHAR(512),
    is_system BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 权限表
CREATE TABLE permission (
    id BIGSERIAL PRIMARY KEY,
    code VARCHAR(128) NOT NULL UNIQUE,
    name VARCHAR(128) NOT NULL,
    group_code VARCHAR(64)
);

-- 角色权限关联表
CREATE TABLE role_permission (
    role_id BIGINT NOT NULL REFERENCES role(id),
    permission_id BIGINT NOT NULL REFERENCES permission(id),
    PRIMARY KEY (role_id, permission_id)
);

-- 用户角色绑定表
CREATE TABLE user_role_binding (
    id BIGSERIAL PRIMARY KEY,
    user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
    role_id BIGINT NOT NULL REFERENCES role(id),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(user_id, role_id)
);

CREATE INDEX idx_user_role_binding_user_id ON user_role_binding(user_id);

-- 命名空间表
CREATE TABLE namespace (
    id BIGSERIAL PRIMARY KEY,
    slug VARCHAR(64) NOT NULL UNIQUE,
    display_name VARCHAR(128) NOT NULL,
    type VARCHAR(32) NOT NULL,
    description TEXT,
    avatar_url VARCHAR(512),
    status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    created_by VARCHAR(128) REFERENCES user_account(id),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 命名空间成员表
CREATE TABLE namespace_member (
    id BIGSERIAL PRIMARY KEY,
    namespace_id BIGINT NOT NULL REFERENCES namespace(id),
    user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
    role VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(namespace_id, user_id)
);

CREATE INDEX idx_namespace_member_user_id ON namespace_member(user_id);
CREATE INDEX idx_namespace_member_namespace_id ON namespace_member(namespace_id);

-- 审计日志表
CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    actor_user_id VARCHAR(128) REFERENCES user_account(id),
    action VARCHAR(64) NOT NULL,
    target_type VARCHAR(64),
    target_id BIGINT,
    request_id VARCHAR(64),
    client_ip VARCHAR(64),
    user_agent VARCHAR(512),
    detail_json JSONB,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_audit_log_actor ON audit_log(actor_user_id);
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX idx_audit_log_request_id ON audit_log(request_id);

-- 插入系统内置角色
INSERT INTO role (code, name, description, is_system) VALUES
('SUPER_ADMIN', '超级管理员', '拥有所有权限', TRUE),
('SKILL_ADMIN', '技能管理员', '全局空间审核、提升审核、隐藏/撤回', TRUE),
('USER_ADMIN', '用户管理员', '准入审批、封禁/解封、角色分配', TRUE),
('AUDITOR', '审计员', '查看审计日志', TRUE);

-- 插入系统权限
INSERT INTO permission (code, name, group_code) VALUES
('skill:publish', '发布技能', 'skill'),
('skill:manage', '管理技能', 'skill'),
('skill:promote', '提升到全局', 'skill'),
('review:approve', '审核技能', 'review'),
('promotion:approve', '审核提升申请', 'promotion'),
('user:manage', '管理用户', 'user'),
('user:approve', '审批用户准入', 'user'),
('audit:read', '查看审计日志', 'audit');

-- 绑定角色权限
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'SKILL_ADMIN' AND p.code IN ('review:approve', 'skill:manage', 'promotion:approve');

INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'USER_ADMIN' AND p.code IN ('user:manage', 'user:approve');

INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'AUDITOR' AND p.code = 'audit:read';

-- 插入系统内置 @global 命名空间
INSERT INTO namespace (slug, display_name, type, description, status)
VALUES ('global', 'Global', 'GLOBAL', 'Platform-level public namespace', 'ACTIVE');
EOF
  • Step 3: 运行 Flyway 迁移

Run: cd server && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://localhost:5432/skillhub -Dflyway.user=skillhub -Dflyway.password=skillhub_dev

Expected: Successfully applied 1 migration to schema "public"

  • Step 4: 验证表创建成功

Run: docker compose exec postgres psql -U skillhub -d skillhub -c "\dt"

Expected: 列出所有表(user_account, identity_binding, api_token, role, permission, role_permission, user_role_binding, namespace, namespace_member, audit_log, flyway_schema_history)

  • Step 5: 运行 ApplicationContextStartsTest 确认通过

Run: cd server && ./mvnw test -Dtest=ApplicationContextStartsTest -Dspring.profiles.active=local

Expected: PASS - ApplicationContext 启动成功

  • Step 6: Commit
git add server/skillhub-app/pom.xml server/skillhub-app/src/main/resources/db/migration/
git commit -m "feat: add Flyway migration with Phase 1 core schema

- Add PostgreSQL driver, Flyway, Spring Data JPA, Redis dependencies
- Create V1__init_schema.sql with auth tables (user_account, identity_binding, api_token)
- Create RBAC tables (role, permission, role_permission, user_role_binding)
- Create namespace tables (namespace, namespace_member)
- Create audit_log table
- Insert system roles (SUPER_ADMIN, SKILL_ADMIN, USER_ADMIN, AUDITOR) and permissions
- Insert @global namespace"

Task 5: 添加 RequestId Filter 和全局异常处理

Files:

  • Create: server/skillhub-app/src/main/java/com/skillhub/filter/RequestIdFilter.java

  • Create: server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java

  • Create: server/skillhub-app/src/main/java/com/skillhub/dto/ErrorResponse.java

  • Test: server/skillhub-app/src/test/java/com/skillhub/filter/RequestIdFilterTest.java

  • Step 1: 编写 RequestIdFilter 测试

mkdir -p server/skillhub-app/src/test/java/com/skillhub/filter
cat > server/skillhub-app/src/test/java/com/skillhub/filter/RequestIdFilterTest.java << 'EOF'
package com.skillhub.filter;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class RequestIdFilterTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldGenerateRequestIdWhenNotProvided() throws Exception {
        mockMvc.perform(get("/actuator/health"))
                .andExpect(status().isOk())
                .andExpect(header().exists("X-Request-Id"));
    }

    @Test
    void shouldPreserveProvidedRequestId() throws Exception {
        String requestId = "test-request-123";
        mockMvc.perform(get("/actuator/health")
                        .header("X-Request-Id", requestId))
                .andExpect(status().isOk())
                .andExpect(header().string("X-Request-Id", requestId));
    }
}
EOF
  • Step 2: 运行测试确认失败

Run: cd server && ./mvnw test -Dtest=RequestIdFilterTest -Dspring.profiles.active=local

Expected: FAIL - "Expected header X-Request-Id does not exist"

  • Step 3: 实现 RequestIdFilter
mkdir -p server/skillhub-app/src/main/java/com/skillhub/filter
cat > server/skillhub-app/src/main/java/com/skillhub/filter/RequestIdFilter.java << 'EOF'
package com.skillhub.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {

    private static final String REQUEST_ID_HEADER = "X-Request-Id";
    private static final String REQUEST_ID_MDC_KEY = "requestId";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String requestId = request.getHeader(REQUEST_ID_HEADER);
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }

        MDC.put(REQUEST_ID_MDC_KEY, requestId);
        response.setHeader(REQUEST_ID_HEADER, requestId);

        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(REQUEST_ID_MDC_KEY);
        }
    }
}
EOF
  • Step 4: 运行测试确认通过

Run: cd server && ./mvnw test -Dtest=RequestIdFilterTest -Dspring.profiles.active=local

Expected: PASS

  • Step 5: 创建全局异常处理器和 DTO
mkdir -p server/skillhub-app/src/main/java/com/skillhub/exception
cat > server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java << 'EOF'
package com.skillhub.exception;

import com.skillhub.dto.ErrorResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGlobalException(Exception ex, WebRequest request) {
        logger.error("Unhandled exception", ex);
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal server error",
                ex.getMessage()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}
EOF

mkdir -p server/skillhub-app/src/main/java/com/skillhub/dto
cat > server/skillhub-app/src/main/java/com/skillhub/dto/ErrorResponse.java << 'EOF'
package com.skillhub.dto;

public record ErrorResponse(
        int status,
        String error,
        String message
) {}
EOF
  • Step 6: Commit
git add server/skillhub-app/src/main/java/com/skillhub/filter/ \
        server/skillhub-app/src/main/java/com/skillhub/exception/ \
        server/skillhub-app/src/main/java/com/skillhub/dto/ \
        server/skillhub-app/src/test/java/com/skillhub/filter/
git commit -m "feat: add RequestId filter and global exception handler

- Implement RequestIdFilter to generate/preserve X-Request-Id header
- Add MDC support for request tracing in logs
- Create GlobalExceptionHandler for unified error responses
- Add ErrorResponse DTO
- Add RequestIdFilterTest with MockMvc"

Task 6: 添加 OpenAPI 配置和健康检查端点

Files:

  • Create: server/skillhub-app/src/main/java/com/skillhub/config/OpenApiConfig.java

  • Create: server/skillhub-app/src/main/java/com/skillhub/controller/HealthController.java

  • Test: server/skillhub-app/src/test/java/com/skillhub/controller/HealthControllerTest.java

  • Step 1: 编写健康检查端点测试

mkdir -p server/skillhub-app/src/test/java/com/skillhub/controller
cat > server/skillhub-app/src/test/java/com/skillhub/controller/HealthControllerTest.java << 'EOF'
package com.skillhub.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class HealthControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldReturnHealthStatus() throws Exception {
        mockMvc.perform(get("/api/v1/health"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("UP"));
    }
}
EOF
  • Step 2: 运行测试确认失败

Run: cd server && ./mvnw test -Dtest=HealthControllerTest -Dspring.profiles.active=local

Expected: FAIL - 404 Not Found

  • Step 3: 实现 HealthController
mkdir -p server/skillhub-app/src/main/java/com/skillhub/controller
cat > server/skillhub-app/src/main/java/com/skillhub/controller/HealthController.java << 'EOF'
package com.skillhub.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/v1")
public class HealthController {

    @GetMapping("/health")
    public Map<String, String> health() {
        return Map.of("status", "UP");
    }
}
EOF
  • Step 4: 运行测试确认通过

Run: cd server && ./mvnw test -Dtest=HealthControllerTest -Dspring.profiles.active=local

Expected: PASS

  • Step 5: 创建 OpenAPI 配置
mkdir -p server/skillhub-app/src/main/java/com/skillhub/config
cat > server/skillhub-app/src/main/java/com/skillhub/config/OpenApiConfig.java << 'EOF'
package com.skillhub.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI skillhubOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("SkillHub API")
                        .description("Skills Registry Platform")
                        .version("0.1.0"))
                .servers(List.of(
                        new Server().url("http://localhost:8080").description("Local development")
                ));
    }
}
EOF
  • Step 6: 验证 OpenAPI 文档可访问

Run: cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local (在另一个终端)

Run: curl -s http://localhost:8080/v3/api-docs | jq '.info.title'

Expected: "SkillHub API"

  • Step 7: 停止应用并 Commit
# Ctrl+C 停止应用
git add server/skillhub-app/src/main/java/com/skillhub/config/ \
        server/skillhub-app/src/main/java/com/skillhub/controller/ \
        server/skillhub-app/src/test/java/com/skillhub/controller/
git commit -m "feat: add OpenAPI configuration and health check endpoint

- Configure Springdoc OpenAPI with API info and server URL
- Add /api/v1/health endpoint for basic health check
- Add HealthControllerTest
- OpenAPI docs available at /v3/api-docs and /swagger-ui.html"

Task 7: 添加 Makefile 顶层编排

Files:

  • Create: Makefile

  • Step 1: 创建 Makefile

cat > Makefile << 'EOF'
.PHONY: dev dev-down build test clean

# 启动本地开发环境(仅依赖服务)
dev:
	docker compose up -d
	@echo "Waiting for services to be healthy..."
	@sleep 5
	@echo "Services ready. Start backend with: cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local"

# 停止本地开发环境
dev-down:
	docker compose down

# 构建后端
build:
	cd server && ./mvnw clean package -DskipTests

# 运行测试
test:
	cd server && ./mvnw test

# 清理构建产物
clean:
	cd server && ./mvnw clean
	docker compose down -v

# 生成 OpenAPI 类型(前端用,Phase 1 暂不实现)
generate-api:
	@echo "Frontend not yet implemented"
EOF
  • Step 2: 测试 Makefile 命令

Run: make dev

Expected: Docker Compose 服务启动,提示信息显示

Run: make test

Expected: 所有测试通过

Run: make dev-down

Expected: Docker Compose 服务停止

  • Step 3: Commit
git add Makefile
git commit -m "feat: add Makefile for top-level orchestration

- Add 'make dev' to start Docker Compose dependencies
- Add 'make dev-down' to stop services
- Add 'make build' to build backend JAR
- Add 'make test' to run all tests
- Add 'make clean' to clean build artifacts and volumes"

Chunk 1 验收标准

运行以下命令验证 Chunk 1 完成:

# 1. 启动依赖服务
make dev

# 2. 运行所有测试
make test
# Expected: BUILD SUCCESS, all tests pass

# 3. 启动后端应用
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local

# 4. 验证健康检查
curl http://localhost:8080/api/v1/health
# Expected: {"status":"UP"}

# 5. 验证 Actuator
curl http://localhost:8080/actuator/health
# Expected: {"status":"UP"}

# 6. 验证 OpenAPI 文档
curl http://localhost:8080/v3/api-docs | jq '.info.title'
# Expected: "SkillHub API"

# 7. 验证 RequestId
curl -v http://localhost:8080/api/v1/health 2>&1 | grep X-Request-Id
# Expected: X-Request-Id header present

# 8. 验证数据库表
docker compose exec postgres psql -U skillhub -d skillhub -c "\dt"
# Expected: 列出所有 Phase 1 表

# 9. 停止服务
make dev-down

Chunk 1 产出:可启动的后端应用 + 数据库 schema + Docker Compose 本地环境 + Makefile 编排。

Chunk 2: 后端认证与授权体系

本块实现完整的认证链路:Spring Security OAuth2 GitHub 登录、AccessPolicy 准入策略、身份绑定、Spring Session Redis、API Token 认证、RBAC 授权、MockAuthFilter 本地开发、CSRF 防护。

文件结构映射

server/
├── skillhub-domain/src/main/java/com/skillhub/domain/
│   ├── user/
│   │   ├── UserAccount.java              # 用户实体
│   │   ├── UserStatus.java               # 用户状态枚举
│   │   └── UserAccountRepository.java    # Repository 接口
│   └── namespace/
│       ├── Namespace.java                # 命名空间实体
│       ├── NamespaceStatus.java          # 命名空间状态枚举
│       ├── NamespaceMember.java          # 成员实体
│       ├── NamespaceRole.java            # 命名空间角色枚举
│       ├── NamespaceRepository.java
│       └── NamespaceMemberRepository.java
├── skillhub-auth/src/main/java/com/skillhub/auth/
│   ├── entity/
│   │   ├── IdentityBinding.java
│   │   ├── ApiToken.java
│   │   ├── Role.java
│   │   ├── Permission.java
│   │   ├── RolePermission.java
│   │   └── UserRoleBinding.java
│   ├── repository/
│   │   ├── IdentityBindingRepository.java
│   │   ├── ApiTokenRepository.java
│   │   ├── RoleRepository.java
│   │   ├── PermissionRepository.java
│   │   └── UserRoleBindingRepository.java
│   ├── oauth/
│   │   ├── OAuthClaims.java
│   │   ├── OAuthClaimsExtractor.java
│   │   ├── GitHubClaimsExtractor.java
│   │   ├── CustomOAuth2UserService.java
│   │   └── OAuth2LoginSuccessHandler.java
│   ├── policy/
│   │   ├── AccessPolicy.java
│   │   ├── AccessDecision.java
│   │   ├── OpenAccessPolicy.java
│   │   ├── EmailDomainAccessPolicy.java
│   │   └── AccessPolicyFactory.java
│   ├── identity/
│   │   └── IdentityBindingService.java
│   ├── token/
│   │   ├── ApiTokenService.java
│   │   └── ApiTokenAuthenticationFilter.java
│   ├── rbac/
│   │   ├── RbacService.java
│   │   └── PlatformPrincipal.java
│   ├── config/
│   │   └── SecurityConfig.java
│   └── mock/
│       └── MockAuthFilter.java
├── skillhub-app/src/main/java/com/skillhub/
│   ├── controller/
│   │   └── AuthController.java
│   └── exception/
│       ├── GlobalExceptionHandler.java
│       └── ErrorResponse.java
└── skillhub-infra/src/main/java/com/skillhub/infra/
    └── jpa/
        ├── UserAccountJpaRepository.java
        ├── NamespaceJpaRepository.java
        └── NamespaceMemberJpaRepository.java

Task 8: Domain 层用户与命名空间实体

Files:

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccount.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserStatus.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccountRepository.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/Namespace.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceStatus.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceMember.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceRole.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceRepository.java

  • Create: server/skillhub-domain/src/main/java/com/skillhub/domain/namespace/NamespaceMemberRepository.java

  • Step 1: 创建 UserStatus 枚举

// server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserStatus.java
package com.skillhub.domain.user;

public enum UserStatus {
    ACTIVE,
    PENDING,
    DISABLED,
    MERGED
}
  • Step 2: 创建 UserAccount 实体
// server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccount.java
package com.skillhub.domain.user;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "user_account")
public class UserAccount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "display_name", nullable = false, length = 128)
    private String displayName;

    @Column(length = 256)
    private String email;

    @Column(name = "avatar_url", length = 512)
    private String avatarUrl;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private UserStatus status = UserStatus.ACTIVE;

    @Column(name = "merged_to_user_id")
    private Long mergedToUserId;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    protected UserAccount() {}

    public UserAccount(String displayName, String email, String avatarUrl) {
        this.displayName = displayName;
        this.email = email;
        this.avatarUrl = avatarUrl;
        this.status = UserStatus.ACTIVE;
    }

    @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 String getDisplayName() { return displayName; }
    public void setDisplayName(String displayName) { this.displayName = displayName; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getAvatarUrl() { return avatarUrl; }
    public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
    public UserStatus getStatus() { return status; }
    public void setStatus(UserStatus status) { this.status = status; }
    public Long getMergedToUserId() { return mergedToUserId; }
    public void setMergedToUserId(Long mergedToUserId) { this.mergedToUserId = mergedToUserId; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }

    public boolean isActive() { return this.status == UserStatus.ACTIVE; }
}
  • Step 3: 创建 UserAccountRepository 接口
// server/skillhub-domain/src/main/java/com/skillhub/domain/user/UserAccountRepository.java
package com.skillhub.domain.user;

import java.util.Optional;

public interface UserAccountRepository {
    Optional<UserAccount> findById(Long id);
    UserAccount save(UserAccount user);
}
  • Step 4: 创建命名空间相关实体
// NamespaceStatus.java
package com.skillhub.domain.namespace;

public enum NamespaceStatus {
    ACTIVE, FROZEN, ARCHIVED
}

// NamespaceRole.java
package com.skillhub.domain.namespace;

public enum NamespaceRole {
    OWNER, ADMIN, MEMBER
}

// Namespace.java
package com.skillhub.domain.namespace;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "namespace")
public class Namespace {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 64)
    private String slug;

    @Column(name = "display_name", nullable = false, length = 128)
    private String displayName;

    @Column(length = 512)
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private NamespaceStatus status = NamespaceStatus.ACTIVE;

    @Column(name = "created_by")
    private Long createdBy;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    protected Namespace() {}

    public Namespace(String slug, String displayName, Long createdBy) {
        this.slug = slug;
        this.displayName = displayName;
        this.createdBy = createdBy;
    }

    @PrePersist
    void prePersist() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    void preUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public String getSlug() { return slug; }
    public String getDisplayName() { return displayName; }
    public NamespaceStatus getStatus() { return status; }
    public Long getCreatedBy() { return createdBy; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

// NamespaceMember.java
package com.skillhub.domain.namespace;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "namespace_member",
       uniqueConstraints = @UniqueConstraint(columnNames = {"namespace_id", "user_id"}))
public class NamespaceMember {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "namespace_id", nullable = false)
    private Long namespaceId;

    @Column(name = "user_id", nullable = false)
    private String userId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private NamespaceRole role;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    protected NamespaceMember() {}

    public NamespaceMember(Long namespaceId, String userId, NamespaceRole role) {
        this.namespaceId = namespaceId;
        this.userId = userId;
        this.role = role;
    }

    @PrePersist
    void prePersist() {
        this.createdAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public Long getNamespaceId() { return namespaceId; }
    public Long getUserId() { return userId; }
    public NamespaceRole getRole() { return role; }
    public void setRole(NamespaceRole role) { this.role = role; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}
  • Step 5: 创建 Repository 接口
// NamespaceRepository.java
package com.skillhub.domain.namespace;

import java.util.Optional;

public interface NamespaceRepository {
    Optional<Namespace> findById(Long id);
    Optional<Namespace> findBySlug(String slug);
    Namespace save(Namespace namespace);
}

// NamespaceMemberRepository.java
package com.skillhub.domain.namespace;

import java.util.List;
import java.util.Optional;

public interface NamespaceMemberRepository {
    Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
    List<NamespaceMember> findByUserId(String userId);
    NamespaceMember save(NamespaceMember member);
}
  • Step 6: Commit
git add server/skillhub-domain/
git commit -m "feat(domain): add UserAccount and Namespace entities with repository interfaces

- UserAccount with status lifecycle (ACTIVE/PENDING/DISABLED/MERGED)
- Namespace, NamespaceMember with role-based membership
- Repository interfaces (implementation in infra module)"

Task 9: Infra 层 JPA Repository 实现

Files:

  • Create: server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/UserAccountJpaRepository.java

  • Create: server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/NamespaceJpaRepository.java

  • Create: server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/NamespaceMemberJpaRepository.java

  • Step 1: 创建 UserAccountJpaRepository

// server/skillhub-infra/src/main/java/com/skillhub/infra/jpa/UserAccountJpaRepository.java
package com.skillhub.infra.jpa;

import com.skillhub.domain.user.UserAccount;
import com.skillhub.domain.user.UserAccountRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserAccountJpaRepository
        extends JpaRepository<UserAccount, Long>, UserAccountRepository {
}
  • Step 2: 创建 NamespaceJpaRepository 和 NamespaceMemberJpaRepository
// NamespaceJpaRepository.java
package com.skillhub.infra.jpa;

import com.skillhub.domain.namespace.Namespace;
import com.skillhub.domain.namespace.NamespaceRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface NamespaceJpaRepository
        extends JpaRepository<Namespace, Long>, NamespaceRepository {
    Optional<Namespace> findBySlug(String slug);
}

// NamespaceMemberJpaRepository.java
package com.skillhub.infra.jpa;

import com.skillhub.domain.namespace.NamespaceMember;
import com.skillhub.domain.namespace.NamespaceMemberRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface NamespaceMemberJpaRepository
        extends JpaRepository<NamespaceMember, Long>, NamespaceMemberRepository {
    Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
    List<NamespaceMember> findByUserId(String userId);
}
  • Step 3: Commit
git add server/skillhub-infra/
git commit -m "feat(infra): add JPA repository implementations for UserAccount and Namespace"

Task 10: Auth 模块实体与 Repository

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/entity/IdentityBinding.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/entity/ApiToken.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/entity/Role.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/entity/Permission.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/entity/RolePermission.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/entity/UserRoleBinding.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/repository/*.java

  • Step 1: 创建 IdentityBinding 实体

package com.skillhub.auth.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "identity_binding",
       uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"}))
public class IdentityBinding {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_id", nullable = false)
    private String userId;

    @Column(name = "provider_code", nullable = false, length = 64)
    private String providerCode;

    @Column(nullable = false, length = 256)
    private String subject;

    @Column(name = "login_name", length = 128)
    private String loginName;

    @Column(name = "extra_json", columnDefinition = "jsonb")
    private String extraJson;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    protected IdentityBinding() {}

    public IdentityBinding(String userId, String providerCode, String subject, String loginName) {
        this.userId = userId;
        this.providerCode = providerCode;
        this.subject = subject;
        this.loginName = loginName;
    }

    @PrePersist
    void prePersist() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    void preUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public Long getUserId() { return userId; }
    public String getProviderCode() { return providerCode; }
    public String getSubject() { return subject; }
    public String getLoginName() { return loginName; }
    public void setLoginName(String loginName) { this.loginName = loginName; }
    public String getExtraJson() { return extraJson; }
    public void setExtraJson(String extraJson) { this.extraJson = extraJson; }
}
  • Step 2: 创建 ApiToken 实体
package com.skillhub.auth.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "api_token")
public class ApiToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "subject_type", nullable = false, length = 32)
    private String subjectType = "USER";

    @Column(name = "subject_id", nullable = false)
    private Long subjectId;

    @Column(name = "user_id", nullable = false)
    private String userId;

    @Column(nullable = false, length = 128)
    private String name;

    @Column(name = "token_prefix", nullable = false, length = 16)
    private String tokenPrefix;

    @Column(name = "token_hash", nullable = false, unique = true, length = 64)
    private String tokenHash;

    @Column(name = "scope_json", nullable = false, columnDefinition = "jsonb")
    private String scopeJson;

    @Column(name = "expires_at")
    private LocalDateTime expiresAt;

    @Column(name = "last_used_at")
    private LocalDateTime lastUsedAt;

    @Column(name = "revoked_at")
    private LocalDateTime revokedAt;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    protected ApiToken() {}

    public ApiToken(String userId, String name, String tokenPrefix, String tokenHash, String scopeJson) {
        this.subjectType = "USER";
        this.subjectId = userId;
        this.userId = userId;
        this.name = name;
        this.tokenPrefix = tokenPrefix;
        this.tokenHash = tokenHash;
        this.scopeJson = scopeJson;
    }

    @PrePersist
    void prePersist() {
        this.createdAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public Long getUserId() { return userId; }
    public String getName() { return name; }
    public String getTokenPrefix() { return tokenPrefix; }
    public String getTokenHash() { return tokenHash; }
    public String getScopeJson() { return scopeJson; }
    public LocalDateTime getExpiresAt() { return expiresAt; }
    public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; }
    public LocalDateTime getLastUsedAt() { return lastUsedAt; }
    public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; }
    public LocalDateTime getRevokedAt() { return revokedAt; }
    public void setRevokedAt(LocalDateTime revokedAt) { this.revokedAt = revokedAt; }
    public LocalDateTime getCreatedAt() { return createdAt; }

    public boolean isRevoked() { return revokedAt != null; }
    public boolean isExpired() { return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); }
    public boolean isValid() { return !isRevoked() && !isExpired(); }
}
  • Step 3: 创建 RBAC 实体(Role, Permission, RolePermission, UserRoleBinding)
// Role.java
package com.skillhub.auth.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "role")
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 64)
    private String code;

    @Column(nullable = false, length = 128)
    private String name;

    @Column(length = 512)
    private String description;

    @Column(name = "is_system", nullable = false)
    private boolean system;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @PrePersist
    void prePersist() { this.createdAt = LocalDateTime.now(); }

    public Long getId() { return id; }
    public String getCode() { return code; }
    public String getName() { return name; }
    public boolean isSystem() { return system; }
}

// Permission.java
package com.skillhub.auth.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "permission")
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 128)
    private String code;

    @Column(nullable = false, length = 128)
    private String name;

    @Column(name = "group_code", length = 64)
    private String groupCode;

    public Long getId() { return id; }
    public String getCode() { return code; }
    public String getName() { return name; }
}

// RolePermission.java
package com.skillhub.auth.entity;

import jakarta.persistence.*;
import java.io.Serializable;

@Entity
@Table(name = "role_permission")
@IdClass(RolePermission.RolePermissionId.class)
public class RolePermission {
    @Id
    @Column(name = "role_id")
    private Long roleId;

    @Id
    @Column(name = "permission_id")
    private Long permissionId;

    public Long getRoleId() { return roleId; }
    public Long getPermissionId() { return permissionId; }

    public static class RolePermissionId implements Serializable {
        private Long roleId;
        private Long permissionId;
        // equals and hashCode omitted for brevity — implement in code
    }
}

// UserRoleBinding.java
package com.skillhub.auth.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "user_role_binding",
       uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id"}))
public class UserRoleBinding {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_id", nullable = false)
    private String userId;

    @Column(name = "role_id", nullable = false)
    private Long roleId;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    protected UserRoleBinding() {}

    public UserRoleBinding(String userId, Long roleId) {
        this.userId = userId;
        this.roleId = roleId;
    }

    @PrePersist
    void prePersist() { this.createdAt = LocalDateTime.now(); }

    public Long getId() { return id; }
    public Long getUserId() { return userId; }
    public Long getRoleId() { return roleId; }
}
  • Step 4: 创建 Auth Repository 接口
// IdentityBindingRepository.java
package com.skillhub.auth.repository;

import com.skillhub.auth.entity.IdentityBinding;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;

@Repository
public interface IdentityBindingRepository extends JpaRepository<IdentityBinding, Long> {
    Optional<IdentityBinding> findByProviderCodeAndSubject(String providerCode, String subject);
}

// ApiTokenRepository.java
package com.skillhub.auth.repository;

import com.skillhub.auth.entity.ApiToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;

@Repository
public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
    Optional<ApiToken> findByTokenHash(String tokenHash);
    List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
}

// RoleRepository.java
package com.skillhub.auth.repository;

import com.skillhub.auth.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;

@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByCode(String code);
}

// UserRoleBindingRepository.java
package com.skillhub.auth.repository;

import com.skillhub.auth.entity.UserRoleBinding;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface UserRoleBindingRepository extends JpaRepository<UserRoleBinding, Long> {
    List<UserRoleBinding> findByUserId(String userId);
}
  • Step 5: Commit
git add server/skillhub-auth/
git commit -m "feat(auth): add auth entities and JPA repositories

- IdentityBinding, ApiToken, Role, Permission, RolePermission, UserRoleBinding
- JPA repositories for all auth entities"

Task 10: OAuth2 Claims 提取与准入策略

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuthClaims.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuthClaimsExtractor.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/GitHubClaimsExtractor.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/policy/AccessDecision.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/policy/AccessPolicy.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/policy/OpenAccessPolicy.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/policy/EmailDomainAccessPolicy.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/policy/AccessPolicyFactory.java

  • Test: server/skillhub-auth/src/test/java/com/skillhub/auth/policy/AccessPolicyTest.java

  • Step 1: 创建 OAuthClaims record

// server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuthClaims.java
package com.skillhub.auth.oauth;

import java.util.Map;

public record OAuthClaims(
    String provider,
    String subject,
    String email,
    boolean emailVerified,
    String providerLogin,
    Map<String, Object> extra
) {}
  • Step 2: 创建 OAuthClaimsExtractor 接口和 GitHub 实现
// OAuthClaimsExtractor.java
package com.skillhub.auth.oauth;

import org.springframework.security.oauth2.core.user.OAuth2User;

public interface OAuthClaimsExtractor {
    String getProvider();
    OAuthClaims extract(OAuth2User oAuth2User);
}
// GitHubClaimsExtractor.java
package com.skillhub.auth.oauth;

import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Map;

@Component
public class GitHubClaimsExtractor implements OAuthClaimsExtractor {

    @Override
    public String getProvider() { return "github"; }

    @Override
    public OAuthClaims extract(OAuth2User oAuth2User) {
        Map<String, Object> attrs = oAuth2User.getAttributes();
        return new OAuthClaims(
            "github",
            String.valueOf(attrs.get("id")),
            (String) attrs.get("email"),
            attrs.get("email") != null,
            (String) attrs.get("login"),
            attrs
        );
    }
}
  • Step 3: 创建 AccessDecision 和 AccessPolicy
// AccessDecision.java
package com.skillhub.auth.policy;

public enum AccessDecision {
    ALLOW,
    DENY,
    PENDING_APPROVAL
}
// AccessPolicy.java
package com.skillhub.auth.policy;

import com.skillhub.auth.oauth.OAuthClaims;

public interface AccessPolicy {
    AccessDecision evaluate(OAuthClaims claims);
}
  • Step 4: 创建 OpenAccessPolicy 和 EmailDomainAccessPolicy
// OpenAccessPolicy.java
package com.skillhub.auth.policy;

import com.skillhub.auth.oauth.OAuthClaims;

public class OpenAccessPolicy implements AccessPolicy {
    @Override
    public AccessDecision evaluate(OAuthClaims claims) {
        return AccessDecision.ALLOW;
    }
}
// EmailDomainAccessPolicy.java
package com.skillhub.auth.policy;

import com.skillhub.auth.oauth.OAuthClaims;
import java.util.Set;

public class EmailDomainAccessPolicy implements AccessPolicy {
    private final Set<String> allowedDomains;

    public EmailDomainAccessPolicy(Set<String> allowedDomains) {
        this.allowedDomains = allowedDomains;
    }

    @Override
    public AccessDecision evaluate(OAuthClaims claims) {
        if (claims.email() == null) return AccessDecision.DENY;
        String domain = claims.email().substring(claims.email().indexOf('@') + 1);
        return allowedDomains.contains(domain.toLowerCase())
            ? AccessDecision.ALLOW : AccessDecision.DENY;
    }
}
// ProviderAllowlistAccessPolicy.java
package com.skillhub.auth.policy;

import com.skillhub.auth.oauth.OAuthClaims;
import java.util.Set;

public class ProviderAllowlistAccessPolicy implements AccessPolicy {
    private final Set<String> allowedProviders;

    public ProviderAllowlistAccessPolicy(Set<String> allowedProviders) {
        this.allowedProviders = allowedProviders;
    }

    @Override
    public AccessDecision evaluate(OAuthClaims claims) {
        return allowedProviders.contains(claims.provider())
            ? AccessDecision.ALLOW : AccessDecision.DENY;
    }
}
// SubjectWhitelistAccessPolicy.java
package com.skillhub.auth.policy;

import com.skillhub.auth.oauth.OAuthClaims;
import java.util.Set;

public class SubjectWhitelistAccessPolicy implements AccessPolicy {
    private final Set<String> whitelistedSubjects; // "provider:subject" 格式

    public SubjectWhitelistAccessPolicy(Set<String> whitelistedSubjects) {
        this.whitelistedSubjects = whitelistedSubjects;
    }

    @Override
    public AccessDecision evaluate(OAuthClaims claims) {
        String key = claims.provider() + ":" + claims.subject();
        return whitelistedSubjects.contains(key)
            ? AccessDecision.ALLOW : AccessDecision.DENY;
    }
}
  • Step 5: 创建 AccessPolicyFactory
// AccessPolicyFactory.java
package com.skillhub.auth.policy;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.Set;

@Configuration
@ConfigurationProperties(prefix = "skillhub.access-policy")
public class AccessPolicyFactory {
    private String mode = "OPEN";
    private List<String> allowedEmailDomains = List.of();
    private List<String> allowedProviders = List.of();
    private List<String> whitelistedSubjects = List.of();

    @Bean
    public AccessPolicy accessPolicy() {
        return switch (mode.toUpperCase()) {
            case "EMAIL_DOMAIN" -> new EmailDomainAccessPolicy(Set.copyOf(allowedEmailDomains));
            case "PROVIDER_ALLOWLIST" -> new ProviderAllowlistAccessPolicy(Set.copyOf(allowedProviders));
            case "SUBJECT_WHITELIST" -> new SubjectWhitelistAccessPolicy(Set.copyOf(whitelistedSubjects));
            default -> new OpenAccessPolicy();
        };
    }

    public void setMode(String mode) { this.mode = mode; }
    public void setAllowedEmailDomains(List<String> d) { this.allowedEmailDomains = d; }
    public void setAllowedProviders(List<String> p) { this.allowedProviders = p; }
    public void setWhitelistedSubjects(List<String> s) { this.whitelistedSubjects = s; }
}
  • Step 6: 编写 AccessPolicy 单元测试
// server/skillhub-auth/src/test/java/com/skillhub/auth/policy/AccessPolicyTest.java
package com.skillhub.auth.policy;

import com.skillhub.auth.oauth.OAuthClaims;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;

class AccessPolicyTest {

    @Test
    void openPolicy_alwaysAllows() {
        var policy = new OpenAccessPolicy();
        var claims = new OAuthClaims("github", "123", "user@any.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW);
    }

    @Test
    void emailDomainPolicy_allowsMatchingDomain() {
        var policy = new EmailDomainAccessPolicy(Set.of("company.com"));
        var claims = new OAuthClaims("github", "123", "user@company.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW);
    }

    @Test
    void emailDomainPolicy_deniesNonMatchingDomain() {
        var policy = new EmailDomainAccessPolicy(Set.of("company.com"));
        var claims = new OAuthClaims("github", "123", "user@other.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY);
    }

    @Test
    void emailDomainPolicy_deniesNullEmail() {
        var policy = new EmailDomainAccessPolicy(Set.of("company.com"));
        var claims = new OAuthClaims("github", "123", null, false, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY);
    }

    @Test
    void providerAllowlistPolicy_allowsMatchingProvider() {
        var policy = new ProviderAllowlistAccessPolicy(Set.of("github"));
        var claims = new OAuthClaims("github", "123", "u@a.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW);
    }

    @Test
    void providerAllowlistPolicy_deniesNonMatchingProvider() {
        var policy = new ProviderAllowlistAccessPolicy(Set.of("github"));
        var claims = new OAuthClaims("google", "123", "u@a.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY);
    }

    @Test
    void subjectWhitelistPolicy_allowsMatchingSubject() {
        var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345"));
        var claims = new OAuthClaims("github", "12345", "u@a.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW);
    }

    @Test
    void subjectWhitelistPolicy_deniesNonMatchingSubject() {
        var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345"));
        var claims = new OAuthClaims("github", "99999", "u@a.com", true, "user", Map.of());
        assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY);
    }
}
  • Step 7: 运行测试验证

Run: cd server && ./mvnw test -pl skillhub-auth -Dtest=AccessPolicyTest -am

Expected: 8 tests PASS

  • Step 8: Commit
git add server/skillhub-auth/
git commit -m "feat(auth): add OAuth claims extraction and access policy

- OAuthClaims record, OAuthClaimsExtractor SPI, GitHubClaimsExtractor
- AccessPolicy SPI with Open and EmailDomain implementations
- AccessPolicyFactory with config-driven strategy selection
- Unit tests for access policies"

Task 11: IdentityBindingService + CustomOAuth2UserService

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/identity/IdentityBindingService.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/CustomOAuth2UserService.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/PlatformPrincipal.java

  • Step 1: 创建 PlatformPrincipal

// server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/PlatformPrincipal.java
package com.skillhub.auth.rbac;

import java.io.Serializable;
import java.util.Set;

public record PlatformPrincipal(
    String userId,
    String displayName,
    String email,
    String avatarUrl,
    String oauthProvider,
    Set<String> platformRoles
) implements Serializable {}
  • Step 2: 创建 IdentityBindingService
// server/skillhub-auth/src/main/java/com/skillhub/auth/identity/IdentityBindingService.java
package com.skillhub.auth.identity;

import com.skillhub.auth.entity.IdentityBinding;
import com.skillhub.auth.entity.UserRoleBinding;
import com.skillhub.auth.oauth.OAuthClaims;
import com.skillhub.auth.rbac.PlatformPrincipal;
import com.skillhub.auth.repository.IdentityBindingRepository;
import com.skillhub.auth.repository.UserRoleBindingRepository;
import com.skillhub.domain.user.UserAccount;
import com.skillhub.domain.user.UserAccountRepository;
import com.skillhub.domain.user.UserStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class IdentityBindingService {

    private final IdentityBindingRepository bindingRepo;
    private final UserAccountRepository userRepo;
    private final UserRoleBindingRepository roleBindingRepo;

    public IdentityBindingService(IdentityBindingRepository bindingRepo,
                                   UserAccountRepository userRepo,
                                   UserRoleBindingRepository roleBindingRepo) {
        this.bindingRepo = bindingRepo;
        this.userRepo = userRepo;
        this.roleBindingRepo = roleBindingRepo;
    }

    @Transactional
    public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStatus) {
        IdentityBinding binding = bindingRepo
            .findByProviderCodeAndSubject(claims.provider(), claims.subject())
            .orElse(null);

        UserAccount user;
        if (binding != null) {
            user = userRepo.findById(binding.getUserId())
                .orElseThrow(() -> new IllegalStateException("User not found for binding"));
            // 同步最新信息
            user.setDisplayName(claims.providerLogin());
            if (claims.email() != null) user.setEmail(claims.email());
            if (claims.extra().get("avatar_url") != null) {
                user.setAvatarUrl((String) claims.extra().get("avatar_url"));
            }
            user = userRepo.save(user);
        } else {
            user = new UserAccount(
                claims.providerLogin(),
                claims.email(),
                (String) claims.extra().get("avatar_url")
            );
            user.setStatus(initialStatus);
            user = userRepo.save(user);

            binding = new IdentityBinding();
            binding.setUserId(user.getId());
            binding.setProviderCode(claims.provider());
            binding.setSubject(claims.subject());
            binding.setLoginName(claims.providerLogin());
            bindingRepo.save(binding);
        }

        Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
            .map(rb -> rb.getRole().getCode())
            .collect(Collectors.toSet());

        return new PlatformPrincipal(
            user.getId(), user.getDisplayName(), user.getEmail(),
            user.getAvatarUrl(), claims.provider(), roles
        );
    }
}
  • Step 3: 创建 CustomOAuth2UserService
// server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/CustomOAuth2UserService.java
package com.skillhub.auth.oauth;

import com.skillhub.auth.identity.IdentityBindingService;
import com.skillhub.auth.policy.AccessDecision;
import com.skillhub.auth.policy.AccessPolicy;
import com.skillhub.auth.rbac.PlatformPrincipal;
import com.skillhub.domain.user.UserStatus;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
    private final Map<String, OAuthClaimsExtractor> extractors;
    private final AccessPolicy accessPolicy;
    private final IdentityBindingService identityBindingService;

    public CustomOAuth2UserService(List<OAuthClaimsExtractor> extractorList,
                                    AccessPolicy accessPolicy,
                                    IdentityBindingService identityBindingService) {
        this.extractors = extractorList.stream()
            .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
        this.accessPolicy = accessPolicy;
        this.identityBindingService = identityBindingService;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = delegate.loadUser(request);
        String registrationId = request.getClientRegistration().getRegistrationId();

        OAuthClaimsExtractor extractor = extractors.get(registrationId);
        if (extractor == null) {
            throw new OAuth2AuthenticationException(
                new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null));
        }

        OAuthClaims claims = extractor.extract(oAuth2User);
        AccessDecision decision = accessPolicy.evaluate(claims);

        UserStatus initialStatus = switch (decision) {
            case ALLOW -> UserStatus.ACTIVE;
            case PENDING_APPROVAL -> UserStatus.PENDING;
            case DENY -> throw new OAuth2AuthenticationException(
                new OAuth2Error("access_denied", "Access denied by policy", null));
        };

        PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, initialStatus);

        // 将 principal 存入 OAuth2User attributes 供后续使用
        var attrs = new java.util.HashMap<>(oAuth2User.getAttributes());
        attrs.put("platformPrincipal", principal);

        return new org.springframework.security.oauth2.core.user.DefaultOAuth2User(
            oAuth2User.getAuthorities(), attrs, "login"
        );
    }
}
  • Step 4: 创建 OAuth2LoginSuccessHandler
// server/skillhub-auth/src/main/java/com/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java
package com.skillhub.auth.oauth;

import com.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    public OAuth2LoginSuccessHandler() {
        setDefaultTargetUrl("/?login=success");
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                         Authentication authentication) throws IOException, jakarta.servlet.ServletException {
        if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) {
            PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal");
            if (principal != null) {
                request.getSession().setAttribute("platformPrincipal", principal);
            }
        }
        super.onAuthenticationSuccess(request, response, authentication);
    }
}
  • Step 5: Commit
git add server/skillhub-auth/ server/skillhub-domain/
git commit -m "feat(auth): add identity binding and OAuth2 user service

- PlatformPrincipal session record
- IdentityBindingService: bind or create user from OAuth claims
- CustomOAuth2UserService: delegate → extract → policy → bind
- OAuth2LoginSuccessHandler: store principal in session"

Task 12: API Token 签发与认证 Filter

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenService.java

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenAuthenticationFilter.java

  • Test: server/skillhub-auth/src/test/java/com/skillhub/auth/token/ApiTokenServiceTest.java

  • Step 1: 创建 ApiTokenService

// server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenService.java
package com.skillhub.auth.token;

import com.skillhub.auth.entity.ApiToken;
import com.skillhub.auth.rbac.PlatformPrincipal;
import com.skillhub.auth.repository.ApiTokenRepository;
import com.skillhub.auth.repository.UserRoleBindingRepository;
import com.skillhub.domain.user.UserAccount;
import com.skillhub.domain.user.UserAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class ApiTokenService {

    private static final String TOKEN_PREFIX = "ask_";
    private static final SecureRandom RANDOM = new SecureRandom();
    private final ApiTokenRepository tokenRepo;
    private final UserAccountRepository userRepo;
    private final UserRoleBindingRepository roleBindingRepo;

    public ApiTokenService(ApiTokenRepository tokenRepo,
                           UserAccountRepository userRepo,
                           UserRoleBindingRepository roleBindingRepo) {
        this.tokenRepo = tokenRepo;
        this.userRepo = userRepo;
        this.roleBindingRepo = roleBindingRepo;
    }

    /** 创建 Token,返回明文(仅此一次) */
    @Transactional
    public String createToken(String userId, String name, List<String> scopes,
                              LocalDateTime expiresAt) {
        byte[] randomBytes = new byte[32];
        RANDOM.nextBytes(randomBytes);
        String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding()
            .encodeToString(randomBytes);
        String hash = sha256(rawToken);

        ApiToken token = new ApiToken();
        token.setSubjectType("USER");
        token.setSubjectId(userId);
        token.setUserId(userId);
        token.setName(name);
        token.setTokenPrefix(TOKEN_PREFIX);
        token.setTokenHash(hash);
        token.setScopeJson(scopes);
        token.setExpiresAt(expiresAt);
        tokenRepo.save(token);

        return rawToken;
    }

    /** 通过明文 Token 认证,返回 PlatformPrincipal */
    public Optional<PlatformPrincipal> authenticate(String rawToken) {
        if (rawToken == null || !rawToken.startsWith(TOKEN_PREFIX)) {
            return Optional.empty();
        }
        String hash = sha256(rawToken);
        return tokenRepo.findByTokenHash(hash)
            .filter(t -> t.getRevokedAt() == null)
            .filter(t -> t.getExpiresAt() == null || t.getExpiresAt().isAfter(LocalDateTime.now()))
            .flatMap(t -> {
                t.setLastUsedAt(LocalDateTime.now());
                tokenRepo.save(t);
                return userRepo.findById(t.getUserId());
            })
            .filter(UserAccount::isActive)
            .map(user -> {
                Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
                    .map(rb -> rb.getRole().getCode())
                    .collect(Collectors.toSet());
                return new PlatformPrincipal(
                    user.getId(), user.getDisplayName(), user.getEmail(),
                    user.getAvatarUrl(), "api_token", roles
                );
            });
    }

    public List<ApiToken> listByUser(String userId) {
        return tokenRepo.findByUserIdAndRevokedAtIsNull(userId);
    }

    @Transactional
    public void revoke(Long tokenId, String userId) {
        tokenRepo.findById(tokenId)
            .filter(t -> t.getUserId().equals(userId))
            .ifPresent(t -> {
                t.setRevokedAt(LocalDateTime.now());
                tokenRepo.save(t);
            });
    }

    static String sha256(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}
  • Step 2: 创建 ApiTokenAuthenticationFilter
// server/skillhub-auth/src/main/java/com/skillhub/auth/token/ApiTokenAuthenticationFilter.java
package com.skillhub.auth.token;

import com.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;

public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {

    private final ApiTokenService apiTokenService;

    public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService) {
        this.apiTokenService = apiTokenService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ask_")) {
            String token = authHeader.substring("Bearer ".length());
            apiTokenService.authenticate(token).ifPresent(principal -> {
                var authorities = principal.platformRoles().stream()
                    .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                    .toList();
                var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(auth);
            });
        }
        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 仅对 CLI 和 Token API 路径生效
        String path = request.getRequestURI();
        return !(path.startsWith("/api/v1/cli/") || path.startsWith("/api/v1/tokens")
                 || path.startsWith("/api/"));
    }
}
  • Step 3: 编写 ApiTokenService 单元测试
// server/skillhub-auth/src/test/java/com/skillhub/auth/token/ApiTokenServiceTest.java
package com.skillhub.auth.token;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class ApiTokenServiceTest {

    @Test
    void sha256_producesConsistentHash() {
        String hash1 = ApiTokenService.sha256("ask_test123");
        String hash2 = ApiTokenService.sha256("ask_test123");
        assertThat(hash1).isEqualTo(hash2);
        assertThat(hash1).hasSize(64); // SHA-256 hex = 64 chars
    }

    @Test
    void sha256_differentInputsDifferentHashes() {
        String hash1 = ApiTokenService.sha256("ask_token1");
        String hash2 = ApiTokenService.sha256("ask_token2");
        assertThat(hash1).isNotEqualTo(hash2);
    }
}
  • Step 4: 运行测试

Run: cd server && ./mvnw test -pl skillhub-auth -Dtest=ApiTokenServiceTest -am

Expected: 2 tests PASS

  • Step 5: Commit
git add server/skillhub-auth/
git commit -m "feat(auth): add API Token service and authentication filter

- ApiTokenService: create, authenticate, list, revoke tokens
- ask_ prefix + SHA-256 hash storage
- ApiTokenAuthenticationFilter for Bearer token auth on CLI/compat paths
- Unit tests for SHA-256 hashing"

Task 13: RBAC 授权服务

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/RbacService.java

  • Test: server/skillhub-auth/src/test/java/com/skillhub/auth/rbac/RbacServiceTest.java

  • Step 1: 创建 RbacService

// server/skillhub-auth/src/main/java/com/skillhub/auth/rbac/RbacService.java
package com.skillhub.auth.rbac;

import com.skillhub.auth.repository.UserRoleBindingRepository;
import com.skillhub.domain.namespace.NamespaceMember;
import com.skillhub.domain.namespace.NamespaceMemberRepository;
import com.skillhub.domain.namespace.NamespaceRole;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class RbacService {

    private final UserRoleBindingRepository roleBindingRepo;
    private final NamespaceMemberRepository namespaceMemberRepo;

    public RbacService(UserRoleBindingRepository roleBindingRepo,
                       NamespaceMemberRepository namespaceMemberRepo) {
        this.roleBindingRepo = roleBindingRepo;
        this.namespaceMemberRepo = namespaceMemberRepo;
    }

    /** 检查用户是否拥有指定平台权限 */
    public boolean hasPlatformRole(PlatformPrincipal principal, String roleCode) {
        if (principal.platformRoles().contains("SUPER_ADMIN")) return true;
        return principal.platformRoles().contains(roleCode);
    }

    /** 检查用户在指定命名空间的角色是否 >= 要求的最低角色 */
    public boolean hasNamespaceRole(String userId, Long namespaceId, NamespaceRole minRole) {
        Optional<NamespaceMember> member = namespaceMemberRepo
            .findByNamespaceIdAndUserId(namespaceId, userId);
        return member.map(m -> m.getRole().ordinal() <= minRole.ordinal()).orElse(false);
    }

    /** 获取用户在指定命名空间的角色 */
    public Optional<NamespaceRole> getNamespaceRole(String userId, Long namespaceId) {
        return namespaceMemberRepo.findByNamespaceIdAndUserId(namespaceId, userId)
            .map(NamespaceMember::getRole);
    }

    /** 获取用户所有平台角色码 */
    public Set<String> getPlatformRoleCodes(String userId) {
        return roleBindingRepo.findByUserId(userId).stream()
            .map(rb -> rb.getRole().getCode())
            .collect(Collectors.toSet());
    }
}
  • Step 2: 编写 RbacService 单元测试
// server/skillhub-auth/src/test/java/com/skillhub/auth/rbac/RbacServiceTest.java
package com.skillhub.auth.rbac;

import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;

class RbacServiceTest {

    @Test
    void superAdmin_hasAnyPlatformRole() {
        var principal = new PlatformPrincipal(1L, "admin", "a@b.com", null, "github",
            Set.of("SUPER_ADMIN"));
        // SUPER_ADMIN 短路判定
        assertThat(principal.platformRoles().contains("SUPER_ADMIN")).isTrue();
    }

    @Test
    void regularUser_doesNotHaveAdminRole() {
        var principal = new PlatformPrincipal(2L, "user", "u@b.com", null, "github",
            Set.of());
        assertThat(principal.platformRoles().contains("SKILL_ADMIN")).isFalse();
    }

    @Test
    void platformPrincipal_isSerializable() {
        var principal = new PlatformPrincipal(1L, "test", "t@t.com", null, "github",
            Set.of("AUDITOR"));
        // record 自动实现 Serializable
        assertThat(principal).isInstanceOf(java.io.Serializable.class);
    }
}
  • Step 3: 运行测试

Run: cd server && ./mvnw test -pl skillhub-auth -Dtest=RbacServiceTest -am

Expected: 3 tests PASS

  • Step 4: Commit
git add server/skillhub-auth/
git commit -m "feat(auth): add RBAC authorization service

- RbacService: platform role check with SUPER_ADMIN short-circuit
- Namespace role check with ordinal comparison
- Unit tests for role checks"

Task 14: Spring Security 配置 + CSRF + Session

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/config/SecurityConfig.java

  • Modify: server/skillhub-app/src/main/resources/application.yml (添加 OAuth2 和 Session 配置)

  • Modify: server/skillhub-app/src/main/resources/application-local.yml (添加 OAuth2 占位配置)

  • Step 1: 创建 SecurityConfig

// server/skillhub-auth/src/main/java/com/skillhub/auth/config/SecurityConfig.java
package com.skillhub.auth.config;

import com.skillhub.auth.oauth.CustomOAuth2UserService;
import com.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.skillhub.auth.token.ApiTokenAuthenticationFilter;
import com.skillhub.auth.token.ApiTokenService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2LoginSuccessHandler successHandler;
    private final ApiTokenService apiTokenService;

    public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
                          OAuth2LoginSuccessHandler successHandler,
                          ApiTokenService apiTokenService) {
        this.customOAuth2UserService = customOAuth2UserService;
        this.successHandler = successHandler;
        this.apiTokenService = apiTokenService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // CSRF: Cookie-to-Header 模式,CLI/compat API 豁免
        var csrfHandler = new CsrfTokenRequestAttributeHandler();
        csrfHandler.setCsrfRequestAttributeName(null);

        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(csrfHandler)
                .ignoringRequestMatchers("/api/v1/cli/**", "/api/**")
            )
            .authorizeHttpRequests(auth -> auth
                // 公开端点
                .requestMatchers(
                    "/api/v1/health",
                    "/api/v1/auth/providers",
                    "/api/v1/skills/**",
                    "/api/v1/namespaces/**",
                    "/actuator/health",
                    "/v3/api-docs/**",
                    "/swagger-ui/**",
                    "/.well-known/**"
                ).permitAll()
                // Admin API
                .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR")
                // 其余需认证
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
                .successHandler(successHandler)
            )
            .logout(logout -> logout
                .logoutUrl("/api/v1/auth/logout")
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .deleteCookies("SESSION")
            )
            .addFilterBefore(
                new ApiTokenAuthenticationFilter(apiTokenService),
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }
}
  • Step 2: 更新 application.yml 添加 OAuth2 和 Session 配置

在 server/skillhub-app/src/main/resources/application.yml 追加:

# 追加到 application.yml
spring:
  session:
    store-type: redis
    redis:
      namespace: skillhub:session
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder}
            client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder}
            scope: read:user,user:email
        provider:
          github:
            user-info-uri: https://api.github.com/user

skillhub:
  access-policy:
    mode: OPEN
  • Step 3: 更新 application-local.yml

在 server/skillhub-app/src/main/resources/application-local.yml 追加:

# 追加到 application-local.yml
spring:
  session:
    store-type: redis
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder}
            client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder}
  • Step 4: Commit
git add server/
git commit -m "feat(auth): add Spring Security config with OAuth2 + CSRF + Session

- SecurityConfig: OAuth2 login, CSRF Cookie-to-Header, CLI API exempt
- API Token filter before UsernamePasswordAuthenticationFilter
- Spring Session Redis configuration
- Public endpoints permit all, admin requires roles"

Task 15: MockAuthFilter 本地开发

Files:

  • Create: server/skillhub-auth/src/main/java/com/skillhub/auth/mock/MockAuthFilter.java

  • Step 1: 创建 MockAuthFilter

// server/skillhub-auth/src/main/java/com/skillhub/auth/mock/MockAuthFilter.java
package com.skillhub.auth.mock;

import com.skillhub.auth.rbac.PlatformPrincipal;
import com.skillhub.auth.repository.UserRoleBindingRepository;
import com.skillhub.domain.user.UserAccount;
import com.skillhub.domain.user.UserAccountRepository;
import com.skillhub.domain.user.UserStatus;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Set;
import java.util.stream.Collectors;

@Component
@Profile("local")
@Order(-100)
public class MockAuthFilter extends OncePerRequestFilter {

    private final UserAccountRepository userRepo;
    private final UserRoleBindingRepository roleBindingRepo;

    public MockAuthFilter(UserAccountRepository userRepo,
                          UserRoleBindingRepository roleBindingRepo) {
        this.userRepo = userRepo;
        this.roleBindingRepo = roleBindingRepo;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String mockUserId = request.getHeader("X-Mock-User-Id");
        if (mockUserId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            String userId = mockUserId;
            userRepo.findById(userId)
                .filter(UserAccount::isActive)
                .ifPresent(user -> {
                    Set<String> roles = roleBindingRepo.findByUserId(userId).stream()
                        .map(rb -> rb.getRole().getCode())
                        .collect(Collectors.toSet());
                    var principal = new PlatformPrincipal(
                        user.getId(), user.getDisplayName(), user.getEmail(),
                        user.getAvatarUrl(), "mock", roles
                    );
                    var authorities = roles.stream()
                        .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                        .toList();
                    var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
                    SecurityContextHolder.getContext().setAuthentication(auth);
                    request.getSession().setAttribute("platformPrincipal", principal);
                });
        }
        filterChain.doFilter(request, response);
    }
}
  • Step 2: Commit
git add server/skillhub-auth/
git commit -m "feat(auth): add MockAuthFilter for local development

- Activated only under 'local' profile via @Profile
- Reads X-Mock-User-Id header to simulate authenticated user
- Creates PlatformPrincipal and sets SecurityContext"

Task 16: AuthController + Token API

Files:

  • Create: server/skillhub-app/src/main/java/com/skillhub/controller/AuthController.java

  • Create: server/skillhub-app/src/main/java/com/skillhub/controller/TokenController.java

  • Step 1: 创建 AuthController

// server/skillhub-app/src/main/java/com/skillhub/controller/AuthController.java
package com.skillhub.controller;

import com.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    private final ClientRegistrationRepository clientRegistrationRepository;

    public AuthController(ClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @GetMapping("/me")
    public ResponseEntity<Map<String, Object>> me(HttpSession session) {
        PlatformPrincipal principal = (PlatformPrincipal) session.getAttribute("platformPrincipal");
        if (principal == null) {
            return ResponseEntity.status(401).build();
        }
        return ResponseEntity.ok(Map.of(
            "userId", principal.userId(),
            "displayName", principal.displayName(),
            "email", principal.email() != null ? principal.email() : "",
            "avatarUrl", principal.avatarUrl() != null ? principal.avatarUrl() : "",
            "oauthProvider", principal.oauthProvider(),
            "platformRoles", principal.platformRoles()
        ));
    }

    @GetMapping("/providers")
    public ResponseEntity<Map<String, Object>> providers() {
        // 一期只有 GitHub,后续可动态读取 ClientRegistrationRepository
        var github = Map.of(
            "id", "github",
            "name", "GitHub",
            "authorizationUrl", "/oauth2/authorization/github"
        );
        return ResponseEntity.ok(Map.of("data", List.of(github)));
    }
}
  • Step 2: 创建 TokenController
// server/skillhub-app/src/main/java/com/skillhub/controller/TokenController.java
package com.skillhub.controller;

import com.skillhub.auth.rbac.PlatformPrincipal;
import com.skillhub.auth.token.ApiTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/tokens")
public class TokenController {

    private final ApiTokenService apiTokenService;

    public TokenController(ApiTokenService apiTokenService) {
        this.apiTokenService = apiTokenService;
    }

    @PostMapping
    public ResponseEntity<Map<String, Object>> create(
            @AuthenticationPrincipal PlatformPrincipal principal,
            @RequestBody Map<String, Object> body) {
        String name = (String) body.get("name");
        @SuppressWarnings("unchecked")
        List<String> scopes = (List<String>) body.getOrDefault("scopes",
            List.of("skill:read", "skill:publish"));
        Integer expiryDays = (Integer) body.get("expiryDays");
        LocalDateTime expiresAt = expiryDays != null
            ? LocalDateTime.now().plusDays(expiryDays) : null;

        String rawToken = apiTokenService.createToken(
            principal.userId(), name, scopes, expiresAt);

        return ResponseEntity.ok(Map.of("token", rawToken));
    }

    @GetMapping
    public ResponseEntity<?> list(@AuthenticationPrincipal PlatformPrincipal principal) {
        var tokens = apiTokenService.listByUser(principal.userId());
        var result = tokens.stream().map(t -> Map.of(
            "id", t.getId(),
            "name", t.getName(),
            "tokenPrefix", t.getTokenPrefix(),
            "createdAt", t.getCreatedAt().toString(),
            "expiresAt", t.getExpiresAt() != null ? t.getExpiresAt().toString() : "",
            "lastUsedAt", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : ""
        )).toList();
        return ResponseEntity.ok(Map.of("data", result));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> revoke(
            @AuthenticationPrincipal PlatformPrincipal principal,
            @PathVariable Long id) {
        apiTokenService.revoke(id, principal.userId());
        return ResponseEntity.noContent().build();
    }
}
  • Step 3: Commit
git add server/skillhub-app/
git commit -m "feat: add AuthController and TokenController

- GET /api/v1/auth/me: return current user info from session
- GET /api/v1/auth/providers: return available OAuth providers
- POST/GET/DELETE /api/v1/tokens: create, list, revoke API tokens"

Task 17: 全局异常处理

Files:

  • Create: server/skillhub-app/src/main/java/com/skillhub/exception/ErrorResponse.java

  • Create: server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java

  • Step 1: 创建 ErrorResponse

// server/skillhub-app/src/main/java/com/skillhub/exception/ErrorResponse.java
package com.skillhub.exception;

import java.time.Instant;

public record ErrorResponse(
    int status,
    String error,
    String message,
    String requestId,
    Instant timestamp
) {
    public ErrorResponse(int status, String error, String message, String requestId) {
        this(status, error, message, requestId, Instant.now());
    }
}
  • Step 2: 创建 GlobalExceptionHandler
// server/skillhub-app/src/main/java/com/skillhub/exception/GlobalExceptionHandler.java
package com.skillhub.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex,
                                                           HttpServletRequest request) {
        String requestId = (String) request.getAttribute("requestId");
        return ResponseEntity.badRequest().body(
            new ErrorResponse(400, "Bad Request", ex.getMessage(), requestId));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex,
                                                        HttpServletRequest request) {
        String requestId = (String) request.getAttribute("requestId");
        log.error("Unhandled exception [requestId={}]", requestId, ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
            new ErrorResponse(500, "Internal Server Error",
                "An unexpected error occurred", requestId));
    }
}
  • Step 3: Commit
git add server/skillhub-app/
git commit -m "feat: add global exception handler

- ErrorResponse record with status, error, message, requestId, timestamp
- GlobalExceptionHandler: 400 for IllegalArgumentException, 500 catch-all
- Logs unhandled exceptions with requestId"

Task 18: Flyway 种子数据(RBAC 预置角色和权限)

Files:

  • Create: server/skillhub-app/src/main/resources/db/migration/V2__seed_rbac.sql

  • Step 1: 创建种子数据迁移脚本

-- server/skillhub-app/src/main/resources/db/migration/V2__seed_rbac.sql

-- 预置平台角色
INSERT INTO role (code, name, description, is_system) VALUES
('SUPER_ADMIN', '平台超管', '拥有所有权限', TRUE),
('SKILL_ADMIN', '技能治理', '全局空间审核、提升审核、隐藏/撤回', TRUE),
('USER_ADMIN', '用户治理', '准入审批、封禁/解封、角色分配', TRUE),
('AUDITOR', '审计员', '查看审计日志', TRUE);

-- 预置权限
INSERT INTO permission (code, name, group_code) VALUES
('review:approve', '审核通过', 'review'),
('review:reject', '审核拒绝', 'review'),
('skill:manage', '技能管理', 'skill'),
('skill:publish', '技能发布', 'skill'),
('skill:delete', '技能删除', 'skill'),
('promotion:approve', '提升审核', 'promotion'),
('user:manage', '用户管理', 'user'),
('user:approve', '用户审批', 'user'),
('audit:read', '审计查看', 'audit');

-- 角色-权限绑定
-- SKILL_ADMIN
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id FROM role r, permission p
WHERE r.code = 'SKILL_ADMIN' AND p.code IN ('review:approve', 'review:reject', 'skill:manage', 'promotion:approve');

-- USER_ADMIN
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id FROM role r, permission p
WHERE r.code = 'USER_ADMIN' AND p.code IN ('user:manage', 'user:approve');

-- AUDITOR
INSERT INTO role_permission (role_id, permission_id)
SELECT r.id, p.id FROM role r, permission p
WHERE r.code = 'AUDITOR' AND p.code = 'audit:read';

-- 预置 @global 命名空间
INSERT INTO namespace (slug, display_name, description, visibility, status)
VALUES ('global', 'Global', '平台级公共空间', 'PUBLIC', 'ACTIVE');

-- 预置种子用户(本地开发用,SUPER_ADMIN)
INSERT INTO user_account (display_name, email, status)
VALUES ('Admin', 'admin@skillhub.dev', 'ACTIVE');

INSERT INTO user_role_binding (user_id, role_id)
SELECT u.id, r.id FROM user_account u, role r
WHERE u.email = 'admin@skillhub.dev' AND r.code = 'SUPER_ADMIN';
  • Step 2: Commit
git add server/skillhub-app/src/main/resources/db/migration/V2__seed_rbac.sql
git commit -m "feat: add RBAC seed data migration

- Preset 4 platform roles: SUPER_ADMIN, SKILL_ADMIN, USER_ADMIN, AUDITOR
- Preset 9 permissions with role-permission bindings
- Create @global namespace
- Create seed admin user for local development"

Chunk 2 验收检查

运行以下命令验证 Chunk 2 完成:

# 1. 确保依赖服务运行
make dev

# 2. 运行所有测试
cd server && ./mvnw test
# Expected: BUILD SUCCESS, AccessPolicyTest + ApiTokenServiceTest + RbacServiceTest 全部 PASS

# 3. 启动应用
./mvnw spring-boot:run -Dspring-boot.run.profiles=local

# 4. 验证 MockAuth + /api/v1/auth/me
curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/auth/me
# Expected: {"userId":1,"displayName":"Admin","email":"admin@skillhub.dev",...}

# 5. 验证未登录返回 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/me
# Expected: 401

# 6. 验证 /api/v1/auth/providers
curl http://localhost:8080/api/v1/auth/providers
# Expected: {"data":[{"id":"github","name":"GitHub","authorizationUrl":"/oauth2/authorization/github"}]}

# 7. 验证 Token 创建
curl -X POST -H "X-Mock-User-Id: 1" -H "Content-Type: application/json" \
  -d '{"name":"test-token","scopes":["skill:read"]}' \
  http://localhost:8080/api/v1/tokens
# Expected: {"token":"ask_..."}

# 8. 验证 Token 列表
curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/tokens
# Expected: {"data":[...]}

# 9. 验证 RBAC 种子数据
docker compose exec postgres psql -U skillhub -d skillhub \
  -c "SELECT r.code, array_agg(p.code) FROM role r JOIN role_permission rp ON r.id=rp.role_id JOIN permission p ON p.id=rp.permission_id GROUP BY r.code;"
# Expected: 4 roles with their permissions

# 10. 停止应用和服务
make dev-down

Chunk 2 产出:完整认证链路(OAuth2 + AccessPolicy + IdentityBinding + Session + Token + RBAC + MockAuth + CSRF)。

Chunk 3: 前端骨架 + 登录集成

本块建立 React 前端工程,集成 TanStack Router/Query、shadcn/ui、openapi-fetch 类型生成管线,实现 OAuth 登录流程和路由守卫。

文件结构映射

web/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── postcss.config.js
├── components.json                    # shadcn/ui 配置
├── index.html
├── src/
│   ├── main.tsx
│   ├── app/
│   │   ├── router.tsx                 # TanStack Router 配置
│   │   ├── providers.tsx              # QueryClient + Router Provider
│   │   └── layout.tsx                 # 全局布局(Header + Main)
│   ├── pages/
│   │   ├── home.tsx                   # 首页
│   │   ├── login.tsx                  # 登录页
│   │   └── dashboard.tsx              # Dashboard(需登录)
│   ├── features/
│   │   └── auth/
│   │       ├── use-auth.ts            # 登录态 hook
│   │       ├── auth-guard.tsx         # 路由守卫
│   │       └── login-button.tsx       # OAuth 登录按钮
│   ├── shared/
│   │   └── ui/                        # shadcn/ui 组件
│   └── api/
│       ├── client.ts                  # openapi-fetch 客户端
│       └── generated/                 # openapi-typescript 生成的类型
│           └── schema.d.ts
├── Dockerfile
└── nginx.conf

Task 19: 初始化前端工程

Files:

  • Create: web/package.json

  • Create: web/tsconfig.json

  • Create: web/vite.config.ts

  • Create: web/index.html

  • Create: web/src/main.tsx

  • Step 1: 初始化 Vite + React + TypeScript 项目

cd web  # 如果 web/ 不存在则先 mkdir web && cd web
pnpm create vite . --template react-ts

或手动创建 package.json:

{
  "name": "skillhub-web",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@tanstack/react-router": "^1.95.0",
    "@tanstack/react-query": "^5.64.0",
    "openapi-fetch": "^0.13.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.7.0",
    "vite": "^6.1.0",
    "tailwindcss": "^3.4.0",
    "postcss": "^8.4.0",
    "autoprefixer": "^10.4.0",
    "openapi-typescript": "^7.6.0"
  }
}
  • Step 2: 安装依赖

Run: cd web && pnpm install

Expected: 依赖安装成功

  • Step 3: 创建 tsconfig.json
// web/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}
  • Step 4: 创建 vite.config.ts
// web/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
      '/oauth2': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
      '/login': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})
  • Step 4: 创建 index.html 和 main.tsx
<!-- web/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SkillHub</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
// web/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './app/providers'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
  • Step 5: Commit
git add web/
git commit -m "feat(web): initialize Vite + React + TypeScript frontend

- package.json with React 19, TanStack Router/Query, openapi-fetch
- Vite config with API proxy to backend
- index.html and main.tsx entry point"

Task 20: Tailwind CSS + shadcn/ui 配置

Files:

  • Create: web/tailwind.config.ts

  • Create: web/postcss.config.js

  • Create: web/src/index.css

  • Create: web/components.json

  • Step 1: 配置 Tailwind CSS

// web/tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  darkMode: ['class'],
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
}
export default config
// web/postcss.config.js
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}
/* web/src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
  • Step 2: 初始化 shadcn/ui

Run: cd web && pnpm dlx shadcn@latest init

选择默认配置,或手动创建 components.json:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "src/index.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/shared/ui",
    "utils": "@/shared/ui/lib/utils"
  }
}
  • Step 3: 添加 Button 组件(验证 shadcn/ui 工作)

Run: cd web && pnpm dlx shadcn@latest add button

Expected: src/shared/ui/button.tsx 生成成功

  • Step 4: Commit
git add web/
git commit -m "feat(web): add Tailwind CSS and shadcn/ui configuration

- Tailwind config with dark mode support
- PostCSS config
- shadcn/ui initialized with Button component"

Task 21: TanStack Router 路由骨架

Files:

  • Create: web/src/app/router.tsx

  • Create: web/src/app/providers.tsx

  • Create: web/src/app/layout.tsx

  • Create: web/src/pages/home.tsx

  • Create: web/src/pages/login.tsx

  • Create: web/src/pages/dashboard.tsx

  • Step 1: 创建路由配置

// web/src/app/router.tsx
import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router'
import { Layout } from './layout'
import { HomePage } from '../pages/home'
import { LoginPage } from '../pages/login'
import { DashboardPage } from '../pages/dashboard'

const rootRoute = createRootRoute({
  component: Layout,
})

const homeRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: HomePage,
})

const loginRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/login',
  component: LoginPage,
})

const dashboardRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/dashboard',
  component: DashboardPage,
})

const routeTree = rootRoute.addChildren([homeRoute, loginRoute, dashboardRoute])

export const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}
  • Step 2: 创建 Providers
// web/src/app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      retry: 1,
    },
  },
})

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  )
}
  • Step 3: 创建 Layout
// web/src/app/layout.tsx
import { Outlet, Link } from '@tanstack/react-router'
import { useAuth } from '../features/auth/use-auth'

export function Layout() {
  const { user, isLoading } = useAuth()

  return (
    <div className="min-h-screen bg-background">
      <header className="border-b">
        <div className="container mx-auto flex h-14 items-center justify-between px-4">
          <Link to="/" className="text-lg font-semibold">SkillHub</Link>
          <nav className="flex items-center gap-4">
            {isLoading ? null : user ? (
              <>
                <Link to="/dashboard" className="text-sm">Dashboard</Link>
                <span className="text-sm text-muted-foreground">{user.displayName}</span>
              </>
            ) : (
              <Link to="/login" className="text-sm">登录</Link>
            )}
          </nav>
        </div>
      </header>
      <main className="container mx-auto px-4 py-6">
        <Outlet />
      </main>
    </div>
  )
}
  • Step 4: 创建页面组件
// web/src/pages/home.tsx
export function HomePage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">SkillHub</h1>
      <p className="mt-2 text-muted-foreground">技能注册中心</p>
    </div>
  )
}
// web/src/pages/login.tsx
import { LoginButton } from '../features/auth/login-button'

export function LoginPage() {
  return (
    <div className="flex min-h-[60vh] items-center justify-center">
      <div className="w-full max-w-sm space-y-6 text-center">
        <h1 className="text-2xl font-bold">登录 SkillHub</h1>
        <LoginButton />
      </div>
    </div>
  )
}
// web/src/pages/dashboard.tsx
import { useAuth } from '../features/auth/use-auth'
import { AuthGuard } from '../features/auth/auth-guard'

export function DashboardPage() {
  const { user } = useAuth()

  return (
    <AuthGuard>
      <div>
        <h1 className="text-2xl font-bold">Dashboard</h1>
        <p className="mt-2">欢迎, {user?.displayName}</p>
      </div>
    </AuthGuard>
  )
}
  • Step 5: Commit
git add web/src/
git commit -m "feat(web): add TanStack Router with page skeleton

- Root layout with header navigation
- Home, Login, Dashboard pages
- Router config with type-safe routes"

Task 22: Auth Hook + 登录按钮 + 路由守卫

Files:

  • Create: web/src/features/auth/use-auth.ts

  • Create: web/src/features/auth/login-button.tsx

  • Create: web/src/features/auth/auth-guard.tsx

  • Step 1: 创建 useAuth hook

// web/src/features/auth/use-auth.ts
import { useQuery } from '@tanstack/react-query'

interface User {
  userId: string
  displayName: string
  email: string
  avatarUrl: string
  oauthProvider: string
  platformRoles: string[]
}

export function useAuth() {
  const { data: user, isLoading, error } = useQuery<User>({
    queryKey: ['auth', 'me'],
    queryFn: async () => {
      const res = await fetch('/api/v1/auth/me')
      if (res.status === 401) return null
      if (!res.ok) throw new Error('Failed to fetch user')
      return res.json()
    },
    retry: false,
    staleTime: 5 * 60 * 1000,
  })

  return {
    user: user ?? null,
    isLoading,
    isAuthenticated: !!user,
    hasRole: (role: string) => user?.platformRoles?.includes(role) ?? false,
  }
}
  • Step 2: 创建 LoginButton
// web/src/features/auth/login-button.tsx
import { useQuery } from '@tanstack/react-query'
import { Button } from '../../shared/ui/button'

interface Provider {
  id: string
  name: string
  authorizationUrl: string
}

export function LoginButton() {
  const { data } = useQuery<{ data: Provider[] }>({
    queryKey: ['auth', 'providers'],
    queryFn: async () => {
      const res = await fetch('/api/v1/auth/providers')
      if (!res.ok) throw new Error('Failed to fetch providers')
      return res.json()
    },
  })

  const providers = data?.data ?? []

  return (
    <div className="space-y-3">
      {providers.map((p) => (
        <Button
          key={p.id}
          className="w-full"
          onClick={() => { window.location.href = p.authorizationUrl }}
        >
          使用 {p.name} 登录
        </Button>
      ))}
    </div>
  )
}
  • Step 3: 创建 AuthGuard
// web/src/features/auth/auth-guard.tsx
import { useNavigate } from '@tanstack/react-router'
import { useAuth } from './use-auth'
import { useEffect } from 'react'

export function AuthGuard({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useAuth()
  const navigate = useNavigate()

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      navigate({ to: '/login' })
    }
  }, [isLoading, isAuthenticated, navigate])

  if (isLoading) {
    return <div className="flex justify-center py-8">加载中...</div>
  }

  if (!isAuthenticated) return null

  return <>{children}</>
}
  • Step 4: Commit
git add web/src/features/
git commit -m "feat(web): add auth hook, login button, and route guard

- useAuth: fetch /api/v1/auth/me with TanStack Query
- LoginButton: dynamic OAuth provider buttons from /api/v1/auth/providers
- AuthGuard: redirect to /login if not authenticated"

Task 23: openapi-fetch 客户端生成管线

Files:

  • Create: web/src/api/client.ts

  • Step 1: 创建 API 客户端

// web/src/api/client.ts
import createClient from 'openapi-fetch'

// 一期先用手动类型,后续通过 openapi-typescript 自动生成
export const api = createClient({ baseUrl: '/' })

// 便捷方法
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  })
  if (!res.ok) {
    throw new Error(`API error: ${res.status}`)
  }
  return res.json()
}
  • Step 2: 验证 generate-api 脚本可用

在后端运行时执行:

Run: cd web && pnpm run generate-api

Expected: 如果后端运行中,生成 src/api/generated/schema.d.ts;如果后端未运行,报连接错误(预期行为)

  • Step 3: Commit
git add web/src/api/
git commit -m "feat(web): add openapi-fetch API client

- createClient wrapper for type-safe API calls
- generate-api script for openapi-typescript code generation"

Task 24: 前端 Dockerfile + nginx.conf

Files:

  • Create: web/Dockerfile

  • Create: web/nginx.conf

  • Step 1: 创建 nginx.conf

# web/nginx.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip 压缩
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;
    gzip_min_length 1000;

    # SPA 路由:所有非文件请求回退到 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # API 反向代理
    location /api/ {
        proxy_pass http://server:8080;
        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;
    }

    # OAuth2 反向代理
    location /oauth2/ {
        proxy_pass http://server:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /login/oauth2/ {
        proxy_pass http://server:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Well-known
    location /.well-known/ {
        proxy_pass http://server:8080;
    }

    # 静态资源缓存
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # 健康检查
    location /nginx-health {
        return 200 'ok';
        add_header Content-Type text/plain;
    }
}
  • Step 2: 创建 Dockerfile
# web/Dockerfile
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

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=10s --timeout=3s \
  CMD wget -qO- http://localhost/nginx-health || exit 1
  • Step 3: Commit
git add web/Dockerfile web/nginx.conf
git commit -m "feat(web): add Dockerfile and nginx config

- Multi-stage build: pnpm build → nginx:alpine
- Nginx SPA routing with API reverse proxy
- Static asset caching, health check endpoint"

Task 25: 更新 Makefile 添加前端命令

Files:

  • Modify: Makefile

  • Step 1: 追加前端相关 target

在 Makefile 末尾追加:

# --- Frontend ---
.PHONY: web-install web-dev web-build generate-api

web-install:
	cd web && pnpm install

web-dev:
	@echo "Run manually: cd web && pnpm dev"

web-build:
	cd web && pnpm build

generate-api:
	cd web && pnpm run generate-api
  • Step 2: Commit
git add Makefile
git commit -m "feat: add frontend targets to Makefile

- web-install, web-build, generate-api targets
- web-dev prints manual run instruction (long-running process)"

Task 26: docker-compose.prod.yml 完整部署

Files:

  • Create: docker-compose.prod.yml

  • Step 1: 创建生产部署 compose 文件

# docker-compose.prod.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: skillhub
      POSTGRES_USER: skillhub
      POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U skillhub"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  minio:
    image: minio/minio:latest
    environment:
      MINIO_ROOT_USER: ${MINIO_USER:-minioadmin}
      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      timeout: 5s
      retries: 5

  server:
    build:
      context: ./server
      dockerfile: Dockerfile
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub
      SPRING_DATASOURCE_USERNAME: skillhub
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
      SPRING_DATA_REDIS_HOST: redis
      OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID}
      OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s

  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      server:
        condition: service_healthy

volumes:
  postgres_data:
  minio_data:
  • Step 2: 创建后端 Dockerfile
# server/Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests -B

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/skillhub-app/target/*.jar app.jar
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
  • Step 3: 更新 Makefile 添加 deploy 命令

在 Makefile 追加:

# --- Deploy ---
.PHONY: deploy deploy-down

deploy:
	docker compose -f docker-compose.prod.yml up -d --build

deploy-down:
	docker compose -f docker-compose.prod.yml down
  • Step 4: Commit
git add docker-compose.prod.yml server/Dockerfile Makefile
git commit -m "feat: add production Docker Compose and backend Dockerfile

- docker-compose.prod.yml: full stack deployment
- server/Dockerfile: Maven multi-stage build → JRE alpine
- Makefile deploy/deploy-down targets"

Chunk 3 验收检查

运行以下命令验证 Chunk 3 完成:

# 1. 确保后端依赖运行
make dev

# 2. 安装前端依赖
make web-install
# Expected: 依赖安装成功

# 3. 构建前端
make web-build
# Expected: dist/ 目录生成

# 4. 启动后端
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local &

# 5. 启动前端开发服务器(手动)
cd web && pnpm dev
# Expected: http://localhost:3000 可访问

# 6. 验证首页
# 浏览器打开 http://localhost:3000
# Expected: 显示 "SkillHub" 标题和 "登录" 链接

# 7. 验证登录页
# 浏览器打开 http://localhost:3000/login
# Expected: 显示 "使用 GitHub 登录" 按钮

# 8. 验证 Dashboard 路由守卫
# 浏览器打开 http://localhost:3000/dashboard
# Expected: 未登录时重定向到 /login

# 9. 验证 MockAuth + Dashboard
curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/auth/me
# Expected: 返回用户信息(前端通过 proxy 也可访问)

# 10. 验证 OpenAPI 类型生成
make generate-api
# Expected: web/src/api/generated/schema.d.ts 生成(需后端运行中)

# 11. 停止所有服务
make dev-down

Chunk 3 产出:可运行的前端应用 + OAuth 登录流程 + 路由守卫 + API 类型生成管线 + 生产部署配置。


Phase 1 整体验收检查

对照 10-delivery-roadmap.md Phase 1 验收标准:

验收项 验证方式 对应 Task
前后端能跑 make dev + 后端启动 + pnpm dev 前端启动 Task 1-7, 19-24
GitHub OAuth 登录可用 配置真实 GitHub OAuth App 后完整登录流程 Task 10-11, 14, 22
AccessPolicy 准入策略生效 切换 skillhub.access-policy.mode 验证不同策略 Task 10
/api/v1/auth/me 可用 curl 验证已登录/未登录响应 Task 16
Token 可用 创建 Token → Bearer 认证 → /api/v1/cli/whoami Task 12, 16
OpenAPI spec 可访问 curl http://localhost:8080/v3/api-docs Task 6
CSRF 防护 Cookie-to-Header 模式,CLI API 豁免 Task 14
MockAuthFilter X-Mock-User-Id Header 模拟登录 Task 15
RBAC 基础 种子数据 4 角色 + 9 权限 + 角色判定 Task 13, 18
全局异常处理 + requestId 错误响应包含 requestId Task 5, 17
前端登录流程 登录页 → GitHub 按钮 → 回调 → Dashboard Task 21, 22
路由守卫 未登录访问 Dashboard 重定向到 /login Task 22
openapi-fetch 管线 make generate-api 生成类型文件 Task 23
Docker 完整部署 make deploy 构建并启动全栈 Task 24, 26

完整端到端验证流程

# 1. 启动本地开发环境
make dev
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local &
cd web && pnpm dev &

# 2. MockAuth 验证
curl -H "X-Mock-User-Id: 1" http://localhost:8080/api/v1/auth/me
# → 200, 返回 Admin 用户信息

# 3. Token 全流程
TOKEN=$(curl -s -X POST -H "X-Mock-User-Id: 1" \
  -H "Content-Type: application/json" \
  -d '{"name":"e2e-test"}' \
  http://localhost:8080/api/v1/tokens | jq -r '.token')
echo $TOKEN
# → ask_...

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/auth/me
# → 200, 返回用户信息

# 4. 未认证访问
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/me
# → 401

# 5. OpenAPI
curl -s http://localhost:8080/v3/api-docs | jq '.info.title'
# → "SkillHub API"

# 6. 前端页面
# 浏览器 http://localhost:3000 → 首页
# 浏览器 http://localhost:3000/login → 登录页
# 浏览器 http://localhost:3000/dashboard → 重定向到 /login

# 7. 清理
make dev-down

Plan complete. 共 26 个 Task,3 个 Chunk,覆盖 Phase 1 全部验收标准。