Compare commits

..

17 Commits

Author SHA1 Message Date
d353735d1b 实现oauth2 2025-12-14 17:47:08 +08:00
d04440f0b1 fix: 避免gateway不需要其错误处理而导致报错的问题 2025-12-14 15:17:51 +08:00
63d0528af4 fix: 修复网关启动找不到服务的问题,修复jwt问题,修复自动导入失败问题。 2025-12-14 15:02:24 +08:00
4912e48922 fix: 确保项目可以启动 2025-12-12 23:50:55 +08:00
c61ee69561 fix: 修复日志功能 2025-12-08 22:51:51 +08:00
lirui
6f7963a73b feat:依赖修复,完善core和mybatis还有log模块,log模块待完成 2025-11-25 17:06:50 +08:00
lirui
d89960f51c feat: 添加代码格式化 2025-11-25 13:53:29 +08:00
4304ec6e29 feat: mybatis自动填充实现,分页拦截器实现 2025-11-25 00:09:33 +08:00
050e808ab8 fix: 重新设计依赖,添加多个模块 2025-11-24 23:42:13 +08:00
7a3d3a06ba fix: 重新设计依赖,添加多个模块 2025-11-24 23:42:00 +08:00
lirui
122a1738bd feat: 添加日志,后面准备拆分 2025-11-24 17:37:37 +08:00
00c2fffad1 feat: 添加网关白名单 2025-11-21 00:11:15 +08:00
aba1e36e03 fix: 修复网关异常😡缺loadbalacner依赖导致的503,然后把auth服务写一下 2025-11-21 00:03:00 +08:00
3603d450e8 feat: 实现刷新token逻辑 2025-11-20 23:13:33 +08:00
c03876e29e refactor: 拆分出认证服务 2025-11-20 22:43:05 +08:00
f93ec43915 fix: 修复依赖问题 2025-11-20 00:33:27 +08:00
9b28ef0a37 feat: 引入spring security和oauth2库还有jwt库,实现基本的注册功能和登录功能 2025-11-19 23:12:35 +08:00
169 changed files with 7299 additions and 941 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(mvn clean compile:*)",
"Bash(mvn spring-javaformat:apply)",
"Bash(cat:*)",
"Bash(mvn dependency:tree:*)",
"Bash(mvn spring-javaformat:apply:*)"
]
}
}

BIN
.idea/.cache/.easy-yapi/.api.cache.v1.1.db generated Normal file

Binary file not shown.

View File

View File

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CoolRequestCommonStatePersistent">
<option name="searchCache" value="G" />
</component>
</project>

6
.idea/CoolRequestSetting.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CoolRequestSetting">
<option name="projectCachePath" value="project-e82c3cb9-7dfc-4fa6-b498-45789b6b3803" />
</component>
</project>

6
.idea/copilot.data.migration.agent.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

29
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="jdbc:mysql://10.0.0.10/aioj_dev [DEBUG]" group="AIOJAdminApplication" uuid="43cc61de-66e1-44cc-b4a2-b24d7e03b490">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://10.0.0.10/aioj_dev</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="jdbc:mysql://10.0.0.10/aioj_dev [DEBUG]" group="UserServiceApplication" uuid="903d03c4-df11-4cf8-939a-3e5fba0ab207">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://10.0.0.10/aioj_dev</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="jdbc:mysql://10.0.0.10/aioj_dev [DEBUG]" group="AIOJAuthApplication" uuid="2fd8684a-b9aa-4507-abb0-f7c259d91286">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://10.0.0.10/aioj_dev</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/db-forest-config.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="1:0:AIOJAdminApplication&#10;3:0:UserServiceApplication&#10;5:0:AIOJAuthApplication&#10;----------------------------------------&#10;2:1:43cc61de-66e1-44cc-b4a2-b24d7e03b490&#10;4:3:903d03c4-df11-4cf8-939a-3e5fba0ab207&#10;6:5:2fd8684a-b9aa-4507-abb0-f7c259d91286&#10;" />
</component>
</project>

18
.idea/encodings.xml generated
View File

@@ -3,8 +3,17 @@
<component name="Encoding"> <component name="Encoding">
<file url="file://$PROJECT_DIR$/aioj-backend-ai-service/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-ai-service/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-ai-service/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-ai-service/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-auth/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-client/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-client/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-client/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-client/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-bom/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-bom/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-feign/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-log/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-mybatis/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-starter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/aioj-backend-common-starter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-common/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-common/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-common/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-gateway/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-gateway/src/main/java" charset="UTF-8" />
@@ -15,11 +24,16 @@
<file url="file://$PROJECT_DIR$/aioj-backend-model/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-model/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-question-service/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-question-service/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-question-service/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-question-service/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-upms/aioj-backend-upms-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-upms/aioj-backend-upms-biz/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-upms/aioj-upms-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-upms/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-upms/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-user-service/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-user-service/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/aioj-backend-user-service/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/aioj-backend-user-service/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/../../../../../Windows/System32/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/../../../../Windows/System32/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/../../../../../Windows/System32/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/../../../../Windows/System32/src/main/resources" charset="UTF-8" />
</component> </component>
</project> </project>

8
.idea/misc.xml generated
View File

@@ -1,13 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
<component name="MavenProjectsManager"> <component name="MavenProjectsManager">
<option name="originalFiles"> <option name="originalFiles">
<list> <list>
<option value="$PROJECT_DIR$/pom.xml" /> <option value="$PROJECT_DIR$/pom.xml" />
</list> </list>
</option> </option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/aioj-backend-client/pom.xml" />
<option value="$PROJECT_DIR$/aioj-backend-model/pom.xml" />
<option value="$PROJECT_DIR$/aioj-backend-upms/aioj-upms-api/pom.xml" />
</set>
</option>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="zulu-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="zulu-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />

123
CLAUDE.md Normal file
View File

@@ -0,0 +1,123 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Codebase Overview
This is a microservices architecture for an Online Judge (OJ) system, built on **Spring Boot 3.5.7**. The project uses Maven as the build tool and follows a modular monorepo structure, with clearly separated core modules and service modules.
### Core Modules
The `aioj-backend-common` directory contains shared components and utilities used across all service modules:
1. **aioj-backend-common-bom**
Bill of materials for centralized dependency management. This ensures consistent versions of all external libraries across all modules.
2. **aioj-backend-common-core**
Core utilities and Spring framework extensions:
- `BannerApplicationRunner`: Custom application banner display during startup
- `SpringContextHolder`: Spring context accessor for non-Spring-managed classes
- `JavaTimeModule`: Jackson module for Java 8+ time API support
- Common constants and enumerations
- Application-level configurations and auto-configuration classes
3. **aioj-backend-common-feign**
Feign client configurations for seamless inter-service communication:
- Auto-configuration for Feign clients with default settings
- `@EnableAIOJFeignClients` annotation for enabling Feign clients with predefined base packages
- Feign interceptors and error handling mechanisms
4. **aioj-backend-common-log**
Aspect-oriented programming (AOP) based logging framework:
- `SysLogAspect`: Aspect for logging system operations (controller methods, service calls)
- `SysLogEvent` and `SysLogListener`: Event-driven logging mechanism
- `SysLogUtils`: Utility class for creating and managing log entries
- Configuration properties for logging behavior
5. **aioj-backend-common-mybatis**
MyBatis ORM framework extensions:
- Auto-fill functionality for `createTime` and `updateTime` fields
- Pagination interceptor implementation
- MyBatis configuration auto-configuration classes
6. **aioj-backend-common-starter**
Auto-configuration starters for easily enabling common features in service modules.
### Service Modules
The service modules represent the individual microservices that make up the system:
1. **aioj-backend-auth**
Authentication and authorization service:
- JWT-based authentication with `JwtAuthenticationFilter`
- Security configuration with Spring Security
- User login, token generation, and validation
- Permission verification and access control
2. **aioj-backend-gateway**
API gateway for request routing and filtering:
- Request routing to appropriate service modules
- Authentication token validation before forwarding requests
- Rate limiting and request filtering mechanisms
3. **aioj-backend-judge-service**
Code judge service (under development):
- Will handle code submission, compilation, and execution
- Support for multiple programming languages
- Test case validation and result return
4. **aioj-backend-user-service**
User management service:
- User registration, profile management, and information retrieval
- User role and permission assignment
- Integration with the auth service for authentication
5. **aioj-backend-question-service**
Question bank service (under development):
- Will manage programming problems, test cases, and problem categories
- Support for problem difficulty levels and tags
- Integration with the judge service for problem submission
6. **aioj-backend-ai-service**
AI-related functionality service (under development):
- Will provide AI-assisted features like problem recommendation, code analysis, etc.
7. **aioj-backend-upms**
User, permission, and menu management service:
- Low-level user and permission management
- Menu and resource access control
- Integration with other services for authorization
## Commonly Used Commands
### Build
- **Build the entire project**: `mvn clean compile`
- **Build with tests**: `mvn clean install`
- **Build a single module**: `mvn clean compile -pl aioj-backend-auth`
### Code Formatting
- **Format code**: `mvn spring-javaformat:apply`
- **Check code format**: `mvn spring-javaformat:check`
### Testing
- **Run all tests**: `mvn test`
- **Run tests for a single module**: `mvn test -pl aioj-backend-user-service`
### Running Services
- **Run a service locally**: Use Spring Boot's `Application` class directly in IDE or use `mvn spring-boot:run -pl <module-name>`
- **Example**: `mvn spring-boot:run -pl aioj-backend-auth`
## Architecture Highlights
- **Authentication**: JWT-based authentication implemented in `aioj-backend-auth` with `JwtAuthenticationFilter`
- **Logging**: Aspect-oriented logging with `SysLogAspect` in `aioj-backend-common-log`
- **Database**: MyBatis with auto-fill for create/update times implemented in `common-mybatis`
- **Inter-service communication**: Feign clients with auto-configuration from `common-feign`
- **Banner**: Custom application banner system in `common-core`
## Key Entry Points
- **Gateway Application**: `aioj-backend-gateway/src/main/java/cn/meowrain/aioj/backend/gateway/AIOJGatewayApplication.java`
- **Auth Application**: `aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/AIOJAuthApplication.java`
- **User Service Application**: `aioj-backend-user-service/src/main/java/cn/meowrain/aioj/backend/userservice/UserServiceApplication.java`

131
aioj-backend-auth/pom.xml Normal file
View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain</groupId>
<artifactId>ai-oj</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>aioj-backend-auth</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 核心模块 -->
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-feign</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-mybatis</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- Spring Cloud服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 & Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<!-- Feign客户端 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- Redis用于存储refreshToken -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- API文档 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
</dependency>
<!-- 开发工具 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,15 @@
package cn.meowrain.aioj.backend.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients(basePackages = "cn.meowrain.aioj.backend.auth.clients")
@SpringBootApplication
public class AIOJAuthApplication {
public static void main(String[] args) {
SpringApplication.run(AIOJAuthApplication.class, args);
}
}

View File

@@ -0,0 +1,18 @@
package cn.meowrain.aioj.backend.auth.clients;
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "user-service", path = "/api/v1/user")
public interface UserClient {
@GetMapping("/inner/get-by-username")
Result<UserAuthRespDTO> getUserByUserName(@RequestParam("userAccount") String userAccount);
@GetMapping("/inner/get-by-userid")
public Result<UserAuthRespDTO> getUserById(@RequestParam("userId") String userId);
}

View File

@@ -0,0 +1,29 @@
package cn.meowrain.aioj.backend.auth.common.constants;
public class RedisKeyConstants {
public static String REFRESH_TOKEN_KEY_PREFIX = "refresh_token:%s";
// ============= OAuth2 相关 Key =============
/**
* 授权码存储 Key 前缀 格式: oauth2:auth_code:{code}
*/
public static final String OAUTH2_AUTH_CODE_PREFIX = "oauth2:auth_code:%s";
/**
* 用户会话存储 Key 前缀 格式: oauth2:session:{sessionId}
*/
public static final String OAUTH2_SESSION_PREFIX = "oauth2:session:%s";
/**
* 用户会话索引 Key 前缀 格式: oauth2:user_sessions:{userId} Value: Set<sessionId>
*/
public static final String OAUTH2_USER_SESSIONS_PREFIX = "oauth2:user_sessions:%s";
/**
* Token 黑名单 Key 前缀(用于单点登出) 格式: oauth2:token_blacklist:{SHA256(token)}
*/
public static final String OAUTH2_TOKEN_BLACKLIST_PREFIX = "oauth2:token_blacklist:%s";
}

View File

@@ -0,0 +1,22 @@
package cn.meowrain.aioj.backend.auth.common.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public enum ChainMarkEnums {
/**
* 用户登录请求验证
*/
USER_LOGIN_REQ_PARAM_VERIFY("USER_LOGIN_REQ_PARAM_VERIFY");
@Getter
private final String markName;
@Override
public String toString() {
return markName;
}
}

View File

@@ -0,0 +1,48 @@
package cn.meowrain.aioj.backend.auth.config;
import cn.meowrain.aioj.backend.auth.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/**", "/oauth2/**", "/.well-known/**", "/doc.html", "/swagger-ui/**",
"/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/favicon.ico")
.permitAll()
.anyRequest()
.authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,40 @@
package cn.meowrain.aioj.backend.auth.config;
import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
@EnableKnife4j
public class SwaggerConfiguration implements ApplicationRunner {
@Value("${server.port:8080}")
private String serverPort;
@Value("${server.servlet.context-path:}")
private String contextPath;
@Bean
public OpenAPI customerOpenAPI() {
return new OpenAPI().info(new Info().title("AIOJ-renz微服务✨")
.description("用户认证功能")
.version("v1.0.0")
.contact(new Contact().name("meowrain").email("meowrain@126.com"))
.license(new License().name("MeowRain").url("https://meowrain.cn")));
}
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("✨API Document: http://127.0.0.1:{}{}/doc.html", serverPort, contextPath);
}
}

View File

@@ -0,0 +1,29 @@
package cn.meowrain.aioj.backend.auth.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@Data
@ConfigurationProperties(value = JwtPropertiesConfiguration.PREFIX)
public class JwtPropertiesConfiguration {
public static final String PREFIX = "jwt";
/**
* JWT 密钥(必须 32 字节以上)
*/
private String secret;
/**
* 过期时间(单位:毫秒)
*/
private long accessExpire; // access token TTL
/**
* 刷新令牌时间
*/
private long refreshExpire; // refresh token TTL
}

View File

