mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge branch 'feature/phase1-foundation-auth' into feature/project-init
This commit is contained in:
commit
cfdb0985a9
56 changed files with 1663 additions and 0 deletions
29
Makefile
Normal file
29
Makefile
Normal file
|
|
@ -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"
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
|
|
@ -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:
|
||||
|
|
@ -70,6 +70,11 @@
|
|||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> health() {
|
||||
return Map.of("status", "UP");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record ErrorResponse(
|
||||
int status,
|
||||
String error,
|
||||
String message
|
||||
) {}
|
||||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
20
server/skillhub-app/src/test/resources/application-test.yml
Normal file
20
server/skillhub-app/src/test/resources/application-test.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -23,6 +23,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
|
||||
var attrs = new HashMap<>(oAuth2User.getAttributes());
|
||||
attrs.put("platformPrincipal", principal);
|
||||
|
||||
return new DefaultOAuth2User(oAuth2User.getAuthorities(), attrs, "login");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object> extra
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.auth.policy;
|
||||
|
||||
public enum AccessDecision {
|
||||
ALLOW, DENY, PENDING_APPROVAL
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.policy;
|
||||
|
||||
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
|
||||
|
||||
public interface AccessPolicy {
|
||||
AccessDecision evaluate(OAuthClaims claims);
|
||||
}
|
||||
|
|
@ -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<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; }
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> whitelistedSubjects;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> platformRoles
|
||||
) implements Serializable {}
|
||||
|
|
@ -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<String> getUserRoleCodes(Long userId) {
|
||||
return roleBindingRepo.findByUserId(userId).stream()
|
||||
.map(rb -> rb.getRole().getCode())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public Set<String> getUserPermissions(Long userId) {
|
||||
List<UserRoleBinding> bindings = roleBindingRepo.findByUserId(userId);
|
||||
Set<Long> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ApiToken, Long> {
|
||||
Optional<ApiToken> findByTokenHash(String tokenHash);
|
||||
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(Long userId);
|
||||
}
|
||||
|
|
@ -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<IdentityBinding, Long> {
|
||||
Optional<IdentityBinding> findByProviderCodeAndSubject(String providerCode, String subject);
|
||||
}
|
||||
|
|
@ -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<Role, Long> {
|
||||
Optional<Role> findByCode(String code);
|
||||
}
|
||||
|
|
@ -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<UserRoleBinding, Long> {
|
||||
List<UserRoleBinding> findByUserId(Long userId);
|
||||
}
|
||||
|
|
@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ApiToken> 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<ApiToken> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface NamespaceMemberRepository {
|
||||
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, Long userId);
|
||||
List<NamespaceMember> findByUserId(Long userId);
|
||||
NamespaceMember save(NamespaceMember member);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface NamespaceRepository {
|
||||
Optional<Namespace> findById(Long id);
|
||||
Optional<Namespace> findBySlug(String slug);
|
||||
Namespace save(Namespace namespace);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
public enum NamespaceRole {
|
||||
OWNER, ADMIN, MEMBER
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
public enum NamespaceStatus {
|
||||
ACTIVE, FROZEN, ARCHIVED
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserAccountRepository {
|
||||
Optional<UserAccount> findById(Long id);
|
||||
UserAccount save(UserAccount user);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
public enum UserStatus {
|
||||
ACTIVE, PENDING, DISABLED, MERGED
|
||||
}
|
||||
|
|
@ -15,5 +15,9 @@
|
|||
<groupId>com.iflytek.skillhub</groupId>
|
||||
<artifactId>skillhub-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
|
|||
|
|
@ -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<Namespace, Long>, NamespaceRepository {
|
||||
Optional<Namespace> findBySlug(String slug);
|
||||
}
|
||||
|
|
@ -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<NamespaceMember, Long>, NamespaceMemberRepository {
|
||||
Optional<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, Long userId);
|
||||
List<NamespaceMember> findByUserId(Long userId);
|
||||
}
|
||||
|
|
@ -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<UserAccount, Long>, UserAccountRepository {
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue