Compare commits
17 Commits
09a5674672
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d353735d1b | |||
| d04440f0b1 | |||
| 63d0528af4 | |||
| 4912e48922 | |||
| c61ee69561 | |||
|
|
6f7963a73b | ||
|
|
d89960f51c | ||
| 4304ec6e29 | |||
| 050e808ab8 | |||
| 7a3d3a06ba | |||
|
|
122a1738bd | ||
| 00c2fffad1 | |||
| aba1e36e03 | |||
| 3603d450e8 | |||
| c03876e29e | |||
| f93ec43915 | |||
| 9b28ef0a37 |
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal 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
BIN
.idea/.cache/.easy-yapi/.api.cache.v1.1.db
generated
Normal file
Binary file not shown.
0
.idea/.cache/.easy-yapi/.cookies.v1.0.json
generated
Normal file
0
.idea/.cache/.easy-yapi/.cookies.v1.0.json
generated
Normal file
0
.idea/.cache/.easy-yapi/.http_content_cache
generated
Normal file
0
.idea/.cache/.easy-yapi/.http_content_cache
generated
Normal file
6
.idea/CoolRequestCommonStatePersistent.xml
generated
Normal file
6
.idea/CoolRequestCommonStatePersistent.xml
generated
Normal 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
6
.idea/CoolRequestSetting.xml
generated
Normal 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
6
.idea/copilot.data.migration.agent.xml
generated
Normal 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
29
.idea/dataSources.xml
generated
Normal 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
6
.idea/db-forest-config.xml
generated
Normal 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 3:0:UserServiceApplication 5:0:AIOJAuthApplication ---------------------------------------- 2:1:43cc61de-66e1-44cc-b4a2-b24d7e03b490 4:3:903d03c4-df11-4cf8-939a-3e5fba0ab207 6:5:2fd8684a-b9aa-4507-abb0-f7c259d91286 " />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/encodings.xml
generated
18
.idea/encodings.xml
generated
@@ -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
8
.idea/misc.xml
generated
@@ -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
123
CLAUDE.md
Normal 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
131
aioj-backend-auth/pom.xml
Normal 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 Token(ID 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 Header(Bearer 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 Token(grant_type=refresh_token 时必需)
|
||||||
|
*/
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权范围(refresh_token 时可选,不能超出原范围)
|
||||||
|
*/
|
||||||
|
private String scope;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 Token(OIDC)
|
||||||
|
*/
|
||||||
|
@JsonProperty("id_token")
|
||||||
|
private String idToken;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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("客户端认证失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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("授权码无效或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 Token(OIDC)
|
||||||
|
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 Token(Refresh 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 方法
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 客户端ID(aud)
|
||||||
|
* @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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
21
aioj-backend-auth/src/main/resources/application-dev.yml
Normal file
21
aioj-backend-auth/src/main/resources/application-dev.yml
Normal 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
|
||||||
38
aioj-backend-auth/src/main/resources/application.yml
Normal file
38
aioj-backend-auth/src/main/resources/application.yml
Normal 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天
|
||||||
97
aioj-backend-common/aioj-backend-common-bom/pom.xml
Normal file
97
aioj-backend-common/aioj-backend-common-bom/pom.xml
Normal 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>
|
||||||
71
aioj-backend-common/aioj-backend-common-core/pom.xml
Normal file
71
aioj-backend-common/aioj-backend-common-core/pom.xml
Normal 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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("【责任链路初始化】所有处理器链已完全就绪。");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package cn.meowrain.aioj.backend.framework.core.errorcode;
|
||||||
|
|
||||||
|
public interface IErrorCode {
|
||||||
|
|
||||||
|
String code();
|
||||||
|
|
||||||
|
String message();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
cn.meowrain.aioj.backend.framework.core.banner.config.AIOJBannerAutoConfiguration
|
||||||
|
cn.meowrain.aioj.backend.framework.core.config.WebAutoConfiguration
|
||||||
60
aioj-backend-common/aioj-backend-common-feign/pom.xml
Normal file
60
aioj-backend-common/aioj-backend-common-feign/pom.xml
Normal 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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package cn.meowrain.aioj.backend.framework.feign;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
|
||||||
|
@AutoConfiguration
|
||||||
|
public class FeignAutoConfiguration {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
50
aioj-backend-common/aioj-backend-common-log/pom.xml
Normal file
50
aioj-backend-common/aioj-backend-common-log/pom.xml
Normal 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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cn.meowrain.aioj.backend.framework.log.LogAutoConfiguration
|
||||||
65
aioj-backend-common/aioj-backend-common-mybatis/pom.xml
Normal file
65
aioj-backend-common/aioj-backend-common-mybatis/pom.xml
Normal 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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cn.meowrain.backend.common.mybaits.MybatisPlusAutoConfiguration
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
Reference in New Issue
Block a user