@@ -0,0 +1,57 @@
package cn.meowrain.aioj.backend.auth.controller;
import cn.meowrain.aioj.backend.auth.dto.req.UserLoginRequestDTO;
import cn.meowrain.aioj.backend.auth.dto.resp.UserLoginResponseDTO;
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2SessionService;
import cn.meowrain.aioj.backend.auth.service.AuthService;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import cn.meowrain.aioj.backend.framework.core.web.Results;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/auth")
public class AuthController {
private final AuthService authService;
private final OAuth2SessionService sessionService;
@PostMapping("/login")
public Result<UserLoginResponseDTO> login(@RequestBody UserLoginRequestDTO userLoginRequest) {
UserLoginResponseDTO userLoginResponse = authService.userLogin(userLoginRequest);
return Results.success(userLoginResponse);
}
@PostMapping("/refresh")
public Result<UserLoginResponseDTO> refresh(@RequestParam String refreshToken) {
return Results.success(authService.refreshToken(refreshToken));
}
@PostMapping("/auth")
public Result<String> auth(@RequestBody UserLoginRequestDTO userLoginRequest) {
UserLoginResponseDTO userLoginResponseDTO = authService.userLogin(userLoginRequest);
return Results.success(userLoginResponseDTO.getAccessToken());
}
@PostMapping("/validate")
public Result<Boolean> validate(@RequestHeader(value = "Authorization", required = false) String authorization) {
// 从Authorization头中提取Bearer token
String token = null;
if (authorization != null && authorization.startsWith("Bearer ")) {
token = authorization.substring(7);
}
// 检查Token黑名单
if (token != null && sessionService.isTokenBlacklisted(token)) {
return Results.success(false);
}
Boolean isValid = authService.validateToken(token);
return Results.success(isValid);
}
}

View File

@@ -0,0 +1,40 @@
package cn.meowrain.aioj.backend.auth.dto.chains;
import cn.meowrain.aioj.backend.auth.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.auth.dto.req.UserLoginRequestDTO;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class UserLoginRequestParamVerifyChain implements AbstractChianHandler<UserLoginRequestDTO> {
@Override
public void handle(UserLoginRequestDTO requestParam) {
if (StringUtils.isAnyBlank(requestParam.getUserAccount(), requestParam.getUserPassword())) {
throw new ClientException("参数为空", ErrorCode.PARAMS_ERROR);
}
if (requestParam.getUserAccount().length() < 4) {
throw new ClientException("账号长度不小于4位", ErrorCode.PARAMS_ERROR);
}
if (requestParam.getUserPassword().length() < 8) {
throw new ClientException("密码长度不小于8位", ErrorCode.PARAMS_ERROR);
}
}
@Override
public String mark() {
return ChainMarkEnums.USER_LOGIN_REQ_PARAM_VERIFY.getMarkName();
}
@Override
public int getOrder() {
return 10;
}
}

View File

@@ -0,0 +1,10 @@
package cn.meowrain.aioj.backend.auth.dto.chains.context;
import cn.meowrain.aioj.backend.auth.dto.req.UserLoginRequestDTO;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.CommonChainContext;
import org.springframework.stereotype.Component;
@Component
public class UserLoginRequestParamVerifyContext extends CommonChainContext<UserLoginRequestDTO> {
}

View File

@@ -0,0 +1,12 @@
package cn.meowrain.aioj.backend.auth.dto.req;
import lombok.Data;
@Data
public class UserLoginRequestDTO {
private String userAccount;
private String userPassword;
}

View File

@@ -0,0 +1,68 @@
package cn.meowrain.aioj.backend.auth.dto.resp;
import lombok.Data;
import java.util.Date;
/**
* 用户认证响应体
*/
@Data
public class UserAuthRespDTO {
/**
* id
*/
private Long id;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户密码
*/
private String userPassword;
/**
* 开放平台id
*/
private String unionId;
/**
* 公众号openId
*/
private String mpOpenId;
/**
* 用户昵称
*/
private String userName;
/**
* 用户头像
*/
private String userAvatar;
/**
* 用户简介
*/
private String userProfile;
/**
* 用户角色user/admin/ban
*/
private String userRole;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}

View File

@@ -0,0 +1,34 @@
package cn.meowrain.aioj.backend.auth.dto.resp;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
public class UserLoginResponseDTO implements Serializable {
/**
* id
*/
private Long id;
/**
* 用户账号
*/
private String userAccount;
/**
* 开放平台id
*/
private String unionId;
private String accessToken;
private String refreshToken;
private Long expire;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,105 @@
package cn.meowrain.aioj.backend.auth.filter;
import cn.meowrain.aioj.backend.auth.service.AuthService;
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
/**
* JWT认证过滤器 拦截所有请求验证JWT Token
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final AuthService authService;
private static final String TOKEN_PREFIX = "Bearer ";
private static final String HEADER_NAME = "Authorization";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String token = extractTokenFromRequest(request);
if (StringUtils.hasText(token) && jwtUtil.isTokenValid(token)) {
Claims claims = jwtUtil.parseClaims(token);
Authentication authentication = createAuthentication(claims);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT Authentication successful for user: {}", claims.getSubject());
}
else {
log.debug("No valid JWT token found in request");
}
}
catch (Exception e) {
log.error("JWT Authentication failed", e);
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
/**
* 从请求中提取JWT Token
*/
private String extractTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(HEADER_NAME);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
/**
* 根据JWT Claims创建Authentication对象
*/
private Authentication createAuthentication(Claims claims) {
String userId = claims.getSubject();
String userName = claims.get("userName", String.class);
String role = claims.get("role", String.class);
// 创建权限列表
List<SimpleGrantedAuthority> authorities = Collections
.singletonList(new SimpleGrantedAuthority("ROLE_" + (role != null ? role : "USER")));
// 创建认证对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null,
authorities);
return authentication;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// 跳过不需要JWT验证的路径
return path.startsWith("/v1/auth/") || path.startsWith("/doc.html") || path.startsWith("/swagger-ui/")
|| path.startsWith("/swagger-resources/") || path.startsWith("/webjars/")
|| path.startsWith("/v3/api-docs/") || path.equals("/favicon.ico");
}
}

View File

@@ -0,0 +1,236 @@
package cn.meowrain.aioj.backend.auth.oauth2.controller;
import cn.hutool.core.util.StrUtil;
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2AuthorizeRequest;
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2AuthorizationService;
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2ClientService;
import cn.meowrain.aioj.backend.auth.oauth2.service.PKCEService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.view.RedirectView;
/**
* OAuth2 授权端点 处理授权请求并生成授权码
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@RestController
@RequestMapping("/oauth2/authorize")
@RequiredArgsConstructor
@Tag(name = "OAuth2 授权端点", description = "OAuth2 授权相关接口")
public class OAuth2AuthorizationController {
private final OAuth2ClientService clientService;
private final OAuth2AuthorizationService authorizationService;
private final PKCEService pkceService;
/**
* 授权端点GET
* @param request 授权请求
* @return 重定向到客户端
*/
@GetMapping
@Operation(summary = "OAuth2 授权GET", description = "发起 OAuth2 授权请求")
public RedirectView authorize(OAuth2AuthorizeRequest request) {
return processAuthorization(request);
}
/**
* 授权端点POST
* @param request 授权请求
* @return 重定向到客户端
*/
@PostMapping
@Operation(summary = "OAuth2 授权POST", description = "发起 OAuth2 授权请求")
public RedirectView authorizePost(OAuth2AuthorizeRequest request) {
return processAuthorization(request);
}
/**
* 处理授权请求
* @param request 授权请求
* @return 重定向视图
*/
private RedirectView processAuthorization(OAuth2AuthorizeRequest request) {
log.info("收到授权请求: clientId={}, redirectUri={}, scope={}", request.getClientId(), request.getRedirectUri(),
request.getScope());
try {
// 1. 验证基本参数
validateBasicParameters(request);
// 2. 验证客户端
OAuth2Client client = clientService.getClientByClientId(request.getClientId());
clientService.validateRedirectUri(client, request.getRedirectUri());
// 3. 验证 response_type
if (!"code".equals(request.getResponseType())) {
throw new OAuth2Exception("不支持的 response_type仅支持 code");
}
// 4. 验证 PKCE如果客户端要求
pkceService.validatePKCERequest(request.getCodeChallenge(), request.getCodeChallengeMethod(),
client.getRequirePkce());
// 5. 验证 scope
String scope = clientService.validateScope(client, request.getScope());
request.setScope(scope);
// 6. 检查用户是否已登录
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| "anonymousUser".equals(authentication.getPrincipal())) {
// 用户未登录,重定向到登录页面
// TODO: 实现登录页面,登录成功后重定向回授权端点
String loginUrl = buildLoginRedirectUrl(request);
log.info("用户未登录,重定向到登录页面: {}", loginUrl);
return new RedirectView(loginUrl);
}
// 7. 提取用户 ID
// 从 SecurityContext 中提取用户ID根据你的实际实现调整
Long userId = extractUserIdFromAuthentication(authentication);
request.setUserId(userId);
// 8. 生成授权码
String code = authorizationService.generateAuthorizationCode(request);
// 9. 构造重定向 URL
String redirectUrl = buildRedirectUrl(request.getRedirectUri(), code, request.getState());
log.info("授权成功,重定向到客户端: {}", redirectUrl);
return new RedirectView(redirectUrl);
}
catch (OAuth2Exception e) {
// OAuth2 异常:重定向到客户端并携带错误信息
String errorUrl = buildErrorRedirectUrl(request.getRedirectUri(), e.getError(), e.getErrorDescription(),
request.getState());
log.error("授权失败: {}", e.getErrorDescription());
return new RedirectView(errorUrl);
}
catch (Exception e) {
// 其他异常:返回 500 错误
log.error("授权过程发生异常", e);
throw new OAuth2Exception("server_error", "服务器内部错误");
}
}
/**
* 验证基本参数
* @param request 授权请求
*/
private void validateBasicParameters(OAuth2AuthorizeRequest request) {
if (StrUtil.isBlank(request.getResponseType())) {
throw new OAuth2Exception("response_type 不能为空");
}
if (StrUtil.isBlank(request.getClientId())) {
throw new OAuth2Exception("client_id 不能为空");
}
if (StrUtil.isBlank(request.getRedirectUri())) {
throw new OAuth2Exception("redirect_uri 不能为空");
}
}
/**
* 从 Authentication 中提取用户 ID
* @param authentication 认证信息
* @return 用户 ID
*/
private Long extractUserIdFromAuthentication(Authentication authentication) {
// 根据你的实际实现调整
// 示例:从 JWT 的 Claims 中提取
Object principal = authentication.getPrincipal();
if (principal instanceof Long) {
return (Long) principal;
}
// 如果是字符串,尝试解析
try {
return Long.parseLong(principal.toString());
}
catch (NumberFormatException e) {
log.error("无法从 Authentication 中提取用户 ID: {}", principal);
throw new OAuth2Exception("无法获取用户信息");
}
}
/**
* 构造重定向 URL成功
* @param redirectUri 重定向 URI
* @param code 授权码
* @param state 状态参数
* @return 重定向 URL
*/
private String buildRedirectUrl(String redirectUri, String code, String state) {
StringBuilder url = new StringBuilder(redirectUri);
url.append(redirectUri.contains("?") ? "&" : "?");
url.append("code=").append(code);
if (StrUtil.isNotBlank(state)) {
url.append("&state=").append(state);
}
return url.toString();
}
/**
* 构造错误重定向 URL
* @param redirectUri 重定向 URI
* @param error 错误代码
* @param errorDescription 错误描述
* @param state 状态参数
* @return 错误重定向 URL
*/
private String buildErrorRedirectUrl(String redirectUri, String error, String errorDescription, String state) {
if (StrUtil.isBlank(redirectUri)) {
// 如果没有 redirect_uri无法重定向直接抛出异常
throw new OAuth2Exception(error, errorDescription);
}
StringBuilder url = new StringBuilder(redirectUri);
url.append(redirectUri.contains("?") ? "&" : "?");
url.append("error=").append(error);
if (StrUtil.isNotBlank(errorDescription)) {
url.append("&error_description=").append(errorDescription);
}
if (StrUtil.isNotBlank(state)) {
url.append("&state=").append(state);
}
return url.toString();
}
/**
* 构造登录重定向 URL
* @param request 授权请求
* @return 登录 URL
*/
private String buildLoginRedirectUrl(OAuth2AuthorizeRequest request) {
// TODO: 根据实际情况调整登录页面 URL
// 登录成功后应该重定向回 /oauth2/authorize 并携带所有参数
StringBuilder loginUrl = new StringBuilder("/login");
loginUrl.append("?redirect_uri=").append("/oauth2/authorize");
loginUrl.append("&client_id=").append(request.getClientId());
// ... 添加其他参数
return loginUrl.toString();
}
}

View File

@@ -0,0 +1,186 @@
package cn.meowrain.aioj.backend.auth.oauth2.controller;
import cn.hutool.core.util.StrUtil;
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2ClientService;
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2SessionService;
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.view.RedirectView;
/**
* OAuth2 登出端点 处理单点登出请求
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@RestController
@RequestMapping("/oauth2/logout")
@RequiredArgsConstructor
@Tag(name = "OAuth2 登出端点", description = "OAuth2 登出相关接口")
public class OAuth2LogoutController {
private final OAuth2SessionService sessionService;
private final OAuth2ClientService clientService;
private final JwtUtil jwtUtil;
/**
* 登出端点GET
* @param idTokenHint ID Token Hint可选
* @param postLogoutRedirectUri 登出后重定向URI可选
* @param clientId 客户端ID可选用于验证redirect_uri
* @param state 状态参数(可选)
* @return 重定向视图
*/
@GetMapping
@Operation(summary = "OAuth2 登出GET", description = "单点登出,撤销所有会话")
public RedirectView logout(@RequestParam(required = false) String idTokenHint,
@RequestParam(name = "post_logout_redirect_uri", required = false) String postLogoutRedirectUri,
@RequestParam(name = "client_id", required = false) String clientId,
@RequestParam(required = false) String state) {
return processLogout(idTokenHint, postLogoutRedirectUri, clientId, state);
}
/**
* 登出端点POST
* @param idTokenHint ID Token Hint可选
* @param postLogoutRedirectUri 登出后重定向URI可选
* @param clientId 客户端ID可选
* @param state 状态参数(可选)
* @return 重定向视图
*/
@PostMapping
@Operation(summary = "OAuth2 登出POST", description = "单点登出,撤销所有会话")
public RedirectView logoutPost(@RequestParam(required = false) String idTokenHint,
@RequestParam(name = "post_logout_redirect_uri", required = false) String postLogoutRedirectUri,
@RequestParam(name = "client_id", required = false) String clientId,
@RequestParam(required = false) String state) {
return processLogout(idTokenHint, postLogoutRedirectUri, clientId, state);
}
/**
* 处理登出请求
* @param idTokenHint ID Token Hint
* @param postLogoutRedirectUri 登出后重定向URI
* @param clientId 客户端ID
* @param state 状态参数
* @return 重定向视图
*/
private RedirectView processLogout(String idTokenHint, String postLogoutRedirectUri, String clientId,
String state) {
log.info("收到登出请求: clientId={}, postLogoutRedirectUri={}", clientId, postLogoutRedirectUri);
try {
// 1. 提取用户ID
Long userId = extractUserIdFromToken(idTokenHint);
if (userId == null) {
log.warn("无法从 id_token_hint 中提取用户ID");
throw new OAuth2Exception("invalid_request", "无效的 id_token_hint");
}
// 2. 撤销用户的所有会话
sessionService.revokeAllUserSessions(userId);
log.info("用户登出成功: userId={}", userId);
// 3. 验证并重定向
if (StrUtil.isNotBlank(postLogoutRedirectUri)) {
// 验证 redirect_uri如果提供了 client_id
if (StrUtil.isNotBlank(clientId)) {
OAuth2Client client = clientService.getClientByClientId(clientId);
clientService.validatePostLogoutRedirectUri(client, postLogoutRedirectUri);
}
// 构造重定向URL
String redirectUrl = buildLogoutRedirectUrl(postLogoutRedirectUri, state);
log.info("重定向到: {}", redirectUrl);
return new RedirectView(redirectUrl);
}
// 4. 如果没有 redirect_uri返回默认页面
log.info("登出成功无重定向URI");
return new RedirectView("/logout-success"); // TODO: 自定义登出成功页面
}
catch (OAuth2Exception e) {
log.error("登出失败: {}", e.getErrorDescription());
throw e;
}
catch (Exception e) {
log.error("登出过程发生异常", e);
throw new OAuth2Exception("server_error", "服务器内部错误");
}
}
/**
* 从 Token 中提取用户 ID
* @param token TokenID Token 或 Access Token
* @return 用户 ID
*/
private Long extractUserIdFromToken(String token) {
if (StrUtil.isBlank(token)) {
return null;
}
try {
// 验证 Token
if (!jwtUtil.isTokenValid(token)) {
log.warn("Token 无效或已过期");
return null;
}
// 解析 Token
Claims claims = jwtUtil.parseClaims(token);
// 尝试从 sub claim 提取用户ID
String sub = claims.getSubject();
if (StrUtil.isNotBlank(sub)) {
return Long.parseLong(sub);
}
// 尝试从 userId claim 提取
Object userId = claims.get("userId");
if (userId != null) {
return Long.parseLong(userId.toString());
}
return null;
}
catch (Exception e) {
log.error("解析 Token 失败", e);
return null;
}
}
/**
* 构造登出重定向 URL
* @param postLogoutRedirectUri 登出后重定向URI
* @param state 状态参数
* @return 重定向 URL
*/
private String buildLogoutRedirectUrl(String postLogoutRedirectUri, String state) {
if (StrUtil.isBlank(state)) {
return postLogoutRedirectUri;
}
StringBuilder url = new StringBuilder(postLogoutRedirectUri);
url.append(postLogoutRedirectUri.contains("?") ? "&" : "?");
url.append("state=").append(state);
return url.toString();
}
}

