# 模块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 - 标准间距:16px(1rem) - 组件内边距: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 handleSftpException(SftpException e) { ApiResponse response = new ApiResponse<>(); response.setSuccess(false); response.setMessage("SFTP操作失败"); response.setError(e.getMessage()); Map 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 handleFileOperationException(FileOperationException e) { ApiResponse response = new ApiResponse<>(); response.setSuccess(false); response.setMessage("文件操作失败"); response.setError(e.getMessage()); Map 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 handleConnectionException(ConnectionException e) { ApiResponse response = new ApiResponse<>(); response.setSuccess(false); response.setMessage("连接失败"); response.setError(e.getMessage()); Map 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 handleIllegalArgumentException(IllegalArgumentException e) { ApiResponse response = new ApiResponse<>(); response.setSuccess(false); response.setMessage("参数错误"); response.setError(e.getMessage()); Map 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 handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { ApiResponse response = new ApiResponse<>(); response.setSuccess(false); response.setMessage("文件大小超过限制"); response.setError("上传的文件大小超过最大限制"); Map 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 handleException(Exception e) { ApiResponse response = new ApiResponse<>(); response.setSuccess(false); response.setMessage("系统错误"); response.setError("服务器内部错误"); Map 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 %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n UTF-8 ${LOG_PATH}/${LOG_FILE}.log %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n UTF-8 ${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.%i.log 30 100MB ${LOG_PATH}/${LOG_FILE}-error.log ERROR ACCEPT DENY %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n UTF-8 ${LOG_PATH}/${LOG_FILE}-error.%d{yyyy-MM-dd}.%i.log 30 100MB ``` ### 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 { List findBySessionIdOrderByOperationTimeDesc(String sessionId); List findByOperationTypeOrderByOperationTimeDesc(String operationType); @Query("SELECT o FROM OperationLog o WHERE o.operationTime BETWEEN :startTime AND :endTime ORDER BY o.operationTime DESC") List 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 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 getOperationLogs(String sessionId) { return logRepository.findBySessionIdOrderByOperationTimeDesc(sessionId); } public List getFailedOperations() { return logRepository.findFailedOperations(); } public List 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> getLogsBySession(@PathVariable String sessionId) { try { List logs = logService.getOperationLogs(sessionId); return ApiResponse.success("查询成功", logs); } catch (Exception e) { return ApiResponse.error("查询失败: " + e.getMessage()); } } @GetMapping("/failed") public ApiResponse> getFailedLogs() { try { List logs = logService.getFailedOperations(); return ApiResponse.success("查询成功", logs); } catch (Exception e) { return ApiResponse.error("查询失败: " + e.getMessage()); } } @GetMapping("/range") public ApiResponse> getLogsByTimeRange( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) { try { List 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:部署与测试