第 85 章:通用模块(异常/响应/工具)
学习目标
- 实现统一的业务异常处理
- 实现统一的响应格式封装
- 抽取高频使用的工具类
一、模块定位
Common 模块:所有业务模块共享的基础设施,不依赖任何业务模块。
二、错误码体系
java
package com.taskflow.common.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ErrorCode {
// ============ 通用 ============
SUCCESS(200, "操作成功"),
PARAM_INVALID(400, "参数无效"),
UNAUTHORIZED(401, "未登录或登录已过期"),
FORBIDDEN(403, "权限不足"),
NOT_FOUND(404, "资源不存在"),
METHOD_NOT_ALLOWED(405, "请求方法不允许"),
RATE_LIMIT(429, "请求过于频繁,请稍后再试"),
// ============ 系统错误(5 位数) ============
SYSTEM_ERROR(500, "系统繁忙,请稍后再试"),
DB_ERROR(5001, "数据库异常"),
REDIS_ERROR(5002, "Redis 异常"),
MQ_ERROR(5003, "消息队列异常"),
// ============ 业务错误(10 位数) ============
USER_NOT_FOUND(10001, "用户不存在"),
USER_PASSWORD_ERROR(10002, "用户名或密码错误"),
USER_DISABLED(10003, "用户已被禁用"),
USER_LOCKED(10004, "用户已被锁定"),
USER_EXISTS(10005, "用户名已存在"),
ROLE_NOT_FOUND(11001, "角色不存在"),
ROLE_IN_USE(11002, "角色已被使用,无法删除"),
MENU_NOT_FOUND(12001, "菜单不存在"),
DEPT_NOT_FOUND(13001, "部门不存在"),
DEPT_HAS_USER(13002, "部门下存在用户,无法删除"),
// ============ 认证错误(20 位数) ============
TOKEN_INVALID(20001, "Token 无效"),
TOKEN_EXPIRED(20002, "Token 已过期"),
REFRESH_TOKEN_INVALID(20003, "Refresh Token 无效"),
// ============ 幂等错误(30 位数) ============
IDEMPOTENT_INVALID(30001, "请勿重复提交"),
// ============ 第三方错误(40 位数) ============
OSS_ERROR(40001, "对象存储异常"),
SMS_SEND_FAIL(40002, "短信发送失败");
private final int code;
private final String message;
}三、业务异常
java
package com.taskflow.common.exception;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final int code;
private final boolean disableStackTrace;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.disableStackTrace = true; // ① 业务异常不需要堆栈
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
this.disableStackTrace = true;
}
public BusinessException(int code, String message) {
super(message);
this.code = code;
this.disableStackTrace = true;
}
@Override
public synchronized Throwable fillInStackTrace() {
if (disableStackTrace) {
return this;
}
return super.fillInStackTrace();
}
}四、全局异常处理
java
package com.taskflow.common.exception;
import com.taskflow.common.api.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException ex, HttpServletRequest req) {
log.warn("业务异常 [{}] {}: {}", req.getRequestURI(), ex.getCode(), ex.getMessage());
return Result.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValid(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return Result.fail(400, msg);
}
@ExceptionHandler(BindException.class)
public Result<Void> handleBind(BindException ex) {
String msg = ex.getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return Result.fail(400, msg);
}
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraint(ConstraintViolationException ex) {
return Result.fail(400, ex.getMessage());
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Result<Void>> handleMethod(HttpRequestMethodNotSupportedException ex) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(Result.fail(405, "不支持 " + ex.getMethod() + " 方法"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Result<Void>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Result.fail(403, "权限不足"));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Result<Void>> handleAuth(AuthenticationException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(401, "认证失败"));
}
@ExceptionHandler(Exception.class)
public Result<Void> handleUnknown(Exception ex, HttpServletRequest req) {
log.error("系统异常 [{}]", req.getRequestURI(), ex);
return Result.fail(500, "系统繁忙,请稍后再试");
}
}五、统一响应
java
package com.taskflow.common.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import org.slf4j.MDC;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> {
private Integer code;
private String message;
private T data;
private String traceId;
private Long timestamp = System.currentTimeMillis();
public static <T> Result<T> ok() {
return ok(null);
}
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.code = 200;
r.message = "操作成功";
r.data = data;
r.traceId = MDC.get("traceId");
return r;
}
public static <T> Result<T> fail(int code, String message) {
Result<T> r = new Result<>();
r.code = code;
r.message = message;
r.traceId = MDC.get("traceId");
return r;
}
public static <T> Result<T> fail(ErrorCode errorCode) {
return fail(errorCode.getCode(), errorCode.getMessage());
}
}分页响应
java
@Data
public class PageResult<T> {
private Long total;
private Long pages;
private Long current;
private Long size;
private List<T> records;
public static <T> PageResult<T> of(IPage<T> page) {
PageResult<T> r = new PageResult<>();
r.total = page.getTotal();
r.pages = page.getPages();
r.current = page.getCurrent();
r.size = page.getSize();
r.records = page.getRecords();
return r;
}
public static <S, T> PageResult<T> of(IPage<S> page, Function<S, T> converter) {
PageResult<T> r = new PageResult<>();
r.total = page.getTotal();
r.pages = page.getPages();
r.current = page.getCurrent();
r.size = page.getSize();
r.records = page.getRecords().stream().map(converter).toList();
return r;
}
public static <T> PageResult<T> empty() {
PageResult<T> r = new PageResult<>();
r.total = 0L;
r.pages = 0L;
r.current = 1L;
r.size = 10L;
r.records = List.of();
return r;
}
}ResponseBodyAdvice 自动包装
java
@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// ① Result 类型不重复包装
return !returnType.getParameterType().equals(Result.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest req, ServerHttpResponse resp) {
if (body instanceof String) {
// ② String 类型要特殊处理
return JSON.toJSONString(Result.ok(body));
}
return Result.ok(body);
}
}六、断言工具
java
package com.taskflow.common.utils;
import com.taskflow.common.exception.BusinessException;
import com.taskflow.common.exception.ErrorCode;
import java.util.Collection;
import java.util.Map;
public class Assert {
public static void notNull(Object obj, ErrorCode errorCode) {
if (obj == null) {
throw new BusinessException(errorCode);
}
}
public static void notNull(Object obj, String message) {
if (obj == null) {
throw new BusinessException(ErrorCode.PARAM_INVALID, message);
}
}
public static void hasText(String text, String message) {
if (text == null || text.isBlank()) {
throw new BusinessException(ErrorCode.PARAM_INVALID, message);
}
}
public static void isTrue(boolean expression, String message) {
if (!expression) {
throw new BusinessException(ErrorCode.PARAM_INVALID, message);
}
}
public static <T> void notEmpty(Collection<T> coll, String message) {
if (coll == null || coll.isEmpty()) {
throw new BusinessException(ErrorCode.PARAM_INVALID, message);
}
}
public static <K, V> void notEmpty(Map<K, V> map, String message) {
if (map == null || map.isEmpty()) {
throw new BusinessException(ErrorCode.PARAM_INVALID, message);
}
}
public static void equals(Object expected, Object actual, String message) {
if (expected == null || !expected.equals(actual)) {
throw new BusinessException(ErrorCode.PARAM_INVALID, message);
}
}
}七、安全上下文工具
java
package com.taskflow.common.security;
import com.taskflow.common.exception.BusinessException;
import com.taskflow.common.exception.ErrorCode;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public final class SecurityUtils {
private SecurityUtils() { }
public static CustomUserDetails getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
Object principal = auth.getPrincipal();
if (principal instanceof CustomUserDetails user) {
return user;
}
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
public static Long getCurrentUserId() {
return getCurrentUser().getUserId();
}
public static String getCurrentUsername() {
return getCurrentUser().getUsername();
}
}八、追踪 ID
java
package com.taskflow.common.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
String traceId = req.getHeader(TRACE_HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
resp.setHeader(TRACE_HEADER, traceId);
try {
chain.doFilter(req, resp);
} finally {
MDC.remove("traceId");
}
}
}九、基类
java
package com.taskflow.common.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
public abstract class BaseEntity implements Serializable {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT)
private Long createBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
@TableLogic
@TableField(select = false)
private Integer deleted;
}十、JWT 工具(Common 模块)
java
package com.taskflow.common.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-expire:7200}")
private Long accessExpire;
@Value("${jwt.refresh-expire:2592000}")
private Long refreshExpire;
@Value("${jwt.issuer:taskflow}")
private String issuer;
private SecretKey key;
public void init() {
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalArgumentException("JWT 密钥至少 32 字节");
}
this.key = Keys.hmacShaKeyFor(bytes);
}
public String generateAccess(Long userId, String username, List<String> roles) {
Date now = new Date();
return Jwts.builder()
.issuer(issuer)
.subject(String.valueOf(userId))
.claim("username", username)
.claim("roles", roles)
.claim("type", "access")
.issuedAt(now)
.expiration(new Date(now.getTime() + accessExpire * 1000))
.signWith(key, Jwts.SIG.HS256)
.compact();
}
public String generateRefresh(Long userId) {
Date now = new Date();
return Jwts.builder()
.issuer(issuer)
.subject(String.valueOf(userId))
.claim("type", "refresh")
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpire * 1000))
.id(UUID.randomUUID().toString())
.signWith(key, Jwts.SIG.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
.requireIssuer(issuer)
.build()
.parseSignedClaims(token)
.getPayload();
}
}十一、依赖与导出
xml
<!-- taskflow-common/pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>十二、本章小结
| 要点 | 关键 |
|---|---|
| ErrorCode | 枚举统一定义错误码 |
| BusinessException | 自定义异常,禁用堆栈 |
| GlobalExceptionHandler | @RestControllerAdvice 统一处理 |
| Result | 统一响应结构 |
| PageResult | 分页响应 |
| Assert | 断言工具 |
| SecurityUtils | 安全上下文工具 |
| BaseEntity | 公共字段 |
动手练习
练习 1:基础题
按本章代码创建 taskflow-common 模块的所有类,编写测试验证:
Result.ok()和Result.fail()返回正确的 JSONAssert.notNull(null, ...)抛出 BusinessExceptionGlobalExceptionHandler能正确处理各种异常
练习 2:进阶题
为 TaskFlow 增加 R<T> 响应包装的 code 字段(业务码),与 HTTP 状态码解耦:
- HTTP 401 → 业务码 401(未登录)
- HTTP 200 + 业务码 500(业务异常)
- HTTP 200 + 业务码 200(成功)
练习 3:思考题
Common 模块应该包含什么、不应该包含什么?比如:JWT 工具类放在 Common 还是 Security 模块?
下一章:第 86 章:用户与认证模块 →