View File

@@ -0,0 +1,135 @@
package cn.meowrain.aioj.backend.auth.oauth2.controller;
import cn.hutool.core.util.StrUtil;
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2TokenRequest;
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2TokenResponse;
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
import cn.meowrain.aioj.backend.auth.oauth2.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* OAuth2 Token 端点 处理 Token 请求(授权码换 Token、刷新 Token
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@RestController
@RequestMapping("/oauth2/token")
@RequiredArgsConstructor
@Tag(name = "OAuth2 Token 端点", description = "OAuth2 Token 相关接口")
public class OAuth2TokenController {
private final OAuth2ClientService clientService;
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenService tokenService;
private final PKCEService pkceService;
/**
* Token 端点
* @param request Token 请求
* @return Token 响应
*/
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@Operation(summary = "获取 Token", description = "使用授权码或刷新令牌获取访问令牌")
public OAuth2TokenResponse token(OAuth2TokenRequest request) {
log.info("收到 Token 请求: grantType={}, clientId={}", request.getGrantType(), request.getClientId());
// 1. 验证 grant_type
if (StrUtil.isBlank(request.getGrantType())) {
throw new OAuth2Exception("grant_type 不能为空");
}
// 2. 验证客户端
OAuth2Client client = clientService.getClientByClientId(request.getClientId());
clientService.validateClientSecret(client, request.getClientSecret());
clientService.validateGrantType(client, request.getGrantType());
// 3. 根据授权类型处理
return switch (request.getGrantType()) {
case "authorization_code" -> handleAuthorizationCode(client, request);
case "refresh_token" -> handleRefreshToken(client, request);
default -> throw new OAuth2Exception("不支持的 grant_type: " + request.getGrantType());
};
}
/**
* 处理授权码流程
* @param client 客户端
* @param request Token 请求
* @return Token 响应
*/
private OAuth2TokenResponse handleAuthorizationCode(OAuth2Client client, OAuth2TokenRequest request) {
// 1. 验证必需参数
if (StrUtil.isBlank(request.getCode())) {
throw new OAuth2Exception("code 不能为空");
}
if (StrUtil.isBlank(request.getRedirectUri())) {
throw new OAuth2Exception("redirect_uri 不能为空");
}
// 2. 验证并消费授权码
Map<String, Object> codeData = authorizationService.validateAndConsumeCode(request.getCode());
// 3. 验证授权码绑定
authorizationService.validateCodeBinding(codeData, request.getClientId(), request.getRedirectUri());
// 4. 验证 PKCE
String codeChallenge = tokenService.extractFromCodeData(codeData, "codeChallenge");
String codeChallengeMethod = tokenService.extractFromCodeData(codeData, "codeChallengeMethod");
if (StrUtil.isNotBlank(codeChallenge)) {
if (StrUtil.isBlank(request.getCodeVerifier())) {
throw new OAuth2Exception("使用了 PKCE必须提供 code_verifier");
}
pkceService.validatePKCE(request.getCodeVerifier(), codeChallenge, codeChallengeMethod);
}
else if (client.getRequirePkce()) {
throw new OAuth2Exception("该客户端必须使用 PKCE");
}
// 5. 提取授权码数据
Long userId = tokenService.extractUserIdFromCodeData(codeData);
String scope = tokenService.extractFromCodeData(codeData, "scope");
String nonce = tokenService.extractFromCodeData(codeData, "nonce");
// 6. 生成 Token
return tokenService.generateTokenResponse(client, userId, scope, nonce);
}
/**
* 处理刷新 Token 流程
* @param client 客户端
* @param request Token 请求
* @return Token 响应
*/
private OAuth2TokenResponse handleRefreshToken(OAuth2Client client, OAuth2TokenRequest request) {
// 1. 验证必需参数
if (StrUtil.isBlank(request.getRefreshToken())) {
throw new OAuth2Exception("refresh_token 不能为空");
}
// 2. 验证 scope不能超出原范围
String scope = request.getScope();
if (StrUtil.isNotBlank(scope)) {
scope = clientService.validateScope(client, scope);
}
// 3. 刷新 Token
return tokenService.refreshToken(client, request.getRefreshToken(), scope);
}
}

View File

@@ -0,0 +1,109 @@
package cn.meowrain.aioj.backend.auth.oauth2.controller;
import cn.hutool.core.util.StrUtil;
import cn.meowrain.aioj.backend.auth.clients.UserClient;
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.auth.oauth2.dto.UserInfoResponse;
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import io.jsonwebtoken.Claims;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* OAuth2 UserInfo 端点OIDC 返回当前用户的信息
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@RestController
@RequestMapping("/oauth2/userinfo")
@RequiredArgsConstructor
@Tag(name = "OAuth2 UserInfo 端点", description = "OIDC UserInfo 相关接口")
public class OAuth2UserInfoController {
private final JwtUtil jwtUtil;
private final UserClient userClient;
/**
* UserInfo 端点
* @param authorization Authorization HeaderBearer Token
* @return 用户信息
*/
@GetMapping
@Operation(summary = "获取用户信息", description = "根据 Access Token 返回用户信息OIDC")
public UserInfoResponse userInfo(@RequestHeader("Authorization") String authorization) {
log.info("收到 UserInfo 请求");
// 1. 提取 Access Token
String accessToken = extractBearerToken(authorization);
// 2. 验证 Token
if (!jwtUtil.isTokenValid(accessToken)) {
log.warn("Access Token 无效或已过期");
throw new OAuth2Exception("invalid_token", "Access Token 无效或已过期", 401);
}
// 3. 解析 Token 获取用户 ID
Claims claims = jwtUtil.parseClaims(accessToken);
String userIdStr = claims.get("userId", String.class);
if (userIdStr == null) {
userIdStr = claims.getSubject();
}
Long userId = Long.parseLong(userIdStr);
// 4. 调用 user-service 获取用户信息
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
if (userResult == null || userResult.getData() == null) {
log.error("获取用户信息失败: userId={}", userId);
throw new OAuth2Exception("server_error", "获取用户信息失败", 500);
}
UserAuthRespDTO user = userResult.getData();
// 5. 构造 UserInfo 响应
// 注意:根据 scope 返回不同的字段,这里简化处理返回所有字段
UserInfoResponse response = UserInfoResponse.builder()
.sub(String.valueOf(user.getId())) // Subject用户唯一标识
.name(user.getUserName()) // 用户全名
.preferredUsername(user.getUserAccount()) // 用户名
.email(null) // TODO: 从用户信息中获取邮箱
.emailVerified(false) // TODO: 从用户信息中获取邮箱验证状态
.picture(user.getUserAvatar()) // 用户头像
.role(user.getUserRole()) // 用户角色
.build();
log.info("返回 UserInfo: userId={}, username={}", userId, user.getUserAccount());
return response;
}
/**
* 从 Authorization Header 中提取 Bearer Token
* @param authorization Authorization Header
* @return Access Token
*/
private String extractBearerToken(String authorization) {
if (StrUtil.isBlank(authorization)) {
throw new OAuth2Exception("invalid_request", "缺少 Authorization Header", 401);
}
if (!authorization.startsWith("Bearer ")) {
throw new OAuth2Exception("invalid_request", "Authorization Header 格式错误,应为 'Bearer {token}'", 401);
}
return authorization.substring(7);
}
}

View File

@@ -0,0 +1,82 @@
package cn.meowrain.aioj.backend.auth.oauth2.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* OAuth2 Well-Known 配置端点OIDC Discovery 返回 OAuth2/OIDC 服务器的元数据配置
*
* @author meowrain
* @since 2025-12-14
*/
@RestController
@RequestMapping("/.well-known")
@RequiredArgsConstructor
@Tag(name = "OAuth2 Well-Known 端点", description = "OIDC Discovery 相关接口")
public class OAuth2WellKnownController {
// TODO: 从配置文件读取这些值
private static final String ISSUER = "http://localhost:10011/api";
/**
* OIDC Discovery 端点
* @return OIDC 配置元数据
*/
@GetMapping("/openid-configuration")
@Operation(summary = "OIDC Discovery", description = "返回 OpenID Connect 配置元数据")
public Map<String, Object> openidConfiguration() {
Map<String, Object> config = new HashMap<>();
// 1. 基本信息
config.put("issuer", ISSUER);
// 2. 端点 URL
config.put("authorization_endpoint", ISSUER + "/oauth2/authorize");
config.put("token_endpoint", ISSUER + "/oauth2/token");
config.put("userinfo_endpoint", ISSUER + "/oauth2/userinfo");
config.put("end_session_endpoint", ISSUER + "/oauth2/logout");
config.put("jwks_uri", ISSUER + "/oauth2/jwks"); // TODO: 实现 JWKS 端点
// 3. 支持的响应类型
config.put("response_types_supported", List.of("code"));
// 4. 支持的授权类型
config.put("grant_types_supported", List.of("authorization_code", "refresh_token"));
// 5. 支持的 Subject 类型
config.put("subject_types_supported", List.of("public"));
// 6. 支持的 ID Token 签名算法
config.put("id_token_signing_alg_values_supported", List.of("HS256"));
// 7. 支持的作用域
config.put("scopes_supported", List.of("openid", "profile", "email"));
// 8. 支持的 Token 端点认证方法
config.put("token_endpoint_auth_methods_supported", List.of("client_secret_post", "client_secret_basic"));
// 9. 支持的 PKCE 方法
config.put("code_challenge_methods_supported", List.of("S256", "plain"));
// 10. 支持的 Claims
config.put("claims_supported",
List.of("sub", "name", "preferred_username", "email", "email_verified", "picture", "role"));
// 11. 其他配置
config.put("response_modes_supported", List.of("query", "fragment"));
config.put("request_parameter_supported", false);
config.put("request_uri_parameter_supported", false);
config.put("require_request_uri_registration", false);
return config;
}
}

View File

@@ -0,0 +1,59 @@
package cn.meowrain.aioj.backend.auth.oauth2.dto;
import lombok.Data;
/**
* OAuth2 授权请求 DTO 对应 /oauth2/authorize 端点的请求参数
*
* @author meowrain
* @since 2025-12-14
*/
@Data
public class OAuth2AuthorizeRequest {
/**
* 响应类型(固定为 "code"
*/
private String responseType;
/**
* 客户端 ID
*/
private String clientId;
/**
* 重定向 URI
*/
private String redirectUri;
/**
* 授权范围(空格分隔,如 "openid profile email"
*/
private String scope;
/**
* 状态参数(用于 CSRF 防护)
*/
private String state;
/**
* PKCE Code Challenge
*/
private String codeChallenge;
/**
* PKCE Code Challenge Method默认 S256
*/
private String codeChallengeMethod;
/**
* Nonce 参数(用于 ID Token 防重放)
*/
private String nonce;
/**
* 用户 ID授权后由服务器设置
*/
private Long userId;
}

View File

@@ -0,0 +1,54 @@
package cn.meowrain.aioj.backend.auth.oauth2.dto;
import lombok.Data;
/**
* OAuth2 Token 请求 DTO 对应 /oauth2/token 端点的请求参数
*
* @author meowrain
* @since 2025-12-14
*/
@Data
public class OAuth2TokenRequest {
/**
* 授权类型authorization_code 或 refresh_token
*/
private String grantType;
/**
* 授权码grant_type=authorization_code 时必需)
*/
private String code;
/**
* 重定向 URI必须与授权请求时一致
*/
private String redirectUri;
/**
* 客户端 ID
*/
private String clientId;
/**
* 客户端密钥(机密客户端必需)
*/
private String clientSecret;
/**
* PKCE Code Verifier
*/
private String codeVerifier;
/**
* Refresh Tokengrant_type=refresh_token 时必需)
*/
private String refreshToken;
/**
* 授权范围refresh_token 时可选,不能超出原范围)
*/
private String scope;
}

View File

@@ -0,0 +1,54 @@
package cn.meowrain.aioj.backend.auth.oauth2.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
/**
* OAuth2 Token 响应 DTO 对应 /oauth2/token 端点的响应
*
* @author meowrain
* @since 2025-12-14
*/
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OAuth2TokenResponse {
/**
* 访问令牌
*/
@JsonProperty("access_token")
private String accessToken;
/**
* Token 类型(固定为 "Bearer"
*/
@JsonProperty("token_type")
private String tokenType;
/**
* 过期时间(秒)
*/
@JsonProperty("expires_in")
private Integer expiresIn;
/**
* 刷新令牌
*/
@JsonProperty("refresh_token")
private String refreshToken;
/**
* 授权范围(空格分隔)
*/
private String scope;
/**
* ID TokenOIDC
*/
@JsonProperty("id_token")
private String idToken;
}

View File

@@ -0,0 +1,56 @@
package cn.meowrain.aioj.backend.auth.oauth2.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
/**
* OIDC UserInfo 响应 DTO 对应 /oauth2/userinfo 端点的响应
*
* @author meowrain
* @since 2025-12-14
*/
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserInfoResponse {
/**
* Subject - 用户唯一标识符
*/
private String sub;
/**
* 用户全名
*/
private String name;
/**
* 首选用户名
*/
@JsonProperty("preferred_username")
private String preferredUsername;
/**
* 电子邮件
*/
private String email;
/**
* 电子邮件是否已验证
*/
@JsonProperty("email_verified")
private Boolean emailVerified;
/**
* 头像图片 URL
*/
private String picture;
/**
* 用户角色
*/
private String role;
}

View File

@@ -0,0 +1,96 @@
package cn.meowrain.aioj.backend.auth.oauth2.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* OAuth2 客户端实体
*
* @author meowrain
* @since 2025-12-14
*/
@Data
@TableName("oauth2_client")
public class OAuth2Client {
/**
* 主键
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 客户端 ID
*/
private String clientId;
/**
* 客户端密钥BCrypt 加密,公共客户端为 NULL
*/
private String clientSecret;
/**
* 客户端名称
*/
private String clientName;
/**
* 客户端类型confidential机密/ public公共
*/
private String clientType;
/**
* 重定向 URI 列表JSON 数组格式)
*/
private String redirectUris;
/**
* 登出后重定向 URI 列表JSON 数组格式)
*/
private String postLogoutRedirectUris;
/**
* 允许的作用域(逗号分隔)
*/
private String allowedScopes;
/**
* 允许的授权类型(逗号分隔)
*/
private String allowedGrantTypes;
/**
* Access Token 有效期(秒)
*/
private Integer accessTokenTtl;
/**
* Refresh Token 有效期(秒)
*/
private Integer refreshTokenTtl;
/**
* 是否要求 PKCE
*/
private Boolean requirePkce;
/**
* 是否启用
*/
private Boolean isEnabled;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,26 @@
package cn.meowrain.aioj.backend.auth.oauth2.exception;
/**
* 无效客户端异常 当客户端 ID 不存在、客户端密钥错误或客户端被禁用时抛出
*
* @author meowrain
* @since 2025-12-14
*/
public class InvalidClientException extends OAuth2Exception {
/**
* 构造函数
* @param errorDescription 错误描述
*/
public InvalidClientException(String errorDescription) {
super("invalid_client", errorDescription, 401); // 401 Unauthorized
}
/**
* 默认构造函数
*/
public InvalidClientException() {
this("客户端认证失败");
}
}

View File

@@ -0,0 +1,26 @@
package cn.meowrain.aioj.backend.auth.oauth2.exception;
/**
* 无效授权异常 当授权码无效、过期或 PKCE 验证失败时抛出
*
* @author meowrain
* @since 2025-12-14
*/
public class InvalidGrantException extends OAuth2Exception {
/**
* 构造函数
* @param errorDescription 错误描述
*/
public InvalidGrantException(String errorDescription) {
super("invalid_grant", errorDescription, 400); // 400 Bad Request
}
/**
* 默认构造函数
*/
public InvalidGrantException() {
this("授权码无效或已过期");
}
}

View File

@@ -0,0 +1,62 @@
package cn.meowrain.aioj.backend.auth.oauth2.exception;
/**
* OAuth2 异常基类 用于统一处理 OAuth2 相关的异常
*
* @author meowrain
* @since 2025-12-14
*/
public class OAuth2Exception extends RuntimeException {
private final String error;
private final String errorDescription;
private final int httpStatus;
/**
* 构造函数
* @param error 错误代码(符合 OAuth2 规范)
* @param errorDescription 错误描述
*/
public OAuth2Exception(String error, String errorDescription) {
super(errorDescription);
this.error = error;
this.errorDescription = errorDescription;
this.httpStatus = 400; // 默认 400 Bad Request
}
/**
* 构造函数(带 HTTP 状态码)
* @param error 错误代码
* @param errorDescription 错误描述
* @param httpStatus HTTP 状态码
*/
public OAuth2Exception(String error, String errorDescription, int httpStatus) {
super(errorDescription);
this.error = error;
this.errorDescription = errorDescription;
this.httpStatus = httpStatus;
}
/**
* 简化构造函数(仅错误描述)
* @param errorDescription 错误描述
*/
public OAuth2Exception(String errorDescription) {
this("invalid_request", errorDescription);
}
public String getError() {
return error;
}
public String getErrorDescription() {
return errorDescription;
}
public int getHttpStatus() {
return httpStatus;
}
}

View File

@@ -0,0 +1,16 @@
package cn.meowrain.aioj.backend.auth.oauth2.mapper;
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* OAuth2 客户端 Mapper
*
* @author meowrain
* @since 2025-12-14
*/
@Mapper
public interface OAuth2ClientMapper extends BaseMapper<OAuth2Client> {
}

View File

@@ -0,0 +1,127 @@
package cn.meowrain.aioj.backend.auth.oauth2.service;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.json.JSONUtil;
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2AuthorizeRequest;
import cn.meowrain.aioj.backend.auth.oauth2.exception.InvalidGrantException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* OAuth2 授权码管理服务 负责授权码的生成、存储、验证和消费
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2AuthorizationService {
private final StringRedisTemplate redisTemplate;
/**
* 授权码有效期(秒)
*/
private static final int AUTH_CODE_TTL = 600; // 10 分钟
/**
* 授权码长度
*/
private static final int AUTH_CODE_LENGTH = 32;
/**
* 生成授权码
* @param request 授权请求
* @return 授权码
*/
public String generateAuthorizationCode(OAuth2AuthorizeRequest request) {
// 1. 生成 32 字符随机授权码
String code = RandomUtil.randomString(AUTH_CODE_LENGTH);
// 2. 构造授权码数据
Map<String, Object> codeData = new HashMap<>();
codeData.put("code", code);
codeData.put("clientId", request.getClientId());
codeData.put("userId", request.getUserId());
codeData.put("redirectUri", request.getRedirectUri());
codeData.put("scope", request.getScope());
codeData.put("codeChallenge", request.getCodeChallenge());
codeData.put("codeChallengeMethod", request.getCodeChallengeMethod());
codeData.put("nonce", request.getNonce());
codeData.put("expiresAt", System.currentTimeMillis() + AUTH_CODE_TTL * 1000);
// 3. 存储到 Redis
String key = String.format(RedisKeyConstants.OAUTH2_AUTH_CODE_PREFIX, code);
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(codeData), AUTH_CODE_TTL, TimeUnit.SECONDS);
log.info("生成授权码: code={}, clientId={}, userId={}", code, request.getClientId(), request.getUserId());
return code;
}
/**
* 验证并消费授权码(一次性使用)
* @param code 授权码
* @return 授权码数据
* @throws InvalidGrantException 授权码无效或已过期
*/
public Map<String, Object> validateAndConsumeCode(String code) {
String key = String.format(RedisKeyConstants.OAUTH2_AUTH_CODE_PREFIX, code);
// 1. 从 Redis 获取授权码数据
String data = redisTemplate.opsForValue().get(key);
if (data == null) {
log.warn("授权码无效或已过期: {}", code);
throw new InvalidGrantException("授权码无效或已过期");
}
// 2. 立即删除授权码(一次性使用)
redisTemplate.delete(key);
// 3. 解析数据
Map<String, Object> codeData = JSONUtil.toBean(data, Map.class);
// 4. 检查是否过期
long expiresAt = ((Number) codeData.get("expiresAt")).longValue();
if (System.currentTimeMillis() > expiresAt) {
log.warn("授权码已过期: {}", code);
throw new InvalidGrantException("授权码已过期");
}
log.info("授权码验证成功并已消费: code={}, clientId={}, userId={}", code, codeData.get("clientId"),
codeData.get("userId"));
return codeData;
}
/**
* 验证授权码绑定的参数
* @param codeData 授权码数据
* @param clientId 客户端ID
* @param redirectUri 重定向URI
* @throws InvalidGrantException 验证失败
*/
public void validateCodeBinding(Map<String, Object> codeData, String clientId, String redirectUri) {
// 验证 client_id
if (!clientId.equals(codeData.get("clientId"))) {
log.warn("授权码绑定的 client_id 不匹配: expected={}, actual={}", codeData.get("clientId"), clientId);
throw new InvalidGrantException("授权码与客户端不匹配");
}
// 验证 redirect_uri
if (!redirectUri.equals(codeData.get("redirectUri"))) {
log.warn("授权码绑定的 redirect_uri 不匹配: expected={}, actual={}", codeData.get("redirectUri"), redirectUri);
throw new InvalidGrantException("授权码与重定向URI不匹配");
}
}
}

View File

@@ -0,0 +1,195 @@
package cn.meowrain.aioj.backend.auth.oauth2.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
import cn.meowrain.aioj.backend.auth.oauth2.exception.InvalidClientException;
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
import cn.meowrain.aioj.backend.auth.oauth2.mapper.OAuth2ClientMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
/**
* OAuth2 客户端服务 负责客户端验证和管理
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2ClientService {
private final OAuth2ClientMapper clientMapper;
private final PasswordEncoder passwordEncoder;
/**
* 根据 client_id 获取客户端
* @param clientId 客户端 ID
* @return 客户端信息
* @throws InvalidClientException 客户端不存在或被禁用
*/
public OAuth2Client getClientByClientId(String clientId) {
if (StrUtil.isBlank(clientId)) {
throw new InvalidClientException("client_id 不能为空");
}
OAuth2Client client = clientMapper
.selectOne(new LambdaQueryWrapper<OAuth2Client>().eq(OAuth2Client::getClientId, clientId));
if (client == null) {
log.warn("客户端不存在: {}", clientId);
throw new InvalidClientException("客户端不存在");
}
if (!client.getIsEnabled()) {
log.warn("客户端已被禁用: {}", clientId);
throw new InvalidClientException("客户端已被禁用");
}
return client;
}
/**
* 验证客户端密钥(机密客户端)
* @param client 客户端信息
* @param clientSecret 客户端密钥
* @throws InvalidClientException 验证失败
*/
public void validateClientSecret(OAuth2Client client, String clientSecret) {
// 公共客户端不需要验证密钥
if ("public".equals(client.getClientType())) {
if (StrUtil.isNotBlank(clientSecret)) {
log.warn("公共客户端不应该提供 client_secret: {}", client.getClientId());
}
return;
}
// 机密客户端必须提供密钥
if (StrUtil.isBlank(clientSecret)) {
throw new InvalidClientException("机密客户端必须提供 client_secret");
}
// 验证密钥
if (!passwordEncoder.matches(clientSecret, client.getClientSecret())) {
log.warn("客户端密钥错误: {}", client.getClientId());
throw new InvalidClientException("客户端认证失败");
}
}
/**
* 验证重定向 URI
* @param client 客户端信息
* @param redirectUri 重定向 URI
* @throws OAuth2Exception 验证失败
*/
public void validateRedirectUri(OAuth2Client client, String redirectUri) {
if (StrUtil.isBlank(redirectUri)) {
throw new OAuth2Exception("redirect_uri 不能为空");
}
// 解析客户端配置的重定向 URI 列表
List<String> allowedUris = parseJsonArray(client.getRedirectUris());
if (!allowedUris.contains(redirectUri)) {
log.warn("无效的 redirect_uri: {} for client: {}", redirectUri, client.getClientId());
throw new OAuth2Exception("无效的 redirect_uri");
}
}
/**
* 验证登出重定向 URI
* @param client 客户端信息
* @param postLogoutRedirectUri 登出重定向 URI
* @throws OAuth2Exception 验证失败
*/
public void validatePostLogoutRedirectUri(OAuth2Client client, String postLogoutRedirectUri) {
if (StrUtil.isBlank(postLogoutRedirectUri)) {
// 登出重定向 URI 可选
return;
}
if (StrUtil.isBlank(client.getPostLogoutRedirectUris())) {
throw new OAuth2Exception("客户端未配置 post_logout_redirect_uri");
}
List<String> allowedUris = parseJsonArray(client.getPostLogoutRedirectUris());
if (!allowedUris.contains(postLogoutRedirectUri)) {
log.warn("无效的 post_logout_redirect_uri: {} for client: {}", postLogoutRedirectUri, client.getClientId());
throw new OAuth2Exception("无效的 post_logout_redirect_uri");
}
}
/**
* 验证授权类型
* @param client 客户端信息
* @param grantType 授权类型
* @throws OAuth2Exception 验证失败
*/
public void validateGrantType(OAuth2Client client, String grantType) {
if (StrUtil.isBlank(grantType)) {
throw new OAuth2Exception("grant_type 不能为空");
}
List<String> allowedGrantTypes = Arrays.asList(client.getAllowedGrantTypes().split(","));
if (!allowedGrantTypes.contains(grantType)) {
log.warn("客户端 {} 不支持授权类型: {}", client.getClientId(), grantType);
throw new OAuth2Exception("不支持的 grant_type");
}
}
/**
* 验证作用域
* @param client 客户端信息
* @param requestedScope 请求的作用域(空格分隔)
* @return 验证后的作用域(如果为空则返回默认作用域)
* @throws OAuth2Exception 验证失败
*/
public String validateScope(OAuth2Client client, String requestedScope) {
// 如果未指定作用域,返回默认作用域
if (StrUtil.isBlank(requestedScope)) {
return client.getAllowedScopes().replace(",", " ");
}
// 解析允许的作用域
List<String> allowedScopes = Arrays.asList(client.getAllowedScopes().split(","));
// 解析请求的作用域
String[] requestedScopes = requestedScope.split(" ");
// 验证每个请求的作用域
for (String scope : requestedScopes) {
if (!allowedScopes.contains(scope.trim())) {
log.warn("客户端 {} 不支持作用域: {}", client.getClientId(), scope);
throw new OAuth2Exception("不支持的 scope: " + scope);
}
}
return requestedScope;
}
/**
* 解析 JSON 数组字符串
* @param jsonArray JSON 数组字符串
* @return 列表
*/
private List<String> parseJsonArray(String jsonArray) {
try {
return JSONUtil.toList(jsonArray, String.class);
}
catch (Exception e) {
log.error("解析 JSON 数组失败: {}", jsonArray, e);
return List.of();
}
}
}

