diff --git a/docs/日志优化方案 - 生产环境错误定位.md b/docs/日志优化方案 - 生产环境错误定位.md new file mode 100644 index 0000000..b80428c --- /dev/null +++ b/docs/日志优化方案 - 生产环境错误定位.md @@ -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` | 查看指定接口的请求 | diff --git a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/aspect/RequestLogAspect.java b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/aspect/RequestLogAspect.java index 018eaf1..6576aab 100644 --- a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/aspect/RequestLogAspect.java +++ b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/aspect/RequestLogAspect.java @@ -1,6 +1,7 @@ package com.lesingle.edu.common.aspect; import com.alibaba.fastjson2.JSON; +import com.lesingle.edu.common.security.SecurityUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; @@ -45,6 +46,16 @@ 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(); @@ -53,7 +64,7 @@ public class RequestLogAspect { String methodName = method.getName(); String params = JSON.toJSONString(getRequestParams(joinPoint)); - log.info("===== 请求开始 ====="); + log.info("===== 请求开始 [userId={}, role={}] =====", userId, role); log.info("接口地址:{} {}", methodType, requestURI); log.info("请求方法:{}.{}", className, methodName); log.info("请求参数:{}", params); @@ -67,7 +78,7 @@ public class RequestLogAspect { // 记录响应结果 log.info("响应时间:{}ms", duration); log.info("响应结果:{}", JSON.toJSONString(result)); - log.info("===== 请求结束 ====="); + log.info("===== 请求结束 [userId={}, role={}] =====", userId, role); return result; } catch (Throwable e) { @@ -75,7 +86,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()); diff --git a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/config/SecurityConfig.java b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/config/SecurityConfig.java index 2287580..2148a13 100644 --- a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/config/SecurityConfig.java +++ b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/config/SecurityConfig.java @@ -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(); } diff --git a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/exception/GlobalExceptionHandler.java b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/exception/GlobalExceptionHandler.java index 94eb9b2..42c39ee 100644 --- a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/exception/GlobalExceptionHandler.java +++ b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/exception/GlobalExceptionHandler.java @@ -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 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 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) : "系统内部错误"; diff --git a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/filter/TraceIdFilter.java b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/filter/TraceIdFilter.java new file mode 100644 index 0000000..eda588b --- /dev/null +++ b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/filter/TraceIdFilter.java @@ -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(); + } +} diff --git a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/security/JwtAuthenticationFilter.java b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/security/JwtAuthenticationFilter.java index d7e19b5..d585aff 100644 --- a/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/security/JwtAuthenticationFilter.java +++ b/lesingle-edu-reading-platform-backend/src/main/java/com/lesingle/edu/common/security/JwtAuthenticationFilter.java @@ -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; diff --git a/lesingle-edu-reading-platform-backend/src/main/resources/application-test.yml b/lesingle-edu-reading-platform-backend/src/main/resources/application-test.yml index 91244e9..ed5d4fc 100644 --- a/lesingle-edu-reading-platform-backend/src/main/resources/application-test.yml +++ b/lesingle-edu-reading-platform-backend/src/main/resources/application-test.yml @@ -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: diff --git a/lesingle-edu-reading-platform-backend/src/main/resources/logback-spring.xml b/lesingle-edu-reading-platform-backend/src/main/resources/logback-spring.xml index 4a58c2f..33d66f8 100644 --- a/lesingle-edu-reading-platform-backend/src/main/resources/logback-spring.xml +++ b/lesingle-edu-reading-platform-backend/src/main/resources/logback-spring.xml @@ -16,7 +16,7 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n UTF-8 @@ -42,7 +42,7 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n UTF-8 @@ -68,7 +68,7 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n UTF-8 @@ -87,7 +87,7 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n UTF-8 @@ -106,7 +106,7 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n UTF-8 diff --git a/lesingle-edu-reading-platform-frontend/public/logo.png b/lesingle-edu-reading-platform-frontend/public/logo.png new file mode 100644 index 0000000..456046e Binary files /dev/null and b/lesingle-edu-reading-platform-frontend/public/logo.png differ