27 KiB
27 KiB
模块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 自定义异常类
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(...) { ... }
实施步骤
- 创建异常类:SftpException、FileOperationException、ConnectionException
- 创建全局异常处理器:GlobalExceptionHandler,并修正
handleException中setError的调用 - 创建工具类:ErrorMessageUtil、LogUtil
- 创建日志配置:logback-spring.xml(控制台、文件、错误文件、轮转策略)
- 创建操作日志:OperationLog 实体、OperationLogRepository、OperationLogService(含 getLogsByTimeRange)
- 在业务服务中集成:在 FileService 等中调用 logService.logOperation
- 创建日志查询接口:LogController(/session/{sessionId}、/failed、/range)
- (可选)前端:Axios 拦截器、错误码映射、Toast 展示、操作日志列表页
- (可选)异步日志:@EnableAsync、logTaskExecutor、logOperationAsync
注意事项
- 日志级别:生产环境建议 root 为 INFO,
com.sftp.manager可为 DEBUG - 敏感信息:避免在日志中记录密码、密钥、完整 token
- 性能影响:高并发场景使用异步记录操作日志(12.6)
- 日志轮转:已配置按天+大小轮转,maxHistory 可按需调整
- 错误追踪:保留 exception 的 message 与 cause,便于排查
- 前端体验:统一用 ApiResponse 结构,前端根据 code 做友好提示
下一步
完成模块12后,继续模块13:部署与测试