View File

@@ -0,0 +1,225 @@
package cn.meowrain.aioj.backend.auth.oauth2.service;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.json.JSONUtil;
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* OAuth2 会话管理服务 负责会话管理和 Token 黑名单(用于单点登出)
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2SessionService {
private final StringRedisTemplate redisTemplate;
private final JwtUtil jwtUtil;
/**
* 会话有效期7天
*/
private static final long SESSION_TTL = 604800L;
/**
* 创建会话
* @param userId 用户ID
* @param clientId 客户端ID
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
* @return 会话ID
*/
public String createSession(Long userId, String clientId, String accessToken, String refreshToken) {
// 1. 生成会话ID
String sessionId = RandomUtil.randomString(32);
// 2. 构造会话数据
Map<String, Object> sessionData = new HashMap<>();
sessionData.put("sessionId", sessionId);
sessionData.put("userId", userId);
sessionData.put("createdAt", System.currentTimeMillis());
// 3. 构造客户端Token信息
Map<String, String> clientTokens = new HashMap<>();
clientTokens.put("clientId", clientId);
clientTokens.put("accessToken", accessToken);
clientTokens.put("refreshToken", refreshToken);
clientTokens.put("issuedAt", String.valueOf(System.currentTimeMillis()));
// 将客户端Token列表添加到会话
List<Map<String, String>> clients = new ArrayList<>();
clients.add(clientTokens);
sessionData.put("clients", clients);
// 4. 存储会话到 Redis
String sessionKey = String.format(RedisKeyConstants.OAUTH2_SESSION_PREFIX, sessionId);
redisTemplate.opsForValue().set(sessionKey, JSONUtil.toJsonStr(sessionData), SESSION_TTL, TimeUnit.SECONDS);
// 5. 维护用户->会话映射(用于查询用户的所有会话)
String userSessionsKey = String.format(RedisKeyConstants.OAUTH2_USER_SESSIONS_PREFIX, userId);
redisTemplate.opsForSet().add(userSessionsKey, sessionId);
redisTemplate.expire(userSessionsKey, SESSION_TTL, TimeUnit.SECONDS);
log.info("创建会话: sessionId={}, userId={}, clientId={}", sessionId, userId, clientId);
return sessionId;
}
/**
* 添加客户端到现有会话
* @param sessionId 会话ID
* @param clientId 客户端ID
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
*/
public void addClientToSession(String sessionId, String clientId, String accessToken, String refreshToken) {
String sessionKey = String.format(RedisKeyConstants.OAUTH2_SESSION_PREFIX, sessionId);
String sessionDataStr = redisTemplate.opsForValue().get(sessionKey);
if (sessionDataStr == null) {
log.warn("会话不存在: {}", sessionId);
return;
}
// 解析会话数据
Map<String, Object> sessionData = JSONUtil.toBean(sessionDataStr, Map.class);
@SuppressWarnings("unchecked")
List<Map<String, String>> clients = (List<Map<String, String>>) sessionData.get("clients");
if (clients == null) {
clients = new ArrayList<>();
}
// 添加新的客户端Token
Map<String, String> clientTokens = new HashMap<>();
clientTokens.put("clientId", clientId);
clientTokens.put("accessToken", accessToken);
clientTokens.put("refreshToken", refreshToken);
clientTokens.put("issuedAt", String.valueOf(System.currentTimeMillis()));
clients.add(clientTokens);
sessionData.put("clients", clients);
// 更新会话
redisTemplate.opsForValue().set(sessionKey, JSONUtil.toJsonStr(sessionData), SESSION_TTL, TimeUnit.SECONDS);
log.info("添加客户端到会话: sessionId={}, clientId={}", sessionId, clientId);
}
/**
* 撤销用户的所有会话(单点登出)
* @param userId 用户ID
*/
public void revokeAllUserSessions(Long userId) {
log.info("撤销用户所有会话: userId={}", userId);
// 1. 获取用户的所有会话ID
String userSessionsKey = String.format(RedisKeyConstants.OAUTH2_USER_SESSIONS_PREFIX, userId);
Set<String> sessionIds = redisTemplate.opsForSet().members(userSessionsKey);
if (sessionIds == null || sessionIds.isEmpty()) {
log.info("用户没有活跃会话: userId={}", userId);
return;
}
int revokedTokenCount = 0;
// 2. 遍历每个会话提取所有Token并加入黑名单
for (String sessionId : sessionIds) {
String sessionKey = String.format(RedisKeyConstants.OAUTH2_SESSION_PREFIX, sessionId);
String sessionDataStr = redisTemplate.opsForValue().get(sessionKey);
if (sessionDataStr != null) {
Map<String, Object> sessionData = JSONUtil.toBean(sessionDataStr, Map.class);
@SuppressWarnings("unchecked")
List<Map<String, String>> clients = (List<Map<String, String>>) sessionData.get("clients");
if (clients != null) {
for (Map<String, String> client : clients) {
String accessToken = client.get("accessToken");
String refreshToken = client.get("refreshToken");
// 将Token加入黑名单
if (accessToken != null) {
blacklistToken(accessToken);
revokedTokenCount++;
}
if (refreshToken != null) {
blacklistToken(refreshToken);
revokedTokenCount++;
}
}
}
// 删除会话
redisTemplate.delete(sessionKey);
}
}
// 3. 清空用户会话映射
redisTemplate.delete(userSessionsKey);
// 4. 删除用户的 Refresh Token
String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
redisTemplate.delete(refreshTokenKey);
log.info("撤销用户会话完成: userId={}, sessionCount={}, tokenCount={}", userId, sessionIds.size(), revokedTokenCount);
}
/**
* 检查 Token 是否在黑名单中
* @param token Token
* @return 是否在黑名单
*/
public boolean isTokenBlacklisted(String token) {
String tokenHash = DigestUtil.sha256Hex(token);
String key = String.format(RedisKeyConstants.OAUTH2_TOKEN_BLACKLIST_PREFIX, tokenHash);
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 将 Token 加入黑名单
* @param token Token
*/
private void blacklistToken(String token) {
try {
// 1. 计算Token的SHA256哈希
String tokenHash = DigestUtil.sha256Hex(token);
String key = String.format(RedisKeyConstants.OAUTH2_TOKEN_BLACKLIST_PREFIX, tokenHash);
// 2. 解析Token获取过期时间
Claims claims = jwtUtil.parseClaims(token);
long expiresAt = claims.getExpiration().getTime();
long now = System.currentTimeMillis();
// 3. 计算Token剩余有效期
long ttl = (expiresAt - now) / 1000;
if (ttl > 0) {
// 4. 加入黑名单TTL设置为Token的剩余有效期
redisTemplate.opsForValue().set(key, "1", ttl, TimeUnit.SECONDS);
log.debug("Token加入黑名单: tokenHash={}, ttl={}秒", tokenHash, ttl);
}
else {
log.debug("Token已过期无需加入黑名单: tokenHash={}", tokenHash);
}
}
catch (Exception e) {
log.error("Token加入黑名单失败", e);
}
}
}

View File

@@ -0,0 +1,166 @@
package cn.meowrain.aioj.backend.auth.oauth2.service;
import cn.meowrain.aioj.backend.auth.clients.UserClient;
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2TokenResponse;
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* OAuth2 Token 服务 负责 Token 的生成、刷新和验证
*
* @author meowrain
* @since 2025-12-14
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2TokenService {
private final JwtUtil jwtUtil;
private final UserClient userClient;
private final StringRedisTemplate redisTemplate;
private final OAuth2SessionService sessionService;
/**
* 生成 Token 响应(授权码流程)
* @param client 客户端信息
* @param userId 用户ID
* @param scope 授权范围
* @param nonce Nonce参数用于ID Token
* @return Token响应
*/
public OAuth2TokenResponse generateTokenResponse(OAuth2Client client, Long userId, String scope, String nonce) {
// 1. 调用 user-service 获取用户信息
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
if (userResult == null || userResult.getData() == null) {
throw new RuntimeException("获取用户信息失败");
}
UserAuthRespDTO user = userResult.getData();
// 2. 生成 Access Token
String accessToken = jwtUtil.generateAccessToken(user);
// 3. 生成 Refresh Token
String refreshToken = jwtUtil.generateRefreshToken(userId);
// 4. 生成 ID TokenOIDC
String idToken = null;
if (scope != null && scope.contains("openid")) {
idToken = jwtUtil.generateIdToken(user, client.getClientId(), nonce);
}
// 5. 存储 Refresh Token 到 Redis
String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, client.getRefreshTokenTtl(), TimeUnit.SECONDS);
// 6. 创建会话(用于单点登出)
sessionService.createSession(userId, client.getClientId(), accessToken, refreshToken);
log.info("签发Token: clientId={}, userId={}, scope={}", client.getClientId(), userId, scope);
// 7. 构造响应
return OAuth2TokenResponse.builder()
.accessToken(accessToken)
.tokenType("Bearer")
.expiresIn(client.getAccessTokenTtl())
.refreshToken(refreshToken)
.scope(scope)
.idToken(idToken)
.build();
}
/**
* 刷新 Token
* @param client 客户端信息
* @param refreshToken Refresh Token
* @param scope 授权范围(可选,不能超出原范围)
* @return Token响应
*/
public OAuth2TokenResponse refreshToken(OAuth2Client client, String refreshToken, String scope) {
// 1. 验证 Refresh Token
if (!jwtUtil.isTokenValid(refreshToken)) {
throw new RuntimeException("Refresh Token 无效或已过期");
}
// 2. 解析 Refresh Token 获取用户ID
Long userId = Long.parseLong(jwtUtil.parseClaims(refreshToken).getSubject());
// 3. 从 Redis 验证 Refresh Token
String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
String storedToken = redisTemplate.opsForValue().get(refreshTokenKey);
if (!refreshToken.equals(storedToken)) {
log.warn("Refresh Token 不匹配: userId={}", userId);
throw new RuntimeException("Refresh Token 无效");
}
// 4. 调用 user-service 获取最新用户信息
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
if (userResult == null || userResult.getData() == null) {
throw new RuntimeException("获取用户信息失败");
}
UserAuthRespDTO user = userResult.getData();
// 5. 生成新的 Access Token
String newAccessToken = jwtUtil.generateAccessToken(user);
// 6. 生成新的 Refresh TokenRefresh Token Rotation
String newRefreshToken = jwtUtil.generateRefreshToken(userId);
// 7. 更新 Redis 中的 Refresh Token
redisTemplate.opsForValue()
.set(refreshTokenKey, newRefreshToken, client.getRefreshTokenTtl(), TimeUnit.SECONDS);
log.info("刷新Token: clientId={}, userId={}", client.getClientId(), userId);
// 8. 构造响应(刷新时不返回 ID Token
return OAuth2TokenResponse.builder()
.accessToken(newAccessToken)
.tokenType("Bearer")
.expiresIn(client.getAccessTokenTtl())
.refreshToken(newRefreshToken)
.scope(scope)
.build();
}
/**
* 从授权码数据中提取信息
* @param codeData 授权码数据
* @param key 键名
* @return 字符串值
*/
public String extractFromCodeData(Map<String, Object> codeData, String key) {
Object value = codeData.get(key);
return value != null ? value.toString() : null;
}
/**
* 从授权码数据中提取用户ID
* @param codeData 授权码数据
* @return 用户ID
*/
public Long extractUserIdFromCodeData(Map<String, Object> codeData) {
Object userId = codeData.get("userId");
if (userId instanceof Number) {
return ((Number) userId).longValue();
}
return Long.parseLong(userId.toString());
}
}

