diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..619ded06
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,29 @@
+.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"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..63b0e8e8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,47 @@
+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:
diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml
index fb33954d..2cd5a376 100644
--- a/server/skillhub-app/pom.xml
+++ b/server/skillhub-app/pom.xml
@@ -70,6 +70,11 @@
spring-boot-starter-test
test
+
+ com.h2database
+ h2
+ test
+
diff --git a/server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java
similarity index 100%
rename from server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java
rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java
new file mode 100644
index 00000000..e45f3c7f
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java
@@ -0,0 +1,25 @@
+package com.iflytek.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")
+ ));
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java
new file mode 100644
index 00000000..29b14f7e
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java
@@ -0,0 +1,22 @@
+package com.iflytek.skillhub.config;
+
+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;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/v1/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
+ .anyRequest().authenticated()
+ );
+ return http.build();
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java
new file mode 100644
index 00000000..b1225f8c
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java
@@ -0,0 +1,17 @@
+package com.iflytek.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 health() {
+ return Map.of("status", "UP");
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java
new file mode 100644
index 00000000..194b6861
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java
@@ -0,0 +1,7 @@
+package com.iflytek.skillhub.dto;
+
+public record ErrorResponse(
+ int status,
+ String error,
+ String message
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
new file mode 100644
index 00000000..a8962949
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
@@ -0,0 +1,27 @@
+package com.iflytek.skillhub.exception;
+
+import com.iflytek.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 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);
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java
new file mode 100644
index 00000000..e9dc6422
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java
@@ -0,0 +1,40 @@
+package com.iflytek.skillhub.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.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);
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql b/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql
new file mode 100644
index 00000000..86b4d9b4
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql
@@ -0,0 +1,164 @@
+-- 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 BIGINT,
+ 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 BIGINT 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 BIGINT NOT NULL,
+ user_id BIGINT 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 BIGINT 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 BIGINT 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 BIGINT 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 BIGINT 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');
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java
new file mode 100644
index 00000000..49dd3083
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java
@@ -0,0 +1,28 @@
+package com.iflytek.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.context.ActiveProfiles;
+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
+@ActiveProfiles("test")
+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"));
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java
new file mode 100644
index 00000000..127059a4
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java
@@ -0,0 +1,37 @@
+package com.iflytek.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.context.ActiveProfiles;
+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
+@ActiveProfiles("test")
+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));
+ }
+}
diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml
new file mode 100644
index 00000000..82988572
--- /dev/null
+++ b/server/skillhub-app/src/test/resources/application-test.yml
@@ -0,0 +1,20 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+ jpa:
+ hibernate:
+ ddl-auto: none
+ database-platform: org.hibernate.dialect.H2Dialect
+ flyway:
+ enabled: false
+ data:
+ redis:
+ host: localhost
+ port: 6379
+ autoconfigure:
+ exclude:
+ - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
+ - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration
diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml
index 44f8bf7a..52785507 100644
--- a/server/skillhub-auth/pom.xml
+++ b/server/skillhub-auth/pom.xml
@@ -23,6 +23,10 @@
org.springframework.boot
spring-boot-starter-oauth2-client
+
+ org.springframework.boot
+ spring-boot-starter-web
+
org.springframework.boot
spring-boot-starter-data-jpa
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java
new file mode 100644
index 00000000..0191c685
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java
@@ -0,0 +1,77 @@
+package com.iflytek.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 Long 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(Long 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(); }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java
new file mode 100644
index 00000000..8a25a0bc
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java
@@ -0,0 +1,66 @@
+package com.iflytek.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 Long 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(Long 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 void setUserId(Long userId) { this.userId = userId; }
+ public String getProviderCode() { return providerCode; }
+ public void setProviderCode(String providerCode) { this.providerCode = providerCode; }
+ public String getSubject() { return subject; }
+ public void setSubject(String subject) { this.subject = 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; }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java
new file mode 100644
index 00000000..8f613859
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java
@@ -0,0 +1,24 @@
+package com.iflytek.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; }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java
new file mode 100644
index 00000000..6c50924b
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java
@@ -0,0 +1,35 @@
+package com.iflytek.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; }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java
new file mode 100644
index 00000000..71e71aee
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java
@@ -0,0 +1,40 @@
+package com.iflytek.skillhub.auth.entity;
+
+import jakarta.persistence.*;
+import java.io.Serializable;
+import java.util.Objects;
+
+@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;
+
+ public RolePermissionId() {}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof RolePermissionId that)) return false;
+ return Objects.equals(roleId, that.roleId) && Objects.equals(permissionId, that.permissionId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(roleId, permissionId);
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java
new file mode 100644
index 00000000..e79db0cd
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java
@@ -0,0 +1,37 @@
+package com.iflytek.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 Long userId;
+
+ @ManyToOne(fetch = FetchType.EAGER)
+ @JoinColumn(name = "role_id", nullable = false)
+ private Role role;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ protected UserRoleBinding() {}
+
+ public UserRoleBinding(Long userId, Role role) {
+ this.userId = userId;
+ this.role = role;
+ }
+
+ @PrePersist
+ void prePersist() { this.createdAt = LocalDateTime.now(); }
+
+ public Long getId() { return id; }
+ public Long getUserId() { return userId; }
+ public Role getRole() { return role; }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java
new file mode 100644
index 00000000..658be587
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java
@@ -0,0 +1,69 @@
+package com.iflytek.skillhub.auth.identity;
+
+import com.iflytek.skillhub.auth.entity.IdentityBinding;
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
+import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
+import com.iflytek.skillhub.domain.user.UserAccount;
+import com.iflytek.skillhub.domain.user.UserAccountRepository;
+import com.iflytek.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(user.getId(), claims.provider(), claims.subject(), claims.providerLogin());
+ bindingRepo.save(binding);
+ }
+
+ Set 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
+ );
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java
new file mode 100644
index 00000000..151168f0
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java
@@ -0,0 +1,67 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import com.iflytek.skillhub.auth.identity.IdentityBindingService;
+import com.iflytek.skillhub.auth.policy.AccessDecision;
+import com.iflytek.skillhub.auth.policy.AccessPolicy;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.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.DefaultOAuth2User;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+public class CustomOAuth2UserService implements OAuth2UserService {
+
+ private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
+ private final Map extractors;
+ private final AccessPolicy accessPolicy;
+ private final IdentityBindingService identityBindingService;
+
+ public CustomOAuth2UserService(List 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);
+
+ var attrs = new HashMap<>(oAuth2User.getAttributes());
+ attrs.put("platformPrincipal", principal);
+
+ return new DefaultOAuth2User(oAuth2User.getAuthorities(), attrs, "login");
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java
new file mode 100644
index 00000000..a79bc367
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java
@@ -0,0 +1,24 @@
+package com.iflytek.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 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
+ );
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java
new file mode 100644
index 00000000..083c9966
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java
@@ -0,0 +1,32 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import jakarta.servlet.ServletException;
+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.SavedRequestAwareAuthenticationSuccessHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
+
+ public OAuth2LoginSuccessHandler() {
+ setDefaultTargetUrl("/");
+ }
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) throws IOException, 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);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java
new file mode 100644
index 00000000..679d1a39
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java
@@ -0,0 +1,12 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import java.util.Map;
+
+public record OAuthClaims(
+ String provider,
+ String subject,
+ String email,
+ boolean emailVerified,
+ String providerLogin,
+ Map extra
+) {}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java
new file mode 100644
index 00000000..d6e347df
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java
@@ -0,0 +1,8 @@
+package com.iflytek.skillhub.auth.oauth;
+
+import org.springframework.security.oauth2.core.user.OAuth2User;
+
+public interface OAuthClaimsExtractor {
+ String getProvider();
+ OAuthClaims extract(OAuth2User oAuth2User);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java
new file mode 100644
index 00000000..1aea1213
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java
@@ -0,0 +1,5 @@
+package com.iflytek.skillhub.auth.policy;
+
+public enum AccessDecision {
+ ALLOW, DENY, PENDING_APPROVAL
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java
new file mode 100644
index 00000000..f2049308
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java
@@ -0,0 +1,7 @@
+package com.iflytek.skillhub.auth.policy;
+
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+
+public interface AccessPolicy {
+ AccessDecision evaluate(OAuthClaims claims);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java
new file mode 100644
index 00000000..79602966
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java
@@ -0,0 +1,31 @@
+package com.iflytek.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 allowedEmailDomains = List.of();
+ private List allowedProviders = List.of();
+ private List 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 d) { this.allowedEmailDomains = d; }
+ public void setAllowedProviders(List p) { this.allowedProviders = p; }
+ public void setWhitelistedSubjects(List s) { this.whitelistedSubjects = s; }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java
new file mode 100644
index 00000000..fe9d442f
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java
@@ -0,0 +1,20 @@
+package com.iflytek.skillhub.auth.policy;
+
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+import java.util.Set;
+
+public class EmailDomainAccessPolicy implements AccessPolicy {
+ private final Set allowedDomains;
+
+ public EmailDomainAccessPolicy(Set 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;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java
new file mode 100644
index 00000000..c0ef33ba
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java
@@ -0,0 +1,10 @@
+package com.iflytek.skillhub.auth.policy;
+
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+
+public class OpenAccessPolicy implements AccessPolicy {
+ @Override
+ public AccessDecision evaluate(OAuthClaims claims) {
+ return AccessDecision.ALLOW;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java
new file mode 100644
index 00000000..2457f123
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java
@@ -0,0 +1,18 @@
+package com.iflytek.skillhub.auth.policy;
+
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+import java.util.Set;
+
+public class ProviderAllowlistAccessPolicy implements AccessPolicy {
+ private final Set allowedProviders;
+
+ public ProviderAllowlistAccessPolicy(Set allowedProviders) {
+ this.allowedProviders = allowedProviders;
+ }
+
+ @Override
+ public AccessDecision evaluate(OAuthClaims claims) {
+ return allowedProviders.contains(claims.provider())
+ ? AccessDecision.ALLOW : AccessDecision.DENY;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java
new file mode 100644
index 00000000..daf6acaf
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java
@@ -0,0 +1,19 @@
+package com.iflytek.skillhub.auth.policy;
+
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+import java.util.Set;
+
+public class SubjectWhitelistAccessPolicy implements AccessPolicy {
+ private final Set whitelistedSubjects;
+
+ public SubjectWhitelistAccessPolicy(Set whitelistedSubjects) {
+ this.whitelistedSubjects = whitelistedSubjects;
+ }
+
+ @Override
+ public AccessDecision evaluate(OAuthClaims claims) {
+ String key = claims.provider() + ":" + claims.subject();
+ return whitelistedSubjects.contains(key)
+ ? AccessDecision.ALLOW : AccessDecision.DENY;
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java
new file mode 100644
index 00000000..6891d87d
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java
@@ -0,0 +1,13 @@
+package com.iflytek.skillhub.auth.rbac;
+
+import java.io.Serializable;
+import java.util.Set;
+
+public record PlatformPrincipal(
+ Long userId,
+ String displayName,
+ String email,
+ String avatarUrl,
+ String oauthProvider,
+ Set platformRoles
+) implements Serializable {}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java
new file mode 100644
index 00000000..b17f2c87
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java
@@ -0,0 +1,60 @@
+package com.iflytek.skillhub.auth.rbac;
+
+import com.iflytek.skillhub.auth.entity.Permission;
+import com.iflytek.skillhub.auth.entity.RolePermission;
+import com.iflytek.skillhub.auth.entity.UserRoleBinding;
+import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
+import org.springframework.stereotype.Service;
+
+import jakarta.persistence.EntityManager;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class RbacService {
+
+ private final UserRoleBindingRepository roleBindingRepo;
+ private final EntityManager entityManager;
+
+ public RbacService(UserRoleBindingRepository roleBindingRepo, EntityManager entityManager) {
+ this.roleBindingRepo = roleBindingRepo;
+ this.entityManager = entityManager;
+ }
+
+ public Set getUserRoleCodes(Long userId) {
+ return roleBindingRepo.findByUserId(userId).stream()
+ .map(rb -> rb.getRole().getCode())
+ .collect(Collectors.toSet());
+ }
+
+ public Set getUserPermissions(Long userId) {
+ List bindings = roleBindingRepo.findByUserId(userId);
+ Set roleIds = bindings.stream()
+ .map(rb -> rb.getRole().getId())
+ .collect(Collectors.toSet());
+
+ if (roleIds.isEmpty()) return Set.of();
+
+ // Check if user has SUPER_ADMIN role - grant all permissions
+ boolean isSuperAdmin = bindings.stream()
+ .anyMatch(rb -> "SUPER_ADMIN".equals(rb.getRole().getCode()));
+ if (isSuperAdmin) {
+ return entityManager.createQuery("SELECT p.code FROM Permission p", String.class)
+ .getResultList().stream().collect(Collectors.toSet());
+ }
+
+ return entityManager.createQuery(
+ "SELECT p.code FROM RolePermission rp JOIN Permission p ON rp.permissionId = p.id WHERE rp.roleId IN :roleIds", String.class)
+ .setParameter("roleIds", roleIds)
+ .getResultList().stream().collect(Collectors.toSet());
+ }
+
+ public boolean hasPermission(Long userId, String permissionCode) {
+ return getUserPermissions(userId).contains(permissionCode);
+ }
+
+ public boolean hasRole(Long userId, String roleCode) {
+ return getUserRoleCodes(userId).contains(roleCode);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java
new file mode 100644
index 00000000..0c60b2de
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java
@@ -0,0 +1,13 @@
+package com.iflytek.skillhub.auth.repository;
+
+import com.iflytek.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 {
+ Optional findByTokenHash(String tokenHash);
+ List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(Long userId);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java
new file mode 100644
index 00000000..efad961f
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.auth.repository;
+
+import com.iflytek.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 {
+ Optional findByProviderCodeAndSubject(String providerCode, String subject);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java
new file mode 100644
index 00000000..2fcc3a46
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.auth.repository;
+
+import com.iflytek.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 {
+ Optional findByCode(String code);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java
new file mode 100644
index 00000000..73f3f71d
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.auth.repository;
+
+import com.iflytek.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 {
+ List findByUserId(Long userId);
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java
new file mode 100644
index 00000000..5e8a5b78
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java
@@ -0,0 +1,63 @@
+package com.iflytek.skillhub.auth.token;
+
+import com.iflytek.skillhub.auth.entity.ApiToken;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
+import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
+import com.iflytek.skillhub.domain.user.UserAccount;
+import com.iflytek.skillhub.domain.user.UserAccountRepository;
+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.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Component
+public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
+
+ private static final String AUTH_HEADER = "Authorization";
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ private final ApiTokenService apiTokenService;
+ private final UserAccountRepository userRepo;
+ private final UserRoleBindingRepository roleBindingRepo;
+
+ public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
+ UserAccountRepository userRepo,
+ UserRoleBindingRepository roleBindingRepo) {
+ this.apiTokenService = apiTokenService;
+ this.userRepo = userRepo;
+ this.roleBindingRepo = roleBindingRepo;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String authHeader = request.getHeader(AUTH_HEADER);
+ if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
+ String rawToken = authHeader.substring(BEARER_PREFIX.length());
+ apiTokenService.validateToken(rawToken).ifPresent(token -> {
+ apiTokenService.touchLastUsed(token);
+ userRepo.findById(token.getUserId()).ifPresent(user -> {
+ Set roles = roleBindingRepo.findByUserId(user.getId()).stream()
+ .map(rb -> rb.getRole().getCode())
+ .collect(Collectors.toSet());
+ PlatformPrincipal principal = new PlatformPrincipal(
+ user.getId(), user.getDisplayName(), user.getEmail(),
+ user.getAvatarUrl(), "api_token", roles
+ );
+ var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ });
+ });
+ }
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java
new file mode 100644
index 00000000..a15a10ec
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java
@@ -0,0 +1,79 @@
+package com.iflytek.skillhub.auth.token;
+
+import com.iflytek.skillhub.auth.entity.ApiToken;
+import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
+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;
+
+@Service
+public class ApiTokenService {
+
+ private static final String TOKEN_PREFIX = "sk_";
+ private static final int TOKEN_BYTES = 32;
+ private final SecureRandom secureRandom = new SecureRandom();
+ private final ApiTokenRepository tokenRepo;
+
+ public ApiTokenService(ApiTokenRepository tokenRepo) {
+ this.tokenRepo = tokenRepo;
+ }
+
+ public record TokenCreateResult(String rawToken, ApiToken entity) {}
+
+ @Transactional
+ public TokenCreateResult createToken(Long userId, String name, String scopeJson) {
+ byte[] randomBytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(randomBytes);
+ String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
+ String tokenHash = sha256(rawToken);
+ String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8));
+
+ ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson);
+ token = tokenRepo.save(token);
+ return new TokenCreateResult(rawToken, token);
+ }
+
+ public Optional validateToken(String rawToken) {
+ String hash = sha256(rawToken);
+ return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid);
+ }
+
+ @Transactional
+ public void revokeToken(Long tokenId, Long userId) {
+ tokenRepo.findById(tokenId)
+ .filter(t -> t.getUserId().equals(userId))
+ .ifPresent(t -> {
+ t.setRevokedAt(LocalDateTime.now());
+ tokenRepo.save(t);
+ });
+ }
+
+ public List listActiveTokens(Long userId) {
+ return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId);
+ }
+
+ @Transactional
+ public void touchLastUsed(ApiToken token) {
+ token.setLastUsedAt(LocalDateTime.now());
+ tokenRepo.save(token);
+ }
+
+ private String sha256(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ }
+}
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java
new file mode 100644
index 00000000..df7f1c52
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java
@@ -0,0 +1,66 @@
+package com.iflytek.skillhub.auth.policy;
+
+import com.iflytek.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);
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java
new file mode 100644
index 00000000..cac43e25
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java
@@ -0,0 +1,61 @@
+package com.iflytek.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;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 32)
+ private NamespaceStatus status = NamespaceStatus.ACTIVE;
+
+ @Column(length = 512)
+ private String description;
+
+ @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; }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java
new file mode 100644
index 00000000..21e83f12
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java
@@ -0,0 +1,46 @@
+package com.iflytek.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 Long 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, Long 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; }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java
new file mode 100644
index 00000000..2ef778a2
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java
@@ -0,0 +1,10 @@
+package com.iflytek.skillhub.domain.namespace;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface NamespaceMemberRepository {
+ Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId);
+ List findByUserId(Long userId);
+ NamespaceMember save(NamespaceMember member);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java
new file mode 100644
index 00000000..78421ffa
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java
@@ -0,0 +1,9 @@
+package com.iflytek.skillhub.domain.namespace;
+
+import java.util.Optional;
+
+public interface NamespaceRepository {
+ Optional findById(Long id);
+ Optional findBySlug(String slug);
+ Namespace save(Namespace namespace);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java
new file mode 100644
index 00000000..9a14652e
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java
@@ -0,0 +1,5 @@
+package com.iflytek.skillhub.domain.namespace;
+
+public enum NamespaceRole {
+ OWNER, ADMIN, MEMBER
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java
new file mode 100644
index 00000000..b04cb411
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java
@@ -0,0 +1,5 @@
+package com.iflytek.skillhub.domain.namespace;
+
+public enum NamespaceStatus {
+ ACTIVE, FROZEN, ARCHIVED
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java
new file mode 100644
index 00000000..7956f141
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java
@@ -0,0 +1,69 @@
+package com.iflytek.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();
+ }
+
+ 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; }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java
new file mode 100644
index 00000000..003b536a
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java
@@ -0,0 +1,8 @@
+package com.iflytek.skillhub.domain.user;
+
+import java.util.Optional;
+
+public interface UserAccountRepository {
+ Optional findById(Long id);
+ UserAccount save(UserAccount user);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java
new file mode 100644
index 00000000..092c83bc
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java
@@ -0,0 +1,5 @@
+package com.iflytek.skillhub.domain.user;
+
+public enum UserStatus {
+ ACTIVE, PENDING, DISABLED, MERGED
+}
diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml
index 04c21641..e3d346ef 100644
--- a/server/skillhub-infra/pom.xml
+++ b/server/skillhub-infra/pom.xml
@@ -15,5 +15,9 @@
com.iflytek.skillhub
skillhub-domain
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java
new file mode 100644
index 00000000..9a326b7e
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java
@@ -0,0 +1,14 @@
+package com.iflytek.skillhub.infra.jpa;
+
+import com.iflytek.skillhub.domain.namespace.Namespace;
+import com.iflytek.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, NamespaceRepository {
+ Optional findBySlug(String slug);
+}
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java
new file mode 100644
index 00000000..5c30d78c
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java
@@ -0,0 +1,16 @@
+package com.iflytek.skillhub.infra.jpa;
+
+import com.iflytek.skillhub.domain.namespace.NamespaceMember;
+import com.iflytek.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, NamespaceMemberRepository {
+ Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId);
+ List findByUserId(Long userId);
+}
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java
new file mode 100644
index 00000000..2968eccf
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.infra.jpa;
+
+import com.iflytek.skillhub.domain.user.UserAccount;
+import com.iflytek.skillhub.domain.user.UserAccountRepository;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserAccountJpaRepository
+ extends JpaRepository, UserAccountRepository {
+}