Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liu
2026-02-03 10:10:11 +08:00
commit 14289beb66
45 changed files with 15479 additions and 0 deletions

View File

@@ -0,0 +1,831 @@
# 模块12错误处理与日志
---
## 🎨 UI设计系统概览
> **完整设计系统文档请参考:** `UI设计系统.md`
### 核心设计原则
- **现代简约**:界面清晰,层次分明
- **专业高效**:减少操作步骤,提升工作效率
- **一致性**:统一的视觉语言和交互模式
- **可访问性**符合WCAG 2.1 AA标准
### 关键设计令牌
**颜色系统:**
- 主色:`#0d6efd`(操作按钮、选中状态)
- 成功:`#198754`(连接成功状态)
- 危险:`#dc3545`(删除操作、错误提示)
- 深灰:`#212529`(导航栏背景)
- 浅灰:`#e9ecef`(工具栏背景)
**字体系统:**
- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等
- 正文14px行高1.5
- 标题20-32px行高1.2-1.4
- 小号文字12px文件大小、日期等
**间距系统:**
- 基础单位8px
- 标准间距16px1rem
- 组件内边距8px-16px
**组件规范:**
- 导航栏高度48px深色背景
- 工具栏浅灰背景按钮间距8px
- 文件项最小高度44px悬停效果150ms
- 按钮圆角4px过渡150ms
**交互规范:**
- 悬停效果150ms过渡
- 触摸目标最小44x44px
- 键盘导航Tab、Enter、Delete、F2、F5、Esc
- 焦点状态2px蓝色轮廓
**响应式断点:**
- 移动端:< 768px双面板垂直排列
- 平板768px - 1024px
- 桌面:> 1024px标准布局
---
## 12.1 全局异常处理
### 12.1.1 自定义异常类
```java
package com.sftp.manager.exception;
public class SftpException extends RuntimeException {
private String errorCode;
public SftpException(String message) {
super(message);
}
public SftpException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
```
```java
package com.sftp.manager.exception;
public class FileOperationException extends RuntimeException {
private String filePath;
public FileOperationException(String message, String filePath) {
super(message);
this.filePath = filePath;
}
public String getFilePath() {
return filePath;
}
}
```
```java
package com.sftp.manager.exception;
public class ConnectionException extends RuntimeException {
private String connectionId;
public ConnectionException(String message, String connectionId) {
super(message);
this.connectionId = connectionId;
}
public ConnectionException(String message, Throwable cause, String connectionId) {
super(message, cause);
this.connectionId = connectionId;
}
public String getConnectionId() {
return connectionId;
}
}
```
### 12.1.2 全局异常处理器
```java
package com.sftp.manager.exception;
import com.sftp.manager.dto.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(SftpException.class)
public ResponseEntity<ApiResponse> handleSftpException(SftpException e) {
ApiResponse<Object> response = new ApiResponse<>();
response.setSuccess(false);
response.setMessage("SFTP操作失败");
response.setError(e.getMessage());
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "SFTP_ERROR");
errorDetail.put("message", e.getMessage());
if (e.getErrorCode() != null) {
errorDetail.put("errorCode", e.getErrorCode());
}
response.setData(errorDetail);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(FileOperationException.class)
public ResponseEntity<ApiResponse> handleFileOperationException(FileOperationException e) {
ApiResponse<Object> response = new ApiResponse<>();
response.setSuccess(false);
response.setMessage("文件操作失败");
response.setError(e.getMessage());
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "FILE_OPERATION_ERROR");
errorDetail.put("message", e.getMessage());
errorDetail.put("filePath", e.getFilePath());
response.setData(errorDetail);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(ConnectionException.class)
public ResponseEntity<ApiResponse> handleConnectionException(ConnectionException e) {
ApiResponse<Object> response = new ApiResponse<>();
response.setSuccess(false);
response.setMessage("连接失败");
response.setError(e.getMessage());
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "CONNECTION_ERROR");
errorDetail.put("message", e.getMessage());
if (e.getConnectionId() != null) {
errorDetail.put("connectionId", e.getConnectionId());
}
if (e.getCause() != null) {
errorDetail.put("cause", e.getCause().getMessage());
}
response.setData(errorDetail);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse> handleIllegalArgumentException(IllegalArgumentException e) {
ApiResponse<Object> response = new ApiResponse<>();
response.setSuccess(false);
response.setMessage("参数错误");
response.setError(e.getMessage());
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "INVALID_PARAMETER");
errorDetail.put("message", e.getMessage());
response.setData(errorDetail);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
ApiResponse<Object> response = new ApiResponse<>();
response.setSuccess(false);
response.setMessage("文件大小超过限制");
response.setError("上传的文件大小超过最大限制");
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "FILE_TOO_LARGE");
errorDetail.put("message", "上传的文件大小超过最大限制");
errorDetail.put("maxSize", e.getMaxUploadSize());
response.setData(errorDetail);
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse> handleException(Exception e) {
ApiResponse<Object> response = new ApiResponse<>();
response.setSuccess(false);
response.setMessage("系统错误");
response.setError("服务器内部错误");
Map<String, Object> errorDetail = new HashMap<>();
errorDetail.put("code", "INTERNAL_SERVER_ERROR");
errorDetail.put("message", e.getMessage());
errorDetail.put("type", e.getClass().getSimpleName());
response.setData(errorDetail);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
```
### 12.1.3 错误信息工具类
```java
package com.sftp.manager.util;
public class ErrorMessageUtil {
public static String getConnectionErrorMessage(Exception e) {
String message = e.getMessage();
if (message == null) {
return "连接失败";
}
if (message.contains("Auth fail")) {
return "认证失败:用户名或密码错误";
}
if (message.contains("timeout")) {
return "连接超时:服务器响应超时";
}
if (message.contains("Connection refused")) {
return "连接被拒绝:服务器拒绝连接";
}
if (message.contains("UnknownHost")) {
return "主机名无法解析:请检查服务器地址";
}
return "连接失败:" + message;
}
public static String getFileOperationErrorMessage(Exception e) {
String message = e.getMessage();
if (message == null) {
return "文件操作失败";
}
if (message.contains("No such file")) {
return "文件或目录不存在";
}
if (message.contains("Permission denied")) {
return "权限不足";
}
if (message.contains("No space left")) {
return "磁盘空间不足";
}
if (message.contains("Directory not empty")) {
return "目录不为空";
}
return "文件操作失败:" + message;
}
}
```
## 12.2 日志配置
### 12.2.1 Logback配置logback-spring.xml
```xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 日志文件路径 -->
<property name="LOG_PATH" value="logs"/>
<property name="LOG_FILE" value="sftp-manager"/>
<!-- 控制台输出 -->
<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>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE}.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<!-- 错误日志文件 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_FILE}-error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<!-- SQL日志 -->
<logger name="org.hibernate.SQL" level="DEBUG"/>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE"/>
<!-- 应用日志级别 -->
<logger name="com.sftp.manager" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</configuration>
```
### 12.2.2 日志工具类
```java
package com.sftp.manager.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogUtil {
public static void logOperation(Logger logger, String operation,
String sessionId, String sourcePath,
String targetPath, boolean success,
String error) {
StringBuilder sb = new StringBuilder();
sb.append("操作: ").append(operation);
sb.append(", 会话: ").append(sessionId);
sb.append(", 源路径: ").append(sourcePath);
if (targetPath != null) {
sb.append(", 目标路径: ").append(targetPath);
}
sb.append(", 结果: ").append(success ? "成功" : "失败");
if (error != null) {
sb.append(", 错误: ").append(error);
}
if (success) {
logger.info(sb.toString());
} else {
logger.error(sb.toString());
}
}
public static void logConnection(Logger logger, String operation,
String connectionId, boolean success,
String error) {
StringBuilder sb = new StringBuilder();
sb.append("连接操作: ").append(operation);
sb.append(", 连接ID: ").append(connectionId);
sb.append(", 结果: ").append(success ? "成功" : "失败");
if (error != null) {
sb.append(", 错误: ").append(error);
}
if (success) {
logger.info(sb.toString());
} else {
logger.error(sb.toString());
}
}
}
```
## 12.3 操作日志记录
### 12.3.1 操作日志实体
```java
package com.sftp.manager.model;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "operation_logs")
public class OperationLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "operation_type", nullable = false)
private String operationType; // 操作类型upload, download, delete等
@Column(name = "session_id")
private String sessionId; // 会话ID
@Column(name = "source_path", length = 1000)
private String sourcePath; // 源路径
@Column(name = "target_path", length = 1000)
private String targetPath; // 目标路径
@Column(nullable = false)
private boolean success; // 是否成功
@Column(columnDefinition = "TEXT")
private String errorMessage; // 错误信息
@Column(name = "operation_time", nullable = false)
private LocalDateTime operationTime; // 操作时间
@Column(name = "user_ip", length = 50)
private String userIp; // 用户IP
@PrePersist
protected void onCreate() {
operationTime = LocalDateTime.now();
}
}
```
### 12.3.2 操作日志Repository
```java
package com.sftp.manager.repository;
import com.sftp.manager.model.OperationLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface OperationLogRepository extends JpaRepository<OperationLog, Long> {
List<OperationLog> findBySessionIdOrderByOperationTimeDesc(String sessionId);
List<OperationLog> findByOperationTypeOrderByOperationTimeDesc(String operationType);
@Query("SELECT o FROM OperationLog o WHERE o.operationTime BETWEEN :startTime AND :endTime ORDER BY o.operationTime DESC")
List<OperationLog> findByOperationTimeBetween(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
@Query("SELECT o FROM OperationLog o WHERE o.success = false ORDER BY o.operationTime DESC")
List<OperationLog> findFailedOperations();
}
```
### 12.3.3 操作日志服务
```java
package com.sftp.manager.service;
import com.sftp.manager.model.OperationLog;
import com.sftp.manager.repository.OperationLogRepository;
import com.sftp.manager.util.LogUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class OperationLogService {
private static final Logger logger = LoggerFactory.getLogger(OperationLogService.class);
@Autowired
private OperationLogRepository logRepository;
public void logOperation(String operationType, String sessionId,
String sourcePath, String targetPath,
boolean success, String errorMessage) {
OperationLog log = new OperationLog();
log.setOperationType(operationType);
log.setSessionId(sessionId);
log.setSourcePath(sourcePath);
log.setTargetPath(targetPath);
log.setSuccess(success);
log.setErrorMessage(errorMessage);
// 获取用户IP
HttpServletRequest request = getCurrentRequest();
if (request != null) {
String ip = getClientIpAddress(request);
log.setUserIp(ip);
}
try {
logRepository.save(log);
// 记录到日志文件
LogUtil.logOperation(logger, operationType, sessionId,
sourcePath, targetPath, success, errorMessage);
} catch (Exception e) {
logger.error("保存操作日志失败", e);
}
}
public List<OperationLog> getOperationLogs(String sessionId) {
return logRepository.findBySessionIdOrderByOperationTimeDesc(sessionId);
}
public List<OperationLog> getFailedOperations() {
return logRepository.findFailedOperations();
}
public List<OperationLog> getLogsByTimeRange(LocalDateTime startTime, LocalDateTime endTime) {
return logRepository.findByOperationTimeBetween(startTime, endTime);
}
private HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
private String getClientIpAddress(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
```
### 12.3.4 使用操作日志
```java
@Service
public class FileService {
@Autowired
private OperationLogService logService;
public void deleteFile(String sessionId, String path) {
try {
// 执行删除操作
localFileService.deleteFile(path);
// 记录成功日志
logService.logOperation("delete", sessionId, path, null, true, null);
} catch (Exception e) {
// 记录失败日志
logService.logOperation("delete", sessionId, path, null, false, e.getMessage());
throw e;
}
}
}
```
## 12.4 日志查询接口
### 12.4.1 日志查询Controller
```java
package com.sftp.manager.controller;
import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.model.OperationLog;
import com.sftp.manager.service.OperationLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/api/logs")
@CrossOrigin(origins = "*")
public class LogController {
@Autowired
private OperationLogService logService;
@GetMapping("/session/{sessionId}")
public ApiResponse<List<OperationLog>> getLogsBySession(@PathVariable String sessionId) {
try {
List<OperationLog> logs = logService.getOperationLogs(sessionId);
return ApiResponse.success("查询成功", logs);
} catch (Exception e) {
return ApiResponse.error("查询失败: " + e.getMessage());
}
}
@GetMapping("/failed")
public ApiResponse<List<OperationLog>> getFailedLogs() {
try {
List<OperationLog> logs = logService.getFailedOperations();
return ApiResponse.success("查询成功", logs);
} catch (Exception e) {
return ApiResponse.error("查询失败: " + e.getMessage());
}
}
@GetMapping("/range")
public ApiResponse<List<OperationLog>> getLogsByTimeRange(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
try {
List<OperationLog> logs = logService.getLogsByTimeRange(startTime, endTime);
return ApiResponse.success("查询成功", logs);
} catch (Exception e) {
return ApiResponse.error("查询失败: " + e.getMessage());
}
}
}
```
## 12.5 前端错误处理与展示
### 12.5.1 统一 API 错误处理Axios 拦截器)
```javascript
// src/api/request.js 或 src/utils/request.js
import axios from 'axios';
const request = axios.create({
baseURL: '/api',
timeout: 30000
});
// 响应拦截器:统一解析后端 ApiResponse 与错误
request.interceptors.response.use(
(response) => {
const res = response.data;
if (res.success === false) {
const message = res.message || res.error || '请求失败';
return Promise.reject(new Error(message));
}
return res;
},
(error) => {
const res = error.response?.data;
let message = '网络异常,请稍后重试';
if (res?.error) {
message = res.error;
} else if (res?.message) {
message = res.message;
} else if (error.message) {
message = error.message;
}
return Promise.reject(new Error(message));
}
);
export default request;
```
### 12.5.2 错误码与用户提示映射
```javascript
// src/utils/errorMessages.js
const ERROR_MESSAGES = {
SFTP_ERROR: 'SFTP 操作失败,请检查连接或路径',
FILE_OPERATION_ERROR: '文件操作失败',
CONNECTION_ERROR: '连接失败,请检查主机、端口和认证信息',
INVALID_PARAMETER: '参数错误,请检查输入',
FILE_TOO_LARGE: '文件大小超过限制',
INTERNAL_SERVER_ERROR: '服务器内部错误,请稍后重试'
};
export function getErrorMessage(code, fallback) {
return ERROR_MESSAGES[code] || fallback || '操作失败';
}
```
### 12.5.3 全局错误提示组件Toast/Message
- 所有接口调用在 `catch` 中统一使用项目已有的 Toast/Message 展示 `error.message`
- 可根据 `response.data?.data?.code` 调用 `getErrorMessage(code, error.message)` 得到更友好的文案后再展示。
### 12.5.4 操作日志列表页(可选)
- 调用 `/api/logs/session/{sessionId}``/api/logs/failed``/api/logs/range` 展示操作日志。
- 表格列操作类型、会话、源路径、目标路径、结果、错误信息、操作时间、IP。
- 支持按时间范围、会话、是否失败筛选。
## 12.6 异步操作日志(可选)
为减少对主流程性能影响,可将操作日志改为异步写入。
### 12.6.1 使用 @Async 异步记录
```java
// OperationLogService.java 中
import org.springframework.scheduling.annotation.Async;
@Async
public void logOperationAsync(String operationType, String sessionId,
String sourcePath, String targetPath,
boolean success, String errorMessage) {
logOperation(operationType, sessionId, sourcePath, targetPath, success, errorMessage);
}
```
启用异步需在启动类或配置类上添加:
```java
@SpringBootApplication
@EnableAsync
public class SftpManagerApplication {
public static void main(String[] args) {
SpringApplication.run(SftpManagerApplication.class, args);
}
}
```
### 12.6.2 异步配置(线程池)
```java
package com.sftp.manager.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "logTaskExecutor")
public Executor logTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("log-async-");
executor.initialize();
return executor;
}
}
```
```java
@Async("logTaskExecutor")
public void logOperationAsync(...) { ... }
```
## 实施步骤
1. **创建异常类**SftpException、FileOperationException、ConnectionException
2. **创建全局异常处理器**GlobalExceptionHandler并修正 `handleException``setError` 的调用
3. **创建工具类**ErrorMessageUtil、LogUtil
4. **创建日志配置**logback-spring.xml控制台、文件、错误文件、轮转策略
5. **创建操作日志**OperationLog 实体、OperationLogRepository、OperationLogService含 getLogsByTimeRange
6. **在业务服务中集成**:在 FileService 等中调用 logService.logOperation
7. **创建日志查询接口**LogController/session/{sessionId}、/failed、/range
8. **(可选)前端**Axios 拦截器、错误码映射、Toast 展示、操作日志列表页
9. **(可选)异步日志**@EnableAsync、logTaskExecutor、logOperationAsync
## 注意事项
1. **日志级别**:生产环境建议 root 为 INFO`com.sftp.manager` 可为 DEBUG
2. **敏感信息**:避免在日志中记录密码、密钥、完整 token
3. **性能影响**高并发场景使用异步记录操作日志12.6
4. **日志轮转**:已配置按天+大小轮转maxHistory 可按需调整
5. **错误追踪**:保留 exception 的 message 与 cause便于排查
6. **前端体验**:统一用 ApiResponse 结构,前端根据 code 做友好提示
## 下一步
完成模块12后继续模块13部署与测试