Переглянути джерело

feat(logging): 增强日志功能,新增请求和响应日志记录,敏感信息脱敏处理

mcbaiyun 1 тиждень тому
батько
коміт
009a961981

+ 7 - 1
build-and-run.bat

@@ -1,6 +1,9 @@
 @echo off
 echo Building and Running Chronic Disease App...
 
+REM Switch to script directory to ensure Maven runs in project folder
+pushd "%~dp0"
+
 REM Check if Maven is installed
 where mvn >nul 2>nul
 if %errorlevel% neq 0 (
@@ -45,4 +48,7 @@ if %errorlevel% neq 0 (
 )
 
 echo Running application...
-java -jar target/ChronicDiseaseApp-1.0-SNAPSHOT.jar
+java -jar target/ChronicDiseaseApp-1.0-SNAPSHOT.jar
+
+REM Return to original directory
+popd

+ 69 - 0
docs/Dev/modules/Processing/logging-optimization-plan.md

@@ -0,0 +1,69 @@
+## 日志优化评估与补丁跟踪
+
+**概述**:基于现有日志(已含 `requestId` / `traceId`),本文档记录对你提出的日志优化建议的评估、对现有配置的影响、需要的补丁清单与实施步骤,可作为补丁进展的跟踪记录。
+
+**当前关键文件**:
+- **当前 logback 配置**: [api-springboot/src/main/resources/logback-spring.xml](api-springboot/src/main/resources/logback-spring.xml#L1-L200)
+- **Trace 拦截器**: [api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/config/TraceInterceptor.java](api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/config/TraceInterceptor.java#L1-L200)
+- **Maven POM**: [api-springboot/pom.xml](api-springboot/pom.xml#L1-L120)
+
+**1. 评估摘要(快速结论)**
+- **可行性**:你列出的优化(logback 重构、请求拦截器、脱敏 appender、AOP)均可实现,且与现有 `logback-spring.xml` 兼容,但需替换或合并已有配置(不要并存多个 logback-spring 配置以免冲突)。
+- **影响范围**:主要影响 `logback` 配置;当前决定先不引入日志 JSON 输出(FILE_JSON)或额外的 `logstash-logback-encoder` 依赖,后续如需再考虑添加。
+- **性能/风险**:脱敏正则在高 QPS 下可能带来 CPU 与延迟,建议异步输出或在代码层避免打印敏感值优先;额外 appender 会增加磁盘 IO。
+
+**2. 是否影响现有日志配置 / 冲突点**
+- 不会破坏 MDC 的现有使用,但要统一 MDC key(当前使用 `requestId` / `traceId`)。若要添加 `spanId`,需在拦截器或链路追踪库中写入 `MDC.put("spanId", ...)`。
+- 必须确保仓库中只有一个 `logback-spring.xml` 生效(当前文件位于上面链接)。替换时请备份原文件。
+- 当前已决定不引入 JSON encoder;若未来启用 JSON 输出,再在 `pom.xml` 添加 `net.logstash.logback:logstash-logback-encoder`。
+
+**3. 依赖变更**
+- 当前无需立即变更 `pom.xml`,保留为文本日志;若未来决定使用结构化 JSON 日志,可考虑添加依赖(示例): `net.logstash.logback:logstash-logback-encoder:7.4`。
+- 若使用异步 appender,依赖可以直接使用 logback 自带 `AsyncAppender`,无需额外库。
+
+- **4. 建议补丁清单(要修改/新增的文件)**
+- 替换/更新 `api-springboot/src/main/resources/logback-spring.xml`:使用示例中更简洁的 pattern、增加 `FILE_DETAIL`、`FILE_ERROR`,并使用 `%X{traceId:-}` 等避免空括号。
+- 新增 `RequestLoggingFilter`:`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/filter/RequestLoggingFilter.java`(记录 URI、耗时、client IP、MDC traceId,慢请求告警)。
+- 新增 `LoggingAspect`:`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/aop/LoggingAspect.java`(入参/出参脱敏与方法耗时、慢方法告警)。
+- 新增 `LogDesensitizeAppender`(可选草稿):`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/log/LogDesensitizeAppender.java`(若采用,全链路异步写入并仅对必要消息做替换)。
+- 新增 `RequestLoggingFilter`:`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/filter/RequestLoggingFilter.java`(记录 URI、耗时、client IP、MDC traceId,慢请求告警)。
+- 新增 `LoggingAspect`:`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/aop/LoggingAspect.java`(入参/出参脱敏与方法耗时、慢方法告警)。
+- 新增 `LogDesensitizeAppender`(可选草稿):`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/log/LogDesensitizeAppender.java`(若采用,全链路异步写入并仅对必要消息做替换)。
+
+**5. 实施步骤(推荐顺序)**
+1. 备份并替换 `logback-spring.xml`,保证新配置引用的 appender 与 logger 正确(避免重复写入)。
+2. 部署 `RequestLoggingFilter`(低风险,记录必要请求信息,注意 body/headers 不默认记录)。
+3. 在代码中查找并移除/屏蔽直接打印敏感值的语句(优先于全局脱敏)。
+4. 添加 `LoggingAspect`(AOP)以覆盖 Controller 入参/出参日志并做脱敏处理。
+5. 若确有必要且确认性能可接受,再实现 `LogDesensitizeAppender` 并通过 `AsyncAppender` 链接到文件/控制台。 
+6. 回归测试(低流量环境),并做压力测试以观察 CPU/IO 影响;调整异步队列大小与文件滚动策略。
+
+**6. 测试与验证要点**
+- 启动应用,检查控制台/`logs/app.log` 是否仅有一份消息(无重复)。
+- 验证 MDC 字段在日志中展示:例如 `[traceId]` 不再空白(或显示 `-` 作为默认)。
+- 验证敏感字段被脱敏(示例请求中 `secret`/`token` 显示为 `***`)。
+- 运行压力测试(如 ApacheBench 或 JMeter)观察新增正则或额外 appender 是否导致延迟或 CPU 升高。
+
+**7. 风险与应对**
+- 脱敏正则造成 CPU 占用:使用预编译 Pattern、限制匹配范围、或采用异步 appender。
+- 日志双写/重复:检查 logger 的 `additivity` 与 root logger,必要时将业务 logger additivity 设为 false。
+- 敏感信息仍可能在异常栈或第三方库中打印:最好在代码中根本避免将 secret 写入日志。
+
+**8. 补丁进度(来自 TODO 跟踪)**
+- 已完成:检查现有配置与 MDC 使用;校验依赖是否存在;评估兼容性。
+- 进行中:评估脱敏 Appender 的可行性及性能影响(id=4)。
+- 未开始:添加 Filter/AOP 与提交可执行补丁并运行测试(id=5、6)。
+ - 已应用:备份并替换 `logback-spring.xml`,已添加异步文件与错误文件 appender(步骤 1 完成)。
+ - 已应用:备份并替换 `logback-spring.xml`,已添加异步文件与错误文件 appender(步骤 1 完成)。
+ - 已应用:新增 `RequestLoggingFilter`(`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/filter/RequestLoggingFilter.java`),记录请求开始/结束、耗时及慢请求告警(步骤 2 完成)。
+ - 已应用:新增 `RequestLoggingFilter`(`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/filter/RequestLoggingFilter.java`),记录请求开始/结束、耗时及慢请求告警(步骤 2 完成)。
+ - 已应用:新增 `LoggingAspect`(`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/aop/LoggingAspect.java`),记录 Controller 入参/出参(脱敏)、耗时与慢方法告警(步骤 3 完成)。
+ - 已应用:新增 `LoggingAspect`(`api-springboot/src/main/java/work/baiyun/chronicdiseaseapp/aop/LoggingAspect.java`),记录 Controller 入参/出参(脱敏)、耗时与慢方法告警(步骤 3 完成)。
+ - 已应用:修正 `build-and-run.bat`,脚本现在会切换到脚本目录(`api-springboot`)再执行 Maven 和启动命令,避免在仓库根目录运行导致的错误(脚本修正完成)。
+
+**9. 下一步(我可以代劳)**
+- 我可以帮你逐项生成补丁:修改 `pom.xml`、替换 `logback-spring.xml`、新增 `RequestLoggingFilter`、`LoggingAspect`、`LogDesensitizeAppender` 草稿,并运行静态检查;是否现在开始按照建议顺序生成这些补丁?
+
+---
+
+文档生成时间:2025-12-22  本文为补丁与评估记录,后续补丁进展会以 TODO 列表同步更新。

+ 0 - 0
docs/Dev/modules/Processing/critical-values-backend-design.md → docs/Dev/modules/危急值功能设计文档.md


+ 93 - 0
src/main/java/work/baiyun/chronicdiseaseapp/aop/LoggingAspect.java

@@ -0,0 +1,93 @@
+package work.baiyun.chronicdiseaseapp.aop;
+
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Aspect
+@Component
+@Slf4j
+public class LoggingAspect {
+
+    private static final long SLOW_METHOD_MS = 1000L;
+
+    private static final Pattern TOKEN_PATTERN = Pattern.compile("(token=)([a-f0-9]{32})", Pattern.CASE_INSENSITIVE);
+    private static final Pattern SECRET_PATTERN = Pattern.compile("(secret=)([a-f0-9]{32})", Pattern.CASE_INSENSITIVE);
+
+    @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
+            "@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
+            "@annotation(org.springframework.web.bind.annotation.PostMapping)")
+    public Object logController(ProceedingJoinPoint joinPoint) throws Throwable {
+        String className = joinPoint.getTarget() == null ? "" : joinPoint.getTarget().getClass().getSimpleName();
+        String methodName = joinPoint.getSignature().getName();
+
+        Object[] args = joinPoint.getArgs();
+        try {
+            if (log.isDebugEnabled()) {
+                log.debug("{}.{}() 入参: {}", className, methodName, maskSensitiveArgs(args));
+            }
+
+            long start = System.currentTimeMillis();
+            Object result = joinPoint.proceed();
+            long cost = System.currentTimeMillis() - start;
+
+            if (log.isDebugEnabled()) {
+                log.debug("{}.{}() 耗时: {}ms, 结果: {}", className, methodName, cost, maskSensitiveResult(result));
+            }
+
+            if (cost > SLOW_METHOD_MS) {
+                log.warn("{}.{}() 执行缓慢: {}ms", className, methodName, cost);
+            }
+
+            return result;
+
+        } catch (Throwable t) {
+            long cost = System.currentTimeMillis();
+            log.error("{}.{}() 异常: {}", className, methodName, t.getMessage(), t);
+            throw t;
+        }
+    }
+
+    private Object maskSensitiveArgs(Object[] args) {
+        if (args == null) return null;
+        StringBuilder sb = new StringBuilder();
+        for (Object a : args) {
+            if (a == null) {
+                sb.append("null;");
+            } else {
+                String s = a.toString();
+                sb.append(desensitize(s)).append(";");
+            }
+        }
+        return sb.toString();
+    }
+
+    private Object maskSensitiveResult(Object result) {
+        if (result == null) return null;
+        String s = result.toString();
+        return desensitize(s);
+    }
+
+    private String desensitize(String input) {
+        if (input == null || input.isEmpty()) return input;
+        String out = replacePattern(TOKEN_PATTERN, input);
+        out = replacePattern(SECRET_PATTERN, out);
+        return out;
+    }
+
+    private String replacePattern(Pattern p, String input) {
+        Matcher m = p.matcher(input);
+        StringBuffer sb = new StringBuffer();
+        while (m.find()) {
+            String g1 = m.group(1);
+            m.appendReplacement(sb, g1 + "***");
+        }
+        m.appendTail(sb);
+        return sb.toString();
+    }
+}

+ 93 - 0
src/main/java/work/baiyun/chronicdiseaseapp/filter/RequestLoggingFilter.java

@@ -0,0 +1,93 @@
+package work.baiyun.chronicdiseaseapp.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;
+
+@Component
+@Slf4j
+@Order(Ordered.LOWEST_PRECEDENCE - 50)
+public class RequestLoggingFilter extends OncePerRequestFilter {
+
+    private static final long SLOW_REQUEST_MS = 3000L;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request,
+                                    HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        long start = System.currentTimeMillis();
+
+        // ensure traceId/requestId/spanId exist in MDC for this request
+        String traceId = MDC.get("traceId");
+        if (traceId == null || traceId.isEmpty()) {
+            String fromHeader = request.getHeader("X-Trace-Id");
+            traceId = (fromHeader == null || fromHeader.isEmpty()) ? UUID.randomUUID().toString() : fromHeader;
+            MDC.put("traceId", traceId);
+            request.setAttribute("traceId", traceId);
+        }
+
+        String requestId = MDC.get("requestId");
+        if (requestId == null || requestId.isEmpty()) {
+            String fromHeader = request.getHeader("X-Request-Id");
+            requestId = (fromHeader == null || fromHeader.isEmpty()) ? UUID.randomUUID().toString() : fromHeader;
+            MDC.put("requestId", requestId);
+            request.setAttribute("requestId", requestId);
+        }
+
+        try {
+            if (log.isInfoEnabled()) {
+                log.info("▶▶ {} {} - {} [traceId:{}]", request.getMethod(), getRequestUri(request), getClientIp(request), traceId);
+            }
+
+            filterChain.doFilter(request, response);
+
+        } finally {
+            long cost = System.currentTimeMillis() - start;
+            int status = response.getStatus();
+            if (log.isInfoEnabled()) {
+                log.info("◀◀ {} {} - {}ms [status:{}, traceId:{}]",
+                        request.getMethod(), getRequestUriWithQuery(request), cost, status, traceId);
+            }
+
+            if (cost > SLOW_REQUEST_MS) {
+                log.warn("慢请求: {} {} 耗时: {}ms [status:{}, traceId:{}]", request.getMethod(), getRequestUriWithQuery(request), cost, status, traceId);
+            }
+
+            // 清理 MDC 中的值(若不是由后续组件清理)
+            MDC.remove("requestId");
+            MDC.remove("traceId");
+            MDC.remove("spanId");
+        }
+    }
+
+    private String getRequestUri(HttpServletRequest request) {
+        return request.getRequestURI();
+    }
+
+    private String getRequestUriWithQuery(HttpServletRequest request) {
+        String qs = request.getQueryString();
+        if (qs == null || qs.isEmpty()) return request.getRequestURI();
+        return request.getRequestURI() + "?" + qs;
+    }
+
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        return ip;
+    }
+}

+ 57 - 5
src/main/resources/logback-spring.xml

@@ -1,25 +1,77 @@
 <configuration>
     <property name="LOG_PATH" value="${LOG_PATH:-./logs}" />
 
+    <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId:-}] [%X{traceId:-}] [%X{spanId:-}] %-5level %logger{36} - %msg%n" />
+
+    <!-- Console -->
     <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
         <encoder>