View File

@@ -0,0 +1,124 @@
package cn.meowrain.aioj.backend.auth.oauth2.service;
import cn.meowrain.aioj.backend.auth.oauth2.exception.InvalidGrantException;
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
import cn.hutool.core.util.StrUtil;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* PKCE (Proof Key for Code Exchange) 验证服务 实现 RFC 7636 规范
*
* @author meowrain
* @since 2025-12-14
*/
@Service
public class PKCEService {
private static final String S256_METHOD = "S256";
private static final String PLAIN_METHOD = "plain";
/**
* 验证 PKCE
* @param codeVerifier Code Verifier客户端生成的随机字符串43-128字符
* @param codeChallenge Code Challenge授权请求时发送的挑战值
* @param codeChallengeMethod 挑战方法S256 或 plain
* @throws InvalidGrantException 验证失败时抛出
*/
public void validatePKCE(String codeVerifier, String codeChallenge, String codeChallengeMethod) {
// 1. 验证参数
if (StrUtil.isBlank(codeVerifier)) {
throw new InvalidGrantException("code_verifier 不能为空");
}
if (StrUtil.isBlank(codeChallenge)) {
throw new InvalidGrantException("code_challenge 不能为空");
}
if (StrUtil.isBlank(codeChallengeMethod)) {
throw new InvalidGrantException("code_challenge_method 不能为空");
}
// 2. 验证 code_verifier 长度RFC 7636: 43-128 字符)
if (codeVerifier.length() < 43 || codeVerifier.length() > 128) {
throw new InvalidGrantException("code_verifier 长度必须在 43-128 字符之间");
}
// 3. 根据方法验证
String computedChallenge;
switch (codeChallengeMethod) {
case S256_METHOD:
computedChallenge = computeS256Challenge(codeVerifier);
break;
case PLAIN_METHOD:
// plain 方法challenge = verifier不推荐使用
computedChallenge = codeVerifier;
break;
default:
throw new OAuth2Exception("不支持的 code_challenge_method: " + codeChallengeMethod);
}
// 4. 比对 challenge
if (!computedChallenge.equals(codeChallenge)) {
throw new InvalidGrantException("code_verifier 验证失败");
}
}
/**
* 计算 S256 方法的 Code Challenge challenge = BASE64URL(SHA256(verifier))
* @param codeVerifier Code Verifier
* @return Code Challenge
*/
private String computeS256Challenge(String codeVerifier) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
}
catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 算法不可用", e);
}
}
/**
* 验证 PKCE 参数(在授权请求阶段)
* @param codeChallenge Code Challenge
* @param codeChallengeMethod 挑战方法
* @param requirePkce 是否强制要求 PKCE
* @throws OAuth2Exception 验证失败时抛出
*/
public void validatePKCERequest(String codeChallenge, String codeChallengeMethod, boolean requirePkce) {
// 如果强制要求 PKCE
if (requirePkce) {
if (StrUtil.isBlank(codeChallenge)) {
throw new OAuth2Exception("该客户端必须使用 PKCE请提供 code_challenge");
}
if (StrUtil.isBlank(codeChallengeMethod)) {
throw new OAuth2Exception("该客户端必须使用 PKCE请提供 code_challenge_method");
}
}
// 如果提供了 code_challenge验证 method
if (StrUtil.isNotBlank(codeChallenge)) {
if (StrUtil.isBlank(codeChallengeMethod)) {
throw new OAuth2Exception("提供了 code_challenge 必须同时提供 code_challenge_method");
}
// 只支持 S256 方法(更安全)
if (!S256_METHOD.equals(codeChallengeMethod) && !PLAIN_METHOD.equals(codeChallengeMethod)) {
throw new OAuth2Exception("不支持的 code_challenge_method仅支持 S256 或 plain");
}
// 推荐使用 S256
if (PLAIN_METHOD.equals(codeChallengeMethod)) {
// 可以记录警告日志:使用了不安全的 plain 方法
}
}
}
}

View File

@@ -0,0 +1,29 @@
package cn.meowrain.aioj.backend.auth.service;
import cn.meowrain.aioj.backend.auth.dto.req.UserLoginRequestDTO;
import cn.meowrain.aioj.backend.auth.dto.resp.UserLoginResponseDTO;
public interface AuthService {
/**
* 用户登录
* @param request {@link UserLoginRequestDTO}
* @return {@link UserLoginResponseDTO}
*/
UserLoginResponseDTO userLogin(UserLoginRequestDTO request);
/**
* 刷新token
* @param refreshToken
* @return
*/
UserLoginResponseDTO refreshToken(String refreshToken);
/**
* 验证token的有效性
* @param accessToken 访问令牌
* @return token是否有效
*/
Boolean validateToken(String accessToken);
}

View File

@@ -0,0 +1,168 @@
package cn.meowrain.aioj.backend.auth.service.impl;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.meowrain.aioj.backend.auth.clients.UserClient;
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
import cn.meowrain.aioj.backend.auth.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.auth.config.properties.JwtPropertiesConfiguration;
import cn.meowrain.aioj.backend.auth.dto.chains.context.UserLoginRequestParamVerifyContext;
import cn.meowrain.aioj.backend.auth.dto.req.UserLoginRequestDTO;
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
import cn.meowrain.aioj.backend.auth.dto.resp.UserLoginResponseDTO;
import cn.meowrain.aioj.backend.auth.service.AuthService;
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ServiceException;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService {
private final JwtUtil jwtUtil;
private final UserLoginRequestParamVerifyContext userLoginRequestParamVerifyContext;
private final UserClient userClient;
private final StringRedisTemplate stringRedisTemplate;
private final JwtPropertiesConfiguration jwtPropertiesConfiguration;
@Override
public UserLoginResponseDTO userLogin(UserLoginRequestDTO requestParam) {
log.info("用户登录请求: userAccount={}", requestParam.getUserAccount());
// 1.校验
userLoginRequestParamVerifyContext.handler(ChainMarkEnums.USER_LOGIN_REQ_PARAM_VERIFY.getMarkName(),
requestParam);
// 如果调用user-service失败那么就说明是系统内部错误
log.info("正在调用user-service查询用户信息...");
Result<UserAuthRespDTO> userResp = userClient.getUserByUserName(requestParam.getUserAccount());
if (userResp.isFail()) {
log.error("调用user-service返回失败{}", userResp.getMessage());
throw new ServiceException(ErrorCode.SYSTEM_ERROR);
}
UserAuthRespDTO user = userResp.getData();
if (user == null) {
log.warn("用户不存在: {}", requestParam.getUserAccount());
throw new ServiceException("用户不存在或密码错误", ErrorCode.NOT_LOGIN_ERROR);
}
if (!BCrypt.checkpw(requestParam.getUserPassword(), user.getUserPassword())) {
log.warn("密码错误: {}", requestParam.getUserAccount());
throw new ServiceException("用户不存在或密码错误", ErrorCode.NOT_LOGIN_ERROR);
}
// 生成 JWT
log.info("正在生成JWT token...");
String accessToken = jwtUtil.generateAccessToken(user);
String refreshToken = jwtUtil.generateRefreshToken(user.getId());
UserLoginResponseDTO resp = new UserLoginResponseDTO();
resp.setId(user.getId());
resp.setUserAccount(user.getUserAccount());
resp.setAccessToken(accessToken);
resp.setRefreshToken(refreshToken);
// refresh token存入到REDIS里面
stringRedisTemplate.opsForValue()
.set(String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, user.getId()), refreshToken,
jwtPropertiesConfiguration.getRefreshExpire(), TimeUnit.MILLISECONDS);
log.info("用户登录成功: userId={}, userAccount={}", user.getId(), user.getUserAccount());
return resp;
}
/**
* 更新access token使用refresh token
* @param refreshToken
* @return
*/
@Override
public UserLoginResponseDTO refreshToken(String refreshToken) {
UserLoginResponseDTO userLoginResponseDTO = new UserLoginResponseDTO();
if (!jwtUtil.isTokenValid(refreshToken)) {
throw new ServiceException("Refresh Token 已过期");
}
Long userId = Long.valueOf(jwtUtil.parseClaims(refreshToken).getSubject());
String cacheKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
if (cacheValue == null || !cacheValue.equals(refreshToken)) {
throw new ServiceException("Refresh Token 已失效");
}
// 再次签发新的 Access Token
// 此处你需要查用户,拿 userName, role
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
if (userResult.isFail()) {
log.error("通过id查找用户失败:{}", userResult.getMessage());
throw new ServiceException(ErrorCode.SYSTEM_ERROR);
}
UserAuthRespDTO user = userResult.getData();
String newAccessToken = jwtUtil.generateAccessToken(user);
// 设置refresh token和access token
userLoginResponseDTO.setRefreshToken(refreshToken);
userLoginResponseDTO.setAccessToken(newAccessToken);
return userLoginResponseDTO;
}
/**
* 验证token的有效性
* @param accessToken 访问令牌
* @return token是否有效
*/
@Override
public Boolean validateToken(String accessToken) {
try {
// 1. 检查token格式
if (accessToken == null || accessToken.trim().isEmpty()) {
log.warn("Access token is null or empty");
return false;
}
// 2. 验证token签名和过期时间
if (!jwtUtil.isTokenValid(accessToken)) {
log.warn("Access token is invalid or expired");
return false;
}
// 3. 解析token获取用户信息
String userId = jwtUtil.parseClaims(accessToken).getSubject();
if (userId == null) {
log.warn("Access token does not contain valid user id");
return false;
}
// 4. 验证用户是否存在(可选,增加安全性)
Result<UserAuthRespDTO> userResult = userClient.getUserById(userId);
if (userResult.isFail() || userResult.getData() == null) {
log.warn("User not found for id: {}", userId);
return false;
}
log.debug("Access token validation successful for user: {}", userId);
return true;
}
catch (Exception e) {
log.error("Error validating access token", e);
return false;
}
}
}

View File

@@ -0,0 +1,102 @@
package cn.meowrain.aioj.backend.auth.utils;
import cn.meowrain.aioj.backend.auth.config.properties.JwtPropertiesConfiguration;
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@RequiredArgsConstructor
@Component
public class JwtUtil {
private final JwtPropertiesConfiguration jwtConfig;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes());
}
/** 生成 Access Token */
public String generateAccessToken(UserAuthRespDTO user) {
long now = System.currentTimeMillis();
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("userName", user.getUserName());
claims.put("role", user.getUserRole());
return Jwts.builder()
.subject(user.getUserAccount())
.issuedAt(new Date(now))
.expiration(new Date(now + jwtConfig.getAccessExpire()))
.claims(claims)
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
/** 生成 Refresh Token只含 userId */
public String generateRefreshToken(Long userId) {
long now = System.currentTimeMillis();
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(new Date(now))
.expiration(new Date(now + jwtConfig.getRefreshExpire()))
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
/** 解析 Token */
public Claims parseClaims(String token) {
return Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token).getPayload();
}
/** 校验 Token 是否过期 */
public boolean isTokenValid(String token) {
try {
Claims claims = parseClaims(token);
return claims.getExpiration().after(new Date());
}
catch (Exception ignored) {
return false;
}
}
/**
* 生成 OIDC ID Token
* @param user 用户信息
* @param clientId 客户端IDaud
* @param nonce 防重放参数
* @return ID Token
*/
public String generateIdToken(UserAuthRespDTO user, String clientId, String nonce) {
long now = System.currentTimeMillis();
Map<String, Object> claims = new HashMap<>();
claims.put("sub", String.valueOf(user.getId())); // Subject - 用户ID
claims.put("aud", clientId); // Audience - 客户端ID
claims.put("name", user.getUserName());
claims.put("preferred_username", user.getUserAccount());
if (nonce != null) {
claims.put("nonce", nonce); // 防重放
}
return Jwts.builder()
.issuer("http://localhost:10011/api") // TODO: 从配置读取
.subject(String.valueOf(user.getId()))
.issuedAt(new Date(now))
.expiration(new Date(now + jwtConfig.getAccessExpire()))
.claims(claims)
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
}

View File

@@ -0,0 +1,21 @@
spring:
application:
name: auth-service
data:
redis:
host: 10.0.0.10
port: 6379
password: 123456
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://10.0.0.10/aioj_dev
username: root
password: root
cloud:
nacos:
discovery:
enabled: true
register-enabled: true
server-addr: 10.0.0.10:8848
username: nacos
password: nacos

View File

@@ -0,0 +1,38 @@
spring:
application:
name: auth-service
profiles:
active: @env@
devtools:
livereload:
enabled: true
server:
port: 10011
servlet:
context-path: /api
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
default-flat-param-object: true
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
group-configs:
- group: 'default'
paths-to-match: '/**'
packages-to-scan: cn.meowrain.aioj.backend.userservice.controller
knife4j:
basic:
enable: true
setting:
language: zh_cn
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:/mapper/**/*.xml
jwt:
secret: "12345678901234567890123456789012" # 至少32字节
access-expire: 900000 # 24小时
refresh-expire: 604800000 # 7天

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>aioj-backend-common-bom</artifactId>
<groupId>cn.meowrain.aioj</groupId>
<packaging>pom</packaging>
<version>${revision}</version>
<name>aioj-common-bom</name>
<description>依赖管理</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<revision>1.0.0</revision>
<mybatis-plus.version>3.5.14</mybatis-plus.version>
<spring-boot.version>3.5.7</spring-boot.version>
<spring-cloud-alibaba.version>2025.0.0.0</spring-cloud-alibaba.version>
<mysql.version>9.4.0</mysql.version>
<jackson.bom>3.0.2</jackson.bom>
</properties>
<dependencyManagement>
<dependencies>
<!-- https://mvnrepository.com/artifact/tools.jackson/jackson-bom -->
<dependency>
<groupId>tools.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.bom}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-bom -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-bom</artifactId>
<version>5.8.41</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--orm 相关 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>${mybatis-plus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
</dependency>
<!-- https://github.com/alibaba/easyexcel -->
<!-- https://mvnrepository.com/artifact/com.alibaba/easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>4.0.3</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.xiaoymin/knife4j-openapi3-jakarta-spring-boot-starter -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.5.0</version>
</dependency>
<!-- OAuth2 Client -->
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-oauth2-client -->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-oauth2-client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>3.5.7</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-test -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>6.5.6</version>
<scope>test</scope>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>3.5.7</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>aioj-backend-common-core</artifactId>
<packaging>jar</packaging>
<description>aioj 公共工具类核心包</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
</dependency>
<!--server-api-->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-commons</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-extra</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<!--json模块-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<!--hibernate-validator-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,60 @@
package cn.meowrain.aioj.backend.framework.core.banner;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Slf4j
@RequiredArgsConstructor
public class BannerApplicationRunner implements ApplicationRunner {
private final Environment env;
@Value("${spring.application.name:unknown}")
private String appName;
@Override
public void run(ApplicationArguments args) throws Exception {
// Active profiles
String profiles = String.join(",", env.getActiveProfiles());
if (profiles.isEmpty()) {
profiles = "default";
}
// Port
String port = env.getProperty("server.port", "unknown");
// JVM info
String jvm = System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")";
// PID
String pid = ManagementFactory.getRuntimeMXBean().getPid() + "";
// Time
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
// Git commit id (如果没有 git.properties不会报错)
String gitCommit = env.getProperty("git.commit.id.abbrev", "N/A");
printBanner(appName, profiles, port, jvm, pid, time, gitCommit);
}
private void printBanner(String appName, String profiles, String port, String jvm, String pid, String time,
String gitCommit) {
String banner = "\n" + "------------------------------------------------------------\n"
+ " ✨AI Online Judge✨ - " + appName + "\n" + " Environment : " + profiles + "\n" + " Port : "
+ port + "\n" + " Git Commit : " + gitCommit + "\n" + " JVM : " + jvm + "\n"
+ " PID : " + pid + "\n" + " Started At : " + time + "\n"
+ "------------------------------------------------------------\n";
System.out.println(banner);
}
}

View File

@@ -0,0 +1,61 @@
package cn.meowrain.aioj.backend.framework.core.banner;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Slf4j
@Deprecated
public class EnvironmentBanner implements ApplicationListener<ApplicationReadyEvent> {
@Value("${spring.application.name:unknown}")
private String appName;
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
Environment env = event.getApplicationContext().getEnvironment();
// Active profiles
String profiles = String.join(",", env.getActiveProfiles());
if (profiles.isEmpty()) {
profiles = "default";
}
// Port
String port = env.getProperty("server.port", "unknown");
// JVM info
String jvm = System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")";
// PID
String pid = ManagementFactory.getRuntimeMXBean().getPid() + "";
// Time
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
// Git commit id (如果没有 git.properties不会报错)
String gitCommit = env.getProperty("git.commit.id.abbrev", "N/A");
printBanner(appName, profiles, port, jvm, pid, time, gitCommit);
}
private void printBanner(String appName, String profiles, String port, String jvm, String pid, String time,
String gitCommit) {
String banner = "\n" + "------------------------------------------------------------\n"
+ " ✨AI Online Judge✨ - " + appName + "\n" + " Environment : " + profiles + "\n" + " Port : "
+ port + "\n" + " Git Commit : " + gitCommit + "\n" + " JVM : " + jvm + "\n"
+ " PID : " + pid + "\n" + " Started At : " + time + "\n"
+ "------------------------------------------------------------\n";
System.out.println(banner);
}
}

View File

@@ -0,0 +1,16 @@
package cn.meowrain.aioj.backend.framework.core.banner.config;
import cn.meowrain.aioj.backend.framework.core.banner.BannerApplicationRunner;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
@AutoConfiguration
public class AIOJBannerAutoConfiguration {
@Bean
public BannerApplicationRunner bannerApplicationRunner(Environment env) {
return new BannerApplicationRunner(env);
}
}

View File

@@ -0,0 +1,23 @@
package cn.meowrain.aioj.backend.framework.core.config;
import cn.meowrain.aioj.backend.framework.core.exception.handler.GlobalExceptionHandler;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
/**
* 注册为bean,全局异常拦截器
*/
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(HttpServletRequest.class)
public class WebAutoConfiguration {
@Bean
public GlobalExceptionHandler globalExceptionHandler() {
return new GlobalExceptionHandler();
}
}

View File

@@ -0,0 +1,23 @@
package cn.meowrain.aioj.backend.framework.core.constants;
/**
* 全局服务名称
*/
public class ServiceNameConstants {
/**
* 用户服务 SERVICE NAME
*/
public static final String USER_SERVICE = "user-service";
/**
* 认证服务 SERVICE NAME
*/
public static final String AUTH_SERVICE = "auth-service";
/**
* UPMS模块
*/
public static final String UPMS_SERVICE = "upms-service";
}

View File

@@ -0,0 +1,19 @@
package cn.meowrain.aioj.backend.framework.core.designpattern.chains;
import org.springframework.core.Ordered;
public interface AbstractChianHandler<T> extends Ordered {
/**
* 执行责任链逻辑
* @param requestParam 责任链执行入参
*/
void handle(T requestParam);
/**
* 责任链组件标识
* @return String
*/
String mark();
}

View File

@@ -0,0 +1,68 @@
package cn.meowrain.aioj.backend.framework.core.designpattern.chains;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* 公共责任链容器
*
* @param <T>
*/
@Component
@Slf4j
public class CommonChainContext<T> implements ApplicationContextAware, CommandLineRunner {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
private final Map<String, List<AbstractChianHandler<T>>> abstractChainHandlerMap = new HashMap<>();
public void handler(String mark, T requestParam) {
List<AbstractChianHandler<T>> merchantAdminAbstractChainHandlers = abstractChainHandlerMap.get(mark);
if (merchantAdminAbstractChainHandlers == null || merchantAdminAbstractChainHandlers.isEmpty()) {
throw new RuntimeException(String.format("[%s] Chain of Responsibility ID is undefined.", mark));
}
merchantAdminAbstractChainHandlers.forEach(h -> {
h.handle(requestParam);
});
}
@Override
public void run(String... args) throws Exception {
log.info("【责任链路初始化】开始加载并分组所有处理器...");
applicationContext.getBeansOfType(AbstractChianHandler.class).values().forEach(handler -> {
// 打印当前处理器的类名和它所属的 ChainMark
String handlerName = handler.getClass().getSimpleName();
String mark = handler.mark();
log.info(" -> 发现处理器:{},归属链路:{}", handlerName, mark);
abstractChainHandlerMap.computeIfAbsent(handler.mark(), k -> new ArrayList<>()).add(handler);
});
// 步骤 2: 对每个链路中的处理器进行排序 (Sort 阶段)
abstractChainHandlerMap.forEach((mark, handlers) -> {
handlers.sort(Comparator.comparing(Ordered::getOrder));
// 打印排序后的 Bean 列表
String sortedList = handlers.stream()
.map(h -> String.format("%s (Order:%d)", h.getClass().getSimpleName(), h.getOrder()))
.collect(Collectors.joining(" -> "));
log.info(" ✅ 链路 {} 排序完成:{}", mark, sortedList);
});
log.info("【责任链路初始化】所有处理器链已完全就绪。");
}
}

