Compare commits

...

3 Commits

Author SHA1 Message Date
En
a003cccbeb Merge remote-tracking branch 'origin/master' 2026-03-26 15:07:44 +08:00
En
c73bae7104 fix(后端): 请求日志敏感信息脱敏
- 添加请求参数脱敏功能,对 password、token 等敏感字段进行掩码处理
- 响应结果中的敏感信息同样脱敏
- 敏感字段列表:password, pwd, secret, token, accessToken, refreshToken, oldPassword, newPassword, confirmPassword
- 脱敏掩码:***

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:03:27 +08:00
En
79d98be366 feat(后端): 添加日志链路追踪功能,支持生产环境快速错误定位
- 新增 TraceIdFilter 过滤器,为每个请求生成唯一链路追踪 ID
- 修改 logback-spring.xml,所有日志输出包含 traceId
- 修改 RequestLogAspect,记录用户上下文信息(userId, role)
- 修改 GlobalExceptionHandler,异常日志包含用户信息和请求 URI
- 修改 application-test.yml,添加 SQL 日志全量输出配置
- 修改 JwtAuthenticationFilter,添加 Order 注解确保过滤器顺序
- 新增文档:日志优化方案 - 生产环境错误定位.md

技术细节:
- TraceId 使用 UUID 前 8 位(大写),如 [8DFC19D9]
- 过滤器执行顺序:TraceIdFilter → JwtAuthenticationFilter
- 所有日志文件(主日志、错误日志、请求日志、SQL 日志)统一输出 traceId
- 支持通过 grep "traceId" logs/*.log 快速定位请求完整链路

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 14:40:16 +08:00
9 changed files with 653 additions and 15 deletions

View File

@ -0,0 +1,462 @@
# 日志优化方案 - 生产环境快速错误定位
> 文档创建时间2026-03-26
> 最后更新2026-03-26
## 需求背景
### 问题描述
生产环境有四个日志文件:
- `reading-platform.log` - 主日志INFO 及以上)
- `reading-platform-error.log` - 错误日志ERROR 级别)
- `reading-platform-request.log` - 请求日志
- `reading-platform-sql.log` - SQL 日志WARN 及以上)
**核心问题**:如何在多个日志文件中快速定位同一个请求的错误链路?
### 解决方案
通过 **TraceId链路追踪 ID** + **增强上下文信息** 实现跨日志文件的请求追踪。
## 核心实现
### 1. TraceId 生成与传递
为每个请求生成唯一的 8 位 TraceId在所有日志文件中统一输出。
**TraceId 示例**`[8DFC19D9]`
### 2. 日志格式
```
2026-03-26 10:00:00.123 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 请求开始 [userId=1, role=admin]
```
| 字段 | 说明 |
|------|------|
| `2026-03-26 10:00:00.123` | 时间戳(精确到毫秒) |
| `http-nio-8481-exec-1` | 线程名 |
| `INFO` | 日志级别 |
| `[8DFC19D9]` | **TraceId链路追踪 ID** |
| `c.l.e.common.aspect.RequestLogAspect` | 类名缩写 |
| `userId=1, role=admin` | 用户上下文信息 |
### 3. 用户上下文信息
在请求日志和异常日志中记录用户信息:
- `userId` - 用户 ID
- `role` - 用户角色admin/school/teacher/parent
- `uri` - 请求地址
## 日志示例
### 正常请求日志
```
# reading-platform-request.log
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - ===== 请求开始 [userId=1, role=admin] =====
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 接口地址POST /api/v1/auth/login
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 请求方法com.lesingle.edu.controller.AuthController.login
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 请求参数:{"password":"123456","username":"admin"}
2026-03-26 14:11:11.154 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 响应时间700ms
2026-03-26 14:11:11.164 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 响应结果:{"code":200,"message":"操作成功"}
2026-03-26 14:11:11.164 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - ===== 请求结束 [userId=1, role=admin] =====
```
### 错误日志
```
# reading-platform-error.log
2026-03-26 14:15:30.123 [http-nio-8481-exec-5] ERROR [8DFC19D9] c.l.e.exception.GlobalExceptionHandler - ===== 请求异常 [userId=1, role=admin, uri=/api/v1/admin/users] =====
2026-03-26 14:15:30.123 [http-nio-8481-exec-5] ERROR [8DFC19D9] c.l.e.exception.GlobalExceptionHandler - 异常信息:数据库连接超时
2026-03-26 14:15:30.123 [http-nio-8481-exec-5] ERROR [8DFC19D9] c.l.e.exception.GlobalExceptionHandler - 异常类型java.sql.SQLTransientConnectionException
2026-03-26 14:15:30.125 [http-nio-8481-exec-5] ERROR [8DFC19D9] c.l.e.exception.GlobalExceptionHandler - 堆栈信息:
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:770)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:545)
...
```
### SQL 日志(慢 SQL
```
# reading-platform-sql.log
2026-03-26 14:15:29.500 [http-nio-8481-exec-5] WARN [8DFC19D9] c.l.e.mapper.UserMapper - SQL 执行超时 (2.5s): SELECT * FROM user WHERE status = 'active' AND ...
```
## 生产环境错误定位流程
### 步骤一:在 error.log 中发现错误
```bash
# 实时查看错误日志
tail -f logs/reading-platform-error.log
```
发现错误日志:
```
2026-03-26 14:15:30.123 [http-nio-8481-exec-5] ERROR [8DFC19D9] c.l.e.exception.GlobalExceptionHandler - 处理异常userId=1, role=admin, uri=/api/v1/admin/users
```
**获取 TraceId**: `[8DFC19D9]`
### 步骤二:使用 TraceId 搜索完整链路
```bash
# 方法一grep 命令搜索所有日志文件
grep "8DFC19D9" logs/*.log
# 方法二:按时间查看主日志
grep "8DFC19D9" logs/reading-platform.log
# 方法三:查看请求日志
grep "8DFC19D9" logs/reading-platform-request.log
# 方法四:查看 SQL 日志
grep "8DFC19D9" logs/reading-platform-sql.log
```
### 步骤三:分析完整链路
将搜索结果按时间排序,还原请求的完整执行过程:
```bash
# 综合查看该 TraceId 的所有日志
grep "8DFC19D9" logs/reading-platform*.log | sort
```
**输出示例**
```
2026-03-26 14:15:28.100 [http-nio-8481-exec-5] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 请求开始 [userId=1, role=admin]
2026-03-26 14:15:28.100 [http-nio-8481-exec-5] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 接口地址GET /api/v1/admin/users
2026-03-26 14:15:28.150 [http-nio-8481-exec-5] INFO [8DFC19D9] c.l.e.service.UserService - 开始查询用户列表
2026-03-26 14:15:29.500 [http-nio-8481-exec-5] WARN [8DFC19D9] c.l.e.mapper.UserMapper - SQL 执行超时 (2.5s)
2026-03-26 14:15:30.123 [http-nio-8481-exec-5] ERROR [8DFC19D9] c.l.e.exception.GlobalExceptionHandler - 处理异常:数据库连接超时
```
### 步骤四:定位问题根因
根据完整链路日志,快速定位问题:
1. **请求参数**:查看 `RequestLogAspect` 输出的请求参数
2. **业务逻辑**:查看 Service 层的业务处理日志
3. **SQL 执行**:查看 `reading-platform-sql.log` 中的 SQL 执行情况
4. **异常信息**:查看 `reading-platform-error.log` 中的完整堆栈
## 日志搜索命令参考
### Linux / macOS
```bash
# 1. 按 TraceId 搜索
grep "8DFC19D9" logs/*.log
# 2. 按用户 ID 搜索
grep "userId=1" logs/reading-platform*.log
# 3. 按接口路径搜索
grep "/api/v1/admin/users" logs/reading-platform*.log
# 4. 按时间段搜索(最近 100 行)
tail -100 logs/reading-platform-error.log
# 5. 组合搜索TraceId + 错误)
grep "8DFC19D9" logs/*.log | grep -i "error\|exception"
# 6. 实时跟踪特定 TraceId 的日志
tail -f logs/reading-platform*.log | grep "8DFC19D9"
```
### Windows (PowerShell)
```powershell
# 1. 按 TraceId 搜索
Select-String -Path "logs\*.log" -Pattern "8DFC19D9"
# 2. 按用户 ID 搜索
Select-String -Path "logs\reading-platform*.log" -Pattern "userId=1"
# 3. 实时跟踪日志
Get-Content logs\reading-platform.log -Wait | Select-String "8DFC19D9"
```
## 日志级别配置
### 开发环境 (dev)
| 日志类型 | 级别 | 输出位置 |
|---------|------|---------|
| 业务日志 | DEBUG | 控制台 + FILE |
| 请求日志 | INFO | REQUEST_FILE |
| SQL 日志 | DEBUG | 控制台 + FILE |
### 测试环境 (test)
| 日志类型 | 级别 | 输出位置 |
|---------|------|---------|
| 业务日志 | INFO | 控制台 + FILE + ERROR_FILE |
| 请求日志 | INFO | REQUEST_FILE |
| SQL 日志 | INFO | FILE |
### 生产环境 (prod)
| 日志类型 | 级别 | 输出位置 |
|---------|------|---------|
| 业务日志 | INFO | FILE + ERROR_FILE |
| 请求日志 | INFO | REQUEST_FILE |
| SQL 日志 | WARN | SQL_FILE只记录慢 SQL 和错误 SQL |
## 技术实现
### 文件清单
| 文件 | 说明 |
|------|------|
| `TraceIdFilter.java` | 链路追踪过滤器,生成 TraceId 并放入 MDC |
| `SecurityConfig.java` | 注册 TraceIdFilter |
| `logback-spring.xml` | 日志配置,添加 TraceId 输出格式 |
| `RequestLogAspect.java` | 请求日志切面,记录用户信息 |
| `GlobalExceptionHandler.java` | 全局异常处理,记录上下文信息 |
### TraceId 生成规则
- 使用 UUID 的前 8 位(大写)
- 示例:`8DFC19D9`
- 唯一性保证UUID 重复概率极低
### MDC 上下文管理
```java
// TraceIdFilter.java
MDC.put("traceId", traceId); // 请求开始时放入
try {
filterChain.doFilter(request, response);
} finally {
MDC.clear(); // 请求结束时清理,防止内存泄漏
}
```
## 日志平台集成
### ELK Stack (Elasticsearch + Logstash + Kibana)
在 Kibana 中搜索 TraceId
```
traceId: "8DFC19D9"
```
### Loki + Grafana
在 Grafana 中搜索 TraceId
```
{app="reading-platform"} |= "8DFC19D9"
```
## 最佳实践
1. **日志简洁**:避免输出过长的日志,单行控制在 500 字符以内
2. **结构化**:使用 JSON 格式输出复杂对象,便于日志平台解析
3. **敏感信息脱敏**:密码、手机号、身份证等敏感信息需要脱敏
4. **TraceId 优先**:定位问题时首先获取 TraceId然后搜索完整链路
5. **定期清理**:生产环境日志保留 30 天,避免磁盘空间不足
## 验证方法
### 开发环境验证
```bash
# 1. 启动服务
export SERVER_PORT=8481
cd lesingle-edu-reading-platform-backend
mvn spring-boot:run
# 2. 发送请求
curl http://localhost:8481/api/v1/auth/login -X POST \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
# 3. 查看日志
tail -f logs/reading-platform-request.log
```
期望输出包含 TraceId
```
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] ...
```
### 生产环境验证
```bash
# 1. 执行接口请求
# 2. 在 error.log 中查找错误
# 3. 使用 grep 搜索 TraceId
grep "8DFC19D9" logs/*.log
```
## 变更记录
| 日期 | 变更内容 | 变更人 |
|------|---------|--------|
| 2026-03-26 | 初始版本,实现 TraceId 链路追踪 | - |
| 2026-03-26 | 添加 SQL 错误定位流程和实际验证结果 | - |
## 附录:实际验证结果
### 开发环境 TraceId 验证
启动服务后执行登录接口,查看日志输出:
```bash
# 启动服务
export SERVER_PORT=8481
export SPRING_PROFILES_ACTIVE=dev
cd lesingle-edu-reading-platform-backend
mvn spring-boot:run
# 发送登录请求
curl http://localhost:8481/api/v1/auth/login -X POST \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
```
**实际日志输出**reading-platform.log
```
2026-03-26 14:11:10.287 [http-nio-8481-exec-1] DEBUG [8DFC19D9] c.l.edu.common.aspect.LogAspect - 获取当前用户信息失败
2026-03-26 14:11:11.154 [http-nio-8481-exec-1] DEBUG [8DFC19D9] c.l.e.c.s.JwtTokenRedisService - Token stored for user: admin
2026-03-26 14:11:11.154 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.edu.service.impl.AuthServiceImpl - 管理员登录成功用户名admin
2026-03-26 14:11:11.178 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.edu.common.aspect.LogAspect - 操作日志记录成功 - 模块:认证管理,操作:用户登录
```
**实际日志输出**reading-platform-request.log
```
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - ===== 请求开始 [userId=null, role=null] =====
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 接口地址POST /api/v1/auth/login
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 请求方法com.lesingle.edu.controller.AuthController.login
2026-03-26 14:11:10.455 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 请求参数:{"password":"123456","username":"admin"}
2026-03-26 14:11:11.154 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 响应时间700ms
2026-03-26 14:11:11.164 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - 响应结果:{"code":200,"message":"操作成功"}
2026-03-26 14:11:11.164 [http-nio-8481-exec-1] INFO [8DFC19D9] c.l.e.common.aspect.RequestLogAspect - ===== 请求结束 [userId=null, role=null] =====
```
**验证结论**
- TraceId `[8DFC19D9]` 成功输出到所有日志文件
- 用户上下文信息userId, role正确记录
- 请求参数、响应结果、执行时间完整记录
---
## 附录SQL 错误定位场景模拟
### 场景一SQL 语法错误
**错误原因**:表名或字段名错误
**日志输出示例**
```
# reading-platform-request.log
2026-03-26 15:00:00.100 [http-nio-8481-exec-3] INFO [A1B2C3D4] c.l.e.common.aspect.RequestLogAspect - 请求开始 [userId=1, role=admin]
2026-03-26 15:00:00.100 [http-nio-8481-exec-3] INFO [A1B2C3D4] c.l.e.common.aspect.RequestLogAspect - 接口地址GET /api/v1/admin/users
# reading-platform-sql.log
2026-03-26 15:00:00.150 [http-nio-8481-exec-3] ERROR [A1B2C3D4] com.lesingle.edu.mapper.UserMapper - SQL 执行错误:
### Error querying database. Cause: com.mysql.cj.jdbc.exceptions.SQLError: Table 'reading_platform.userx' doesn't exist
### The error may exist in com/lesingle/edu/mapper/UserMapper.xml
### The error may involve defaultParameterMap
### SQL: SELECT * FROM userx WHERE deleted = 0
### Cause: com.mysql.cj.jdbc.exceptions.SQLError: Table 'reading_platform.userx' doesn't exist
# reading-platform-error.log
2026-03-26 15:00:00.160 [http-nio-8481-exec-3] ERROR [A1B2C3D4] c.l.e.exception.GlobalExceptionHandler - ===== 请求异常 [userId=1, role=admin, uri=/api/v1/admin/users] =====
2026-03-26 15:00:00.160 [http-nio-8481-exec-3] ERROR [A1B2C3D4] c.l.e.exception.GlobalExceptionHandler - 异常类型org.mybatis.spring.MyBatisSystemException
2026-03-26 15:00:00.160 [http-nio-8481-exec-3] ERROR [A1B2C3D4] c.l.e.exception.GlobalExceptionHandler - 异常信息Error querying database. Cause: Table 'reading_platform.userx' doesn't exist
```
**定位步骤**
1. 在 `error.log` 中发现错误,获取 TraceId `[A1B2C3D4]`
2. 使用 `grep "A1B2C3D4" logs/*.log` 搜索完整链路
3. 在 `sql.log` 中查看 SQL 错误原因:表不存在
4. 定位问题:代码中表名写错(`userx` 应为 `user`
---
### 场景二:数据库连接超时
**错误原因**:数据库连接池耗尽或网络问题
**日志输出示例**
```
# reading-platform.log
2026-03-26 15:30:00.100 [http-nio-8481-exec-5] INFO [E5F6G7H8] c.l.e.service.UserService - 开始查询用户列表
2026-03-26 15:30:30.100 [http-nio-8481-exec-5] WARN [E5F6G7H8] c.l.e.mapper.UserMapper - SQL 执行超时 (30s): SELECT * FROM user WHERE ...
# reading-platform-sql.log
2026-03-26 15:30:30.100 [http-nio-8481-exec-5] WARN [E5F6G7H8] com.lesingle.edu.mapper - SQL 执行超时警告:
执行时间30000ms
SQL: SELECT * FROM user WHERE status = 'active' LIMIT 1000
# reading-platform-error.log
2026-03-26 15:30:30.150 [http-nio-8481-exec-5] ERROR [E5F6G7H8] c.l.e.exception.GlobalExceptionHandler - ===== 请求异常 [userId=1, role=admin, uri=/api/v1/admin/users] =====
2026-03-26 15:30:30.150 [http-nio-8481-exec-5] ERROR [E5F6G7H8] c.l.e.exception.GlobalExceptionHandler - 异常类型org.springframework.jdbc.CannotGetJdbcConnectionException
2026-03-26 15:30:30.150 [http-nio-8481-exec-5] ERROR [E5F6G7H8] c.l.e.exception.GlobalExceptionHandler - 异常信息Could not get JDBC Connection; nested exception is com.zaxxer.hikari.pool.HikariPool$TimeoutException: Connection is not available, request timed out after 30000ms
```
**定位步骤**
1. 在 `error.log` 中发现错误,获取 TraceId `[E5F6G7H8]`
2. 搜索完整链路,发现 SQL 执行耗时 30 秒
3. 在 `sql.log` 中查看慢 SQL 语句
4. 定位问题:数据库连接池耗尽或 SQL 执行过慢
**解决方案**
- 检查数据库连接池配置(`max-active` 参数)
- 优化慢 SQL添加索引、减少查询数据量
- 检查数据库服务器负载
---
### 场景三:数据约束违规
**错误原因**:违反唯一约束、外键约束等
**日志输出示例**
```
# reading-platform-request.log
2026-03-26 16:00:00.100 [http-nio-8481-exec-8] INFO [I9J0K1L2] c.l.e.common.aspect.RequestLogAspect - 请求开始 [userId=1, role=admin]
2026-03-26 16:00:00.100 [http-nio-8481-exec-8] INFO [I9J0K1L2] c.l.e.common.aspect.RequestLogAspect - 请求参数:{"username":"admin","email":"admin@example.com"}
# reading-platform-sql.log
2026-03-26 16:00:00.150 [http-nio-8481-exec-8] ERROR [I9J0K1L2] com.lesingle.edu.mapper.UserMapper - SQL 执行错误:
### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLIntegrityConstraintViolationException: Duplicate entry 'admin' for key 'uk_username'
### SQL: INSERT INTO user (username, email, status) VALUES (?, ?, ?)
# reading-platform-error.log
2026-03-26 16:00:00.160 [http-nio-8481-exec-8] ERROR [I9J0K1L2] c.l.e.exception.GlobalExceptionHandler - ===== 请求异常 [userId=1, role=admin, uri=/api/v1/admin/users] =====
2026-03-26 16:00:00.160 [http-nio-8481-exec-8] ERROR [I9J0K1L2] c.l.e.exception.GlobalExceptionHandler - 异常类型org.duplicateKeyException
2026-03-26 16:00:00.160 [http-nio-8481-exec-8] ERROR [I9J0K1L2] c.l.e.exception.GlobalExceptionHandler - 异常信息Duplicate entry 'admin' for key 'uk_username'
```
**定位步骤**
1. 在 `error.log` 中发现错误,获取 TraceId `[I9J0K1L2]`
2. 在 `request.log` 中查看请求参数:`username=admin`
3. 在 `sql.log` 中查看错误:唯一约束违规
4. 定位问题:用户名 `admin` 已存在,无法重复创建
---
## 附录:常用故障排查命令速查
| 场景 | 命令 | 说明 |
|------|------|------|
| 实时查看错误 | `tail -f logs/reading-platform-error.log` | 实时监控错误日志 |
| 搜索 TraceId | `grep "ABC12345" logs/*.log` | 搜索指定请求的所有日志 |
| 查看慢 SQL | `grep "超时\|timeout" logs/reading-platform-sql.log` | 搜索慢 SQL |
| 统计错误数 | `grep -c "ERROR" logs/reading-platform-error.log` | 统计错误数量 |
| 查看最近 100 行 | `tail -100 logs/reading-platform.log` | 查看最近日志 |
| 按时间过滤 | `grep "2026-03-26 14:" logs/*.log` | 过滤指定时间段 |
| 查看用户日志 | `grep "userId=1" logs/*.log` | 查看指定用户的所有请求 |
| 查看接口日志 | `grep "/api/v1/admin/users" logs/*.log` | 查看指定接口的请求 |

View File

@ -1,6 +1,8 @@
package com.lesingle.edu.common.aspect;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.lesingle.edu.common.security.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
@ -17,13 +19,26 @@ import java.util.Objects;
/**
* 请求日志切面
* 记录所有 Controller 层的请求信息
* 记录所有 Controller 层的请求信息敏感信息已脱敏
*/
@Slf4j
@Aspect
@Component
public class RequestLogAspect {
/**
* 敏感字段列表不区分大小写
*/
private static final String[] SENSITIVE_FIELDS = {
"password", "pwd", "secret", "token", "accessToken", "refreshToken",
"oldPassword", "newPassword", "confirmPassword"
};
/**
* 脱敏掩码
*/
private static final String MASK = "***";
/**
* 定义切点Controller 层所有方法
*/
@ -45,15 +60,26 @@ public class RequestLogAspect {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取用户信息可能未登录需要捕获异常
Long userId = null;
String role = null;
try {
userId = SecurityUtils.getCurrentUserId();
role = SecurityUtils.getCurrentRole();
} catch (Exception e) {
// 未登录或 token 无效不记录用户信息
}
// 记录请求开始
long startTime = System.currentTimeMillis();
String requestURI = request.getRequestURI();
String methodType = request.getMethod();
String className = joinPoint.getTarget().getClass().getName();
String methodName = method.getName();
String params = JSON.toJSONString(getRequestParams(joinPoint));
// 请求参数脱敏处理
String params = desensitize(getRequestParams(joinPoint));
log.info("===== 请求开始 =====");
log.info("===== 请求开始 [userId={}, role={}] =====", userId, role);
log.info("接口地址:{} {}", methodType, requestURI);
log.info("请求方法:{}.{}", className, methodName);
log.info("请求参数:{}", params);
@ -66,8 +92,8 @@ public class RequestLogAspect {
// 记录响应结果
log.info("响应时间:{}ms", duration);
log.info("响应结果:{}", JSON.toJSONString(result));
log.info("===== 请求结束 =====");
log.info("响应结果:{}", desensitize(result));
log.info("===== 请求结束 [userId={}, role={}] =====", userId, role);
return result;
} catch (Throwable e) {
@ -75,7 +101,7 @@ public class RequestLogAspect {
long duration = endTime - startTime;
// 记录异常信息
log.error("===== 请求异常 =====");
log.error("===== 请求异常 [userId={}, role={}] =====", userId, role);
log.error("接口地址:{} {}", methodType, requestURI);
log.error("执行时间:{}ms", duration);
log.error("异常信息:{}", e.getMessage());
@ -100,4 +126,72 @@ public class RequestLogAspect {
return args;
}
}
/**
* 请求参数脱敏处理
* 将敏感字段 passwordtoken 替换为 ***
*
* @param original 原始参数对象
* @return 脱敏后的 JSON 字符串
*/
private String desensitize(Object original) {
if (original == null) {
return null;
}
try {
// 转换为 JSONObject 以便修改
String jsonStr = JSON.toJSONString(original);
JSONObject jsonObject = JSON.parseObject(jsonStr);
if (jsonObject != null) {
maskSensitiveFields(jsonObject);
return jsonObject.toJSONString();
}
return jsonStr;
} catch (Exception e) {
// 解析失败时返回原始 JSON
log.warn("请求参数脱敏失败:{}", e.getMessage());
return JSON.toJSONString(original);
}
}
/**
* 递归掩码敏感字段
*
* @param jsonObject 要处理的 JSON 对象
*/
private void maskSensitiveFields(JSONObject jsonObject) {
if (jsonObject == null) {
return;
}
for (String key : jsonObject.keySet()) {
if (isSensitiveField(key)) {
jsonObject.put(key, MASK);
} else {
// 递归处理嵌套的 JSONObject
Object value = jsonObject.get(key);
if (value instanceof JSONObject) {
maskSensitiveFields((JSONObject) value);
}
}
}
}
/**
* 判断字段名是否为敏感字段
*
* @param fieldName 字段名
* @return true 如果是敏感字段
*/
private boolean isSensitiveField(String fieldName) {
if (fieldName == null) {
return false;
}
String lowerFieldName = fieldName.toLowerCase();
for (String sensitiveField : SENSITIVE_FIELDS) {
if (lowerFieldName.equals(sensitiveField.toLowerCase())) {
return true;
}
}
return false;
}
}

View File

@ -1,5 +1,6 @@
package com.lesingle.edu.common.config;
import com.lesingle.edu.common.filter.TraceIdFilter;
import com.lesingle.edu.common.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
@ -30,7 +31,7 @@ import java.util.List;
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final TraceIdFilter traceIdFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@ -52,7 +53,7 @@ public class SecurityConfig {
// All other requests require authentication
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(traceIdFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

View File

@ -2,6 +2,7 @@ package com.lesingle.edu.common.exception;
import com.lesingle.edu.common.enums.ErrorCode;
import com.lesingle.edu.common.response.Result;
import com.lesingle.edu.common.security.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@ -34,7 +35,16 @@ public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Object> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.warn("业务异常 at {}: {}", request.getRequestURI(), e.getMessage());
// 获取用户信息
Long userId = null;
String role = null;
try {
userId = SecurityUtils.getCurrentUserId();
role = SecurityUtils.getCurrentRole();
} catch (Exception ex) {
// 未登录或 token 无效
}
log.warn("业务异常 at {} [userId={}, role={}]: {}", request.getRequestURI(), userId, role, e.getMessage());
return Result.error(e.getCode(), e.getMessage(), e.getData());
}
@ -103,7 +113,16 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
log.error("系统异常 at {}: ", request.getRequestURI(), e);
// 获取用户信息
Long userId = null;
String role = null;
try {
userId = SecurityUtils.getCurrentUserId();
role = SecurityUtils.getCurrentRole();
} catch (Exception ex) {
// 未登录或 token 无效
}
log.error("系统异常 at {} [userId={}, role={}]: ", request.getRequestURI(), userId, role, e);
// 开发/测试环境返回详细错误信息便于排查
boolean isDev = activeProfile != null && (activeProfile.contains("dev") || activeProfile.contains("test"));
String msg = isDev ? getExceptionMessage(e) : "系统内部错误";

View File

@ -0,0 +1,57 @@
package com.lesingle.edu.common.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* 链路追踪过滤器
* 为每个请求生成唯一的 traceId放入 MDC 上下文便于日志链路追踪
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1) // 最优先执行确保 traceId 在所有过滤器之前生成
public class TraceIdFilter extends OncePerRequestFilter {
/**
* MDC traceId key
*/
private static final String TRACE_ID_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 生成 traceId使用 UUID 8
String traceId = generateTraceId();
// 放入 MDC 上下文
MDC.put(TRACE_ID_KEY, traceId);
try {
// 执行过滤器链
filterChain.doFilter(request, response);
} finally {
// 清理 MDC防止内存泄漏
MDC.clear();
}
}
/**
* 生成 traceId
* 使用 UUID 的前 8 保证唯一性的同时保持简洁
*/
private String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
}
}

View File

@ -18,6 +18,8 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -37,6 +39,7 @@ import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 10) // TraceIdFilter 之后执行
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;

View File

@ -58,6 +58,8 @@ spring:
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
# 开启 SQL 日志输出(测试环境全量记录)
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
# JWT 配置(测试环境)
jwt:

View File

@ -16,7 +16,7 @@
<!-- ==================== 控制台输出 ==================== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
@ -42,7 +42,7 @@
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
@ -68,7 +68,7 @@
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
@ -87,7 +87,7 @@
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
@ -106,7 +106,7 @@
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB