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

27 KiB
Raw Blame History

模块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 自定义异常类

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;
    }
}
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;
    }
}
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 全局异常处理器

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 错误信息工具类

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 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 日志工具类

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 操作日志实体

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

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 操作日志服务

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 使用操作日志

@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

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 拦截器)

// 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 错误码与用户提示映射

// 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 异步记录

// 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);
}

启用异步需在启动类或配置类上添加:

@SpringBootApplication
@EnableAsync
public class SftpManagerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SftpManagerApplication.class, args);
    }
}

12.6.2 异步配置(线程池)

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;
    }
}
@Async("logTaskExecutor")
public void logOperationAsync(...) { ... }

实施步骤

  1. 创建异常类SftpException、FileOperationException、ConnectionException
  2. 创建全局异常处理器GlobalExceptionHandler并修正 handleExceptionsetError 的调用
  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 为 INFOcom.sftp.manager 可为 DEBUG
  2. 敏感信息:避免在日志中记录密码、密钥、完整 token
  3. 性能影响高并发场景使用异步记录操作日志12.6
  4. 日志轮转:已配置按天+大小轮转maxHistory 可按需调整
  5. 错误追踪:保留 exception 的 message 与 cause便于排查
  6. 前端体验:统一用 ApiResponse 结构,前端根据 code 做友好提示

下一步

完成模块12后继续模块13部署与测试