View File

@@ -0,0 +1,20 @@
package cn.meowrain.aioj.backend.framework.core.enums;
/**
* 删除枚举
*/
public enum DelStatusEnum {
STATUS_NORMAL("0"), STATUS_DELETE("1");
private final String code;
DelStatusEnum(String code) {
this.code = code;
}
public String code() {
return this.code;
}
}

View File

@@ -0,0 +1,35 @@
package cn.meowrain.aioj.backend.framework.core.errorcode;
public enum ErrorCode implements IErrorCode {
SUCCESS("0", "ok"), PARAMS_ERROR("40000", "请求参数错误"), NOT_LOGIN_ERROR("40100", "未登录"), NO_AUTH_ERROR("40101", "无权限"),
NOT_FOUND_ERROR("40400", "请求数据不存在"), FORBIDDEN_ERROR("40300", "禁止访问"), SYSTEM_ERROR("50000", "系统内部异常"),
OPERATION_ERROR("50001", "操作失败"), API_REQUEST_ERROR("50010", "接口调用失败");
/**
* 状态码
*/
private final String code;
/**
* 信息
*/
private final String message;
ErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}

View File

@@ -0,0 +1,9 @@
package cn.meowrain.aioj.backend.framework.core.errorcode;
public interface IErrorCode {
String code();
String message();
}

View File

@@ -0,0 +1,27 @@
package cn.meowrain.aioj.backend.framework.core.exception;
import cn.meowrain.aioj.backend.framework.core.errorcode.IErrorCode;
import lombok.Getter;
import org.springframework.util.StringUtils;
import java.util.Optional;
/**
* 抽象错误处理Exception,基于这个抽象类我们能创建很多其它类型的exception定义错误类型
*/
@Getter
public class AbstractException extends RuntimeException {
public final String errorCode;
public final String errorMessage;
public AbstractException(String message, Throwable throwable, IErrorCode errorCode) {
super(message);
this.errorCode = errorCode.code();
this.errorMessage = Optional.ofNullable(StringUtils.hasLength(message) ? message : null)
.orElse(errorCode.message());
}
}

View File

@@ -0,0 +1,29 @@
package cn.meowrain.aioj.backend.framework.core.exception;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.errorcode.IErrorCode;
import lombok.ToString;
/**
* 客户端异常
*/
@ToString
public class ClientException extends AbstractException {
public ClientException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable, errorCode);
}
public ClientException(IErrorCode errorCode) {
this(null, null, errorCode);
}
public ClientException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public ClientException(String message) {
this(message, null, ErrorCode.PARAMS_ERROR);
}
}

View File

@@ -0,0 +1,29 @@
package cn.meowrain.aioj.backend.framework.core.exception;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.errorcode.IErrorCode;
import lombok.ToString;
/**
* 调用第三方服务异常
*/
@ToString
public class RemoteException extends AbstractException {
public RemoteException(IErrorCode errorCode) {
this(null, null, errorCode);
}
public RemoteException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public RemoteException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable, errorCode);
}
public RemoteException(String message) {
this(message, null, ErrorCode.API_REQUEST_ERROR);
}
}

View File

@@ -0,0 +1,29 @@
package cn.meowrain.aioj.backend.framework.core.exception;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.errorcode.IErrorCode;
import lombok.ToString;
/**
* 系统执行异常
*/
@ToString
public class ServiceException extends AbstractException {
public ServiceException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public ServiceException(String message) {
this(message, null, ErrorCode.SYSTEM_ERROR);
}
public ServiceException(IErrorCode errorCode) {
this(null, null, errorCode);
}
public ServiceException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable, errorCode);
}
}

View File

@@ -0,0 +1,99 @@
package cn.meowrain.aioj.backend.framework.core.exception.handler;
import cn.meowrain.aioj.backend.framework.core.exception.AbstractException;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import cn.meowrain.aioj.backend.framework.core.web.Results;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 全局错误捕获器
*/
@Slf4j
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class GlobalExceptionHandler {
// 加这个构造器,启动看日志
public GlobalExceptionHandler() {
System.out.println("===== 自定义异常处理器已加载 =====");
}
/**
* 捕获所有参数错误,然后统一捕获并且抛出
*
* @param request {@link HttpServletRequest}
* @param ex {@link org.springframework.validation.method.MethodValidationException}
* @return {@link Result<Void>}
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result<Void> validExceptionHandler(HttpServletRequest request, MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
// 收集所有错误字段
List<String> errorMessages = bindingResult.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.toList();
String exceptionMessage = String.join(",", errorMessages);
log.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionMessage);
return Results.paramsValidFailure();
}
/**
* 抽象异常捕获其
*
* @param request {@link HttpServletRequest}
* @param ex {@link AbstractException}
* @return {@link Result<Void>}
*/
@ExceptionHandler(value = { AbstractException.class })
public Result<Void> abstractExceptionHandler(HttpServletRequest request, AbstractException ex) {
if (ex.getCause() != null) {
log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex, ex.getCause());
return Results.failure(ex);
}
StringBuilder stackTraceBuilder = new StringBuilder();
stackTraceBuilder.append(ex.getClass().getName()).append(": ").append(ex.getErrorMessage()).append("\n");
StackTraceElement[] stackTrace = ex.getStackTrace();
for (int i = 0; i < Math.min(5, stackTrace.length); i++) {
stackTraceBuilder.append("\tat ").append(stackTrace[i]).append("\n");
}
log.error("[{}] {} [ex] {} \n\n{}", request.getMethod(), request.getRequestURL().toString(), ex,
stackTraceBuilder);
return Results.failure(ex);
}
/**
* 拦截未捕获异常
*/
@ExceptionHandler(value = Throwable.class)
public Result<Void> defaultErrorHandler(HttpServletRequest request, Throwable throwable) {
log.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
return Results.failure();
}
/**
* 获取请求URL
*
* @param request {@link HttpServletRequest}
* @return String
*/
private String getUrl(HttpServletRequest request) {
if (!StringUtils.hasText(request.getQueryString())) {
return request.getRequestURI();
}
return request.getRequestURL() + "?" + request.getQueryString();
}
}

View File

@@ -0,0 +1,49 @@
package cn.meowrain.aioj.backend.framework.core.jackson;
import cn.hutool.core.date.DatePattern;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.PackageVersion;
import com.fasterxml.jackson.datatype.jsr310.deser.*;
import com.fasterxml.jackson.datatype.jsr310.ser.*;
import java.io.Serial;
import java.time.*;
import java.time.format.DateTimeFormatter;
public class JavaTimeModule extends SimpleModule {
@Serial
private static final long serialVersionUID = 1L;
/**
* JavaTimeModule构造函数用于初始化时间序列化和反序列化规则
*/
public JavaTimeModule() {
super(PackageVersion.VERSION);
// ======================= 时间序列化规则 ===============================
// yyyy-MM-dd HH:mm:ss
this.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DatePattern.NORM_DATETIME_FORMATTER));
// yyyy-MM-dd
this.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE));
// HH:mm:ss
this.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME));
// Instant 类型序列化
this.addSerializer(Instant.class, InstantSerializer.INSTANCE);
// Duration 类型序列化
this.addSerializer(Duration.class, DurationSerializer.INSTANCE);
// ======================= 时间反序列化规则 ==============================
// yyyy-MM-dd HH:mm:ss
this.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DatePattern.NORM_DATETIME_FORMATTER));
// yyyy-MM-dd
this.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE));
// HH:mm:ss
this.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ISO_LOCAL_TIME));
// Instant 反序列化
this.addDeserializer(Instant.class, InstantDeserializer.INSTANT);
// Duration 反序列化
this.addDeserializer(Duration.class, DurationDeserializer.INSTANCE);
}
}

View File

@@ -0,0 +1,81 @@
package cn.meowrain.aioj.backend.framework.core.utils;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class SpringContextHolder implements ApplicationContextAware, EnvironmentAware, DisposableBean {
private static ApplicationContext applicationContext = null;
private static Environment environment = null;
/**
* 从静态变量applicationContext中取得Bean, 自动转型为所赋值对象的类型.
*/
public static <T> T getBean(String name) {
return (T) applicationContext.getBean(name);
}
/**
* 从静态变量applicationContext中取得Bean, 自动转型为所赋值对象的类型.
*/
public static <T> T getBean(Class<T> requiredType) {
return applicationContext.getBean(requiredType);
}
@Override
@SneakyThrows
public void destroy() throws Exception {
clearHolder();
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextHolder.applicationContext = applicationContext;
}
@Override
public void setEnvironment(Environment environment) {
SpringContextHolder.environment = environment;
}
/**
* 清除SpringContextHolder中的ApplicationContext为Null.
*/
public static void clearHolder() {
if (log.isDebugEnabled()) {
log.debug("清除SpringContextHolder中的ApplicationContext:" + applicationContext);
}
applicationContext = null;
}
/**
* 发布事件
* @param event
*/
public static void publishEvent(ApplicationEvent event) {
if (applicationContext == null) {
return;
}
applicationContext.publishEvent(event);
}
/**
* 是否是微服务
* @return boolean
*/
public static boolean isMicro() {
return environment.getProperty("spring.cloud.nacos.discovery.enabled", Boolean.class, true);
}
}

View File

@@ -0,0 +1,55 @@
package cn.meowrain.aioj.backend.framework.core.web;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
/**
* 全局返回对象
*/
@Data
@Accessors(chain = true)
public class Result<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 正确返回码
*/
public static final String SUCCESS_CODE = "0";
/**
* 响应码
*/
private String code;
/**
* 响应数据
*/
private T data;
/**
* 响应信息
*/
private String message;
/**
* 返回是否是正确响应
* @return boolean
*/
public boolean isSuccess() {
return SUCCESS_CODE.equals(code);
}
/**
* 返回是否是错误响应
* @return boolean
*/
public boolean isFail() {
return !isSuccess();
}
}

View File

@@ -0,0 +1,69 @@
package cn.meowrain.aioj.backend.framework.core.web;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.AbstractException;
import java.util.Optional;
/**
* 构建响应的工具类
*/
public final class Results {
/**
* 成功响应,不返回任何信息
* @return {@link Result<Void>}
*/
public static Result<Void> success() {
return new Result<Void>().setCode(Result.SUCCESS_CODE);
}
/**
* 成功响应 返回数据
* @param data 返回的响应体信息
* @param <T> 泛型
* @return {@link Result<T>}
*/
public static <T> Result<T> success(T data) {
return new Result<T>().setCode(Result.SUCCESS_CODE).setData(data);
}
/**
* 客户端请求参数错误
* @return {@link Result<Void>}
*/
public static Result<Void> paramsValidFailure() {
return failure(ErrorCode.PARAMS_ERROR.code(), ErrorCode.PARAMS_ERROR.message());
}
/**
* 服务端错误默认响应 -- <b>内部错误</b>
* @return {@link Result<Void>}
*/
public static Result<Void> failure() {
return new Result<Void>().setCode(ErrorCode.SYSTEM_ERROR.code()).setMessage(ErrorCode.SYSTEM_ERROR.message());
}
/**
* 服务端错误响应 - 接收一个AbstractException里面定义了错误码和错误信息
* @param exception {@link AbstractException}
* @return {@link Result<Void>}
*/
public static Result<Void> failure(AbstractException exception) {
String errorCode = Optional.ofNullable(exception.getErrorCode()).orElse(ErrorCode.SYSTEM_ERROR.code());
String errorMessage = Optional.ofNullable(exception.getMessage()).orElse(ErrorCode.SYSTEM_ERROR.message());
return new Result<Void>().setCode(errorCode).setMessage(errorMessage);
}
/**
* 服务端错误响应,自定义错误码和错误信息
* @param errorCode {@link String}
* @param errorMessage {@link String}
* @return {@link Result<Void>}
*/
public static Result<Void> failure(String errorCode, String errorMessage) {
return new Result<Void>().setCode(errorCode).setMessage(errorMessage);
}
}

View File

@@ -0,0 +1,2 @@
cn.meowrain.aioj.backend.framework.core.banner.config.AIOJBannerAutoConfiguration
cn.meowrain.aioj.backend.framework.core.config.WebAutoConfiguration

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>aioj-backend-common-feign</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!--feign 依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- okhttp 扩展 -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<!-- LB 扩展 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!--caffeine 替换LB 默认缓存实现-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!--oauth server 依赖-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<!-- 异常枚举 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,8 @@
package cn.meowrain.aioj.backend.framework.feign;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@AutoConfiguration
public class FeignAutoConfiguration {
}

View File

@@ -0,0 +1,15 @@
package cn.meowrain.aioj.backend.framework.feign.annotation;
import org.springframework.cloud.openfeign.EnableFeignClients;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableFeignClients
public @interface EnableAIOJFeignClients {
String[] basePackages() default {};
}

View File

@@ -0,0 +1,16 @@
package cn.meowrain.aioj.backend.framework.feign.annotation;
import java.lang.annotation.*;
/**
* 服务无token调用声明注解
* <p>
* 只有发起方没有 token 时候才需要添加此注解, @NoToken + @Inner
* <p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoToken {
}

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>aioj-backend-common-log</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!--安全依赖获取上下文信息-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-core</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-extra</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
</dependency>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-upms-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,39 @@
package cn.meowrain.aioj.backend.framework.log;
import cn.meowrain.aioj.backend.framework.log.aspect.SysLogAspect;
import cn.meowrain.aioj.backend.framework.log.config.AIOJLogPropertiesConfiguration;
import cn.meowrain.aioj.backend.framework.log.event.SysLogListener;
import cn.meowrain.aioj.backend.upms.api.feign.RemoteLogService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AIOJLogPropertiesConfiguration.class)
@ConditionalOnProperty(value = "aioj.log.enabled", matchIfMissing = true)
public class LogAutoConfiguration {
/**
* 创建并返回SysLogListener的Bean实例
*/
@Bean
@ConditionalOnBean(RemoteLogService.class)
public SysLogListener sysLogListener(AIOJLogPropertiesConfiguration logProperties,
RemoteLogService remoteLogService) {
return new SysLogListener(remoteLogService, logProperties);
}
/**
* 返回切面类实例
* @return {@link SysLogAspect}
*/
@Bean
public SysLogAspect sysLogAspect() {
return new SysLogAspect();
}
}

View File

@@ -0,0 +1,25 @@
package cn.meowrain.aioj.backend.framework.log.annotation;
import java.lang.annotation.*;
/**
* 系统日志注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
/**
* 描述
* @return {@link String}
*/
String value() default "";
/**
* Spel表达式
* @return 日志描述
*/
String expression() default "";
}

View File

