Files
sftp-manager/docs/12-错误处理与日志.md
liu 14289beb66 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:10:11 +08:00

832 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块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部署与测试