-            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
     </appender>
 
-    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+    <!-- Detailed rolling file -->
+    <appender name="FILE_DETAIL" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <file>${LOG_PATH}/app.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${LOG_PATH}/app.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <fileNamePattern>${LOG_PATH}/app.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
             <maxHistory>30</maxHistory>
         </rollingPolicy>
         <encoder>
-            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
     </appender>
 
+    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="FILE_DETAIL" />
+        <queueSize>512</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+    </appender>
+
+    <!-- Error-only rolling file -->
+    <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_PATH}/error.log</file>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>WARN</level>
+        </filter>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>90</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <appender name="ASYNC_ERROR" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="FILE_ERROR" />
+        <queueSize>256</queueSize>
+    </appender>
+
+    <!-- Application logger -->
+    <logger name="work.baiyun.chronicdiseaseapp" level="DEBUG" additivity="false">
+        <appender-ref ref="CONSOLE" />
+        <appender-ref ref="ASYNC_FILE" />
+    </logger>
+
+    <!-- Third-party libraries reduce noise -->
+    <logger name="org.springframework" level="INFO" />
+    <logger name="org.hibernate" level="WARN" />
+    <logger name="com.zaxxer.hikari" level="WARN" />
+    <logger name="org.apache" level="WARN" />
+    <logger name="org.mybatis" level="WARN" />
+
+    <!-- SQL logs (if needed) -->
+    <logger name="work.baiyun.chronicdiseaseapp.mapper" level="DEBUG">
+        <appender-ref ref="ASYNC_FILE" />
+    </logger>
+
     <root level="INFO">
         <appender-ref ref="CONSOLE" />
-        <appender-ref ref="FILE" />
+        <appender-ref ref="ASYNC_FILE" />
+        <appender-ref ref="ASYNC_ERROR" />
     </root>
 </configuration>