@@ -0,0 +1,81 @@
package cn.meowrain.aioj.backend.framework.log.aspect;
import cn.hutool.core.util.StrUtil;
import cn.meowrain.aioj.backend.framework.core.utils.SpringContextHolder;
import cn.meowrain.aioj.backend.framework.log.annotation.SysLog;
import cn.meowrain.aioj.backend.framework.log.enums.LogTypeEnum;
import cn.meowrain.aioj.backend.framework.log.event.SysLogEvent;
import cn.meowrain.aioj.backend.framework.log.event.SysLogEventSource;
import cn.meowrain.aioj.backend.framework.log.utils.SysLogUtils;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.EvaluationContext;
@Aspect
@Slf4j
@RequiredArgsConstructor
public class SysLogAspect {
/**
* 环绕通知方法,用于处理系统日志记录
* @param point 连接点对象
* @param sysLog 系统日志注解
* @return 目标方法执行结果
* @throws Throwable 目标方法执行可能抛出的异常
*/
@Around("@annotation(sysLog)")
@SneakyThrows
public Object around(ProceedingJoinPoint point, SysLog sysLog) {
String strClassName = point.getTarget().getClass().getName();
String strMethodName = point.getSignature().getName();
log.debug("[类名]:{},[方法]:{}", strClassName, strMethodName);
String value = sysLog.value();
String expression = sysLog.expression();
// 当前表达式存在 SPEL会覆盖 value 的值
if (StrUtil.isNotBlank(expression)) {
// 解析SPEL
MethodSignature signature = (MethodSignature) point.getSignature();
EvaluationContext context = SysLogUtils.getContext(point.getArgs(), signature.getMethod());
try {
value = SysLogUtils.getValue(context, expression, String.class);
}
catch (Exception e) {
// SPEL 表达式异常,获取 value 的值
log.error("@SysLog 解析SPEL {} 异常", expression);
}
}
SysLogEventSource logVo = SysLogUtils.getSysLog();
logVo.setTitle(value);
// 获取请求body参数
if (StrUtil.isBlank(logVo.getParams())) {
logVo.setBody(point.getArgs());
}
// 发送异步日志事件
Long startTime = System.currentTimeMillis();
Object obj;
try {
obj = point.proceed();
}
catch (Exception e) {
logVo.setLogType(LogTypeEnum.ERROR.getType());
logVo.setException(e.getMessage());
throw e;
}
finally {
Long endTime = System.currentTimeMillis();
logVo.setTime(endTime - startTime);
SpringContextHolder.publishEvent(new SysLogEvent(logVo));
}
return obj;
}
}

View File

@@ -0,0 +1,33 @@
package cn.meowrain.aioj.backend.framework.log.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@Getter
@Setter
@ConfigurationProperties(AIOJLogPropertiesConfiguration.PREFIX)
public class AIOJLogPropertiesConfiguration {
public static final String PREFIX = "aioj.log";
/**
* 开启日志记录
*/
private boolean enabled = true;
/**
* 请求报文最大存储长度
*/
private Integer maxLength = 20000;
/**
* 放行字段password,mobile,idcard,phone
*/
@Value("${log.exclude-fields:password,mobile,idcard,phone}")
private List<String> excludeFields;
}

View File

@@ -0,0 +1,33 @@
package cn.meowrain.aioj.backend.framework.log.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 日志类型枚举
*/
@Getter
@RequiredArgsConstructor
public enum LogTypeEnum {
/**
* 正常日志类型
*/
NORMAL("0", "正常日志"),
/**
* 错误日志类型
*/
ERROR("9", "错误日志");
/**
* 类型
*/
private final String type;
/**
* 描述
*/
private final String description;
}

View File

@@ -0,0 +1,23 @@
package cn.meowrain.aioj.backend.framework.log.event;
import cn.meowrain.aioj.backend.upms.api.entity.SysLog;
import org.springframework.context.ApplicationEvent;
import java.io.Serial;
/**
* 系统日志事件类
*/
public class SysLogEvent extends ApplicationEvent {
@Serial
private static final long serialVersionUID = 1L;
/**
* 构造方法根据源SysLog对象创建SysLogEvent
*/
public SysLogEvent(SysLog source) {
super(source);
}
}

View File

@@ -0,0 +1,24 @@
package cn.meowrain.aioj.backend.framework.log.event;
import cn.meowrain.aioj.backend.upms.api.entity.SysLog;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serial;
/**
* 系统
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class SysLogEventSource extends SysLog {
@Serial
private static final long serialVersionUID = 1L;
/**
* 参数重写成object
*/
private Object body;
}

View File

@@ -0,0 +1,77 @@
package cn.meowrain.aioj.backend.framework.log.event;
import cn.hutool.core.util.StrUtil;
import cn.meowrain.aioj.backend.framework.core.jackson.JavaTimeModule;
import cn.meowrain.aioj.backend.framework.log.config.AIOJLogPropertiesConfiguration;
import cn.meowrain.aioj.backend.upms.api.entity.SysLog;
import cn.meowrain.aioj.backend.upms.api.feign.RemoteLogService;
import com.fasterxml.jackson.annotation.JsonFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.scheduling.annotation.Async;
import java.util.Objects;
@RequiredArgsConstructor
public class SysLogListener implements InitializingBean {
private final static ObjectMapper objectMapper = new ObjectMapper();
private final RemoteLogService remoteLogService;
private final AIOJLogPropertiesConfiguration logProperties;
@SneakyThrows
@Async
@Order
@EventListener(SysLogEvent.class)
public void saveLog(SysLogEvent sysLogEvent) {
SysLogEventSource source = (SysLogEventSource) sysLogEvent.getSource();
SysLog sysLog = new SysLog();
BeanUtils.copyProperties(source, sysLog);
// json 格式刷参数放在异步中处理,提升性能
if (Objects.nonNull(source.getBody())) {
String params = objectMapper.writeValueAsString(source.getBody());
sysLog.setParams(StrUtil.subPre(params, logProperties.getMaxLength()));
}
remoteLogService.saveLog(sysLog);
}
/**
* 在 Bean 初始化后执行,用于初始化 ObjectMapper
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
// 给 ObjectMapper 添加 MixIn用于过滤字段
objectMapper.addMixIn(Object.class, PropertyFilterMixIn.class);
String[] ignorableFieldNames = logProperties.getExcludeFields().toArray(new String[0]);
FilterProvider filters = new SimpleFilterProvider().addFilter("filter properties by name",
SimpleBeanPropertyFilter.serializeAllExcept(ignorableFieldNames));
objectMapper.setFilterProvider(filters);
objectMapper.registerModule(new JavaTimeModule());
}
/**
* 属性过滤混合类:用于通过名称过滤属性
*
* @author lengleng
* @date 2025/05/31
*/
@JsonFilter("filter properties by name")
class PropertyFilterMixIn {
}
}

View File

@@ -0,0 +1,20 @@
package cn.meowrain.aioj.backend.framework.log.init;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
public class ApplicationLoggerInitializer implements EnvironmentPostProcessor, Ordered {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}

View File

@@ -0,0 +1,101 @@
package cn.meowrain.aioj.backend.framework.log.utils;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.http.HttpUtil;
import cn.meowrain.aioj.backend.framework.core.utils.SpringContextHolder;
import cn.meowrain.aioj.backend.framework.log.config.AIOJLogPropertiesConfiguration;
import cn.meowrain.aioj.backend.framework.log.enums.LogTypeEnum;
import cn.meowrain.aioj.backend.framework.log.event.SysLogEventSource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.StandardReflectionParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public final class SysLogUtils {
/**
* 获取系统日志事件源
* @return 系统日志事件源对象
*/
public static SysLogEventSource getSysLog() {
HttpServletRequest request = ((ServletRequestAttributes) Objects
.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
SysLogEventSource sysLog = new SysLogEventSource();
sysLog.setLogType(LogTypeEnum.NORMAL.getType());
sysLog.setRequestUri(URLUtil.getPath(request.getRequestURI()));
sysLog.setMethod(request.getMethod());
sysLog.setRemoteAddr(JakartaServletUtil.getClientIP(request));
sysLog.setUserAgent(request.getHeader(HttpHeaders.USER_AGENT));
sysLog.setCreateBy(getUsername());
sysLog.setServiceId(SpringUtil.getProperty("spring.application.name"));
// get 参数脱敏
AIOJLogPropertiesConfiguration logProperties = SpringContextHolder
.getBean(AIOJLogPropertiesConfiguration.class);
Map<String, String[]> paramsMap = MapUtil.removeAny(new HashMap<>(request.getParameterMap()),
ArrayUtil.toArray(logProperties.getExcludeFields(), String.class));
sysLog.setParams(HttpUtil.toParams(paramsMap));
return sysLog;
}
/**
* 获取用户名称
* @return username
*/
private static String getUsername() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
return authentication.getName();
}
/**
* 获取spel 定义的参数值
* @param context 参数容器
* @param key key
* @param clazz 需要返回的类型
* @param <T> 返回泛型
* @return 参数值
*/
public static <T> T getValue(EvaluationContext context, String key, Class<T> clazz) {
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
Expression expression = spelExpressionParser.parseExpression(key);
return expression.getValue(context, clazz);
}
/**
* 获取参数容器
* @param arguments 方法的参数列表
* @param signatureMethod 被执行的方法体
* @return 装载参数的容器
*/
public static EvaluationContext getContext(Object[] arguments, Method signatureMethod) {
String[] parameterNames = new StandardReflectionParameterNameDiscoverer().getParameterNames(signatureMethod);
EvaluationContext context = new StandardEvaluationContext();
if (parameterNames == null) {
return context;
}
for (int i = 0; i < arguments.length; i++) {
context.setVariable(parameterNames[i], arguments[i]);
}
return context;
}
}

View File

@@ -0,0 +1 @@
cn.meowrain.aioj.backend.framework.log.LogAutoConfiguration

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>aioj-backend-common-mybatis</artifactId>
<packaging>jar</packaging>
<description>aioj mybatis 封装</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
</dependency>
<!-- orm 模块-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!--mybatis-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.xiaoymin/knife4j-openapi3-jakarta-spring-boot-starter -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,27 @@
package cn.meowrain.backend.common.mybaits;
import cn.meowrain.backend.common.mybaits.config.MybatisPlusMetaObjectHandler;
import cn.meowrain.backend.common.mybaits.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
public class MybatisPlusAutoConfiguration {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInterceptor());
return interceptor;
}
/**
* 创建并返回MybatisPlusMetaObjectHandler实例用于审计字段自动填充
*/
@Bean
public MybatisPlusMetaObjectHandler mybatisPlusMetaObjectHandler() {
return new MybatisPlusMetaObjectHandler();
}
}

View File

@@ -0,0 +1,51 @@
package cn.meowrain.backend.common.mybaits.base;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 基础实体抽象类,包含通用实体字段
*/
@Getter
@Setter
public class BaseEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 创建者
*/
@Schema(description = "创建人")
@TableField(fill = FieldFill.INSERT)
private String createBy;
/**
* 创建时间
*/
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新者
*/
@Schema(description = "更新人")
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/**
* 更新时间
*/
@Schema(description = "更新时间")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,72 @@
package cn.meowrain.backend.common.mybaits.config;
import cn.meowrain.aioj.backend.framework.core.enums.DelStatusEnum;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.ClassUtils;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* MybatisPlus 自动填充处理器,用于实体类字段的自动填充
*/
@Slf4j
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
log.debug("mybatis plus start insert fill ....");
LocalDateTime now = LocalDateTime.now();
fillValIfNullByName("createTime", now, metaObject, true);
fillValIfNullByName("updateTime", now, metaObject, true);
fillValIfNullByName("createBy", getUserName(), metaObject, true);
fillValIfNullByName("updateBy", getUserName(), metaObject, true);
// 删除标记自动填充
fillValIfNullByName("delFlag", DelStatusEnum.STATUS_NORMAL.code(), metaObject, true);
}
@Override
public void updateFill(MetaObject metaObject) {
log.debug("mybatis plus start update fill ....");
fillValIfNullByName("updateTime", LocalDateTime.now(), metaObject, true);
fillValIfNullByName("updateBy", getUserName(), metaObject, true);
}
private void fillValIfNullByName(String fieldName, Object fieldVal, MetaObject metaObject, boolean isCover) {
// 如果填充值为空
if (fieldVal == null) {
return;
}
// 没有 set 方法
if (!metaObject.hasSetter(fieldName)) {
return;
}
// field 类型相同时设置
Class<?> getterType = metaObject.getGetterType(fieldName);
if (ClassUtils.isAssignableValue(getterType, fieldVal)) {
metaObject.setValue(fieldName, fieldVal);
}
}
private Object getUserName() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 匿名接口直接返回
if (authentication instanceof AnonymousAuthenticationToken) {
return null;
}
if (Optional.ofNullable(authentication).isPresent()) {
return authentication.getName();
}
return null;
}
}

View File

@@ -0,0 +1,63 @@
package cn.meowrain.backend.common.mybaits.plugins;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.ParameterUtils;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.dialects.IDialect;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
/**
* * 分页拦截器实现类,用于处理分页查询逻辑 *
* <p>
* * 当分页大小小于0时自动设置为0防止全表查询
*/
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class PaginationInterceptor extends PaginationInnerInterceptor {
/**
* 数据库类型
* <p>
* 查看 {@link #findIDialect(Executor)} 逻辑
*/
private DbType dbType;
/**
* 方言实现类
* <p>
* 查看 {@link #findIDialect(Executor)} 逻辑
*/
private IDialect dialect;
public PaginationInterceptor(DbType dbType) {
this.dbType = dbType;
}
public PaginationInterceptor(IDialect dialect) {
this.dialect = dialect;
}
/**
* 在执行查询前处理分页参数
*/
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
// size 小于 0 直接设置为 0 , 即不查询任何数据
if (null != page && page.getSize() < 0) {
page.setSize(0);
}
super.beforeQuery(executor, ms, page, rowBounds, resultHandler, boundSql);
}
}

View File

@@ -0,0 +1 @@
cn.meowrain.backend.common.mybaits.MybatisPlusAutoConfiguration

View File

@@ -5,11 +5,12 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>cn.meowrain</groupId> <groupId>cn.meowrain</groupId>
<artifactId>ai-oj</artifactId> <artifactId>aioj-backend-common</artifactId>
<version>1.0-SNAPSHOT</version> <version>1.0-SNAPSHOT</version>
</parent> </parent>
<packaging>pom</packaging>
<artifactId>aioj-backend-model</artifactId> <artifactId>aioj-backend-common-starter</artifactId>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
@@ -17,4 +18,16 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
<dependencies>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.meowrain</groupId>
<artifactId>aioj-backend-common-log</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project> </project>

View File

@@ -10,10 +10,31 @@
</parent> </parent>
<artifactId>aioj-backend-common</artifactId> <artifactId>aioj-backend-common</artifactId>
<packaging>pom</packaging>
<modules>
<module>aioj-backend-common-log</module>
<module>aioj-backend-common-core</module>
<module>aioj-backend-common-starter</module>
<module>aioj-backend-common-mybatis</module>
<module>aioj-backend-common-bom</module>
<module>aioj-backend-common-feign</module>
</modules>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>cn.meowrain.aioj</groupId>
<artifactId>aioj-backend-common-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project> </project>

View File

@@ -1,12 +0,0 @@
package cn.meowrain.aioj.backend.framework.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
String mustRole() default "";
}

Some files were not shown because too many files have changed in this diff Show More