Skip to content
第 85 / 250 章后端⏱ 10 分钟阅读

第 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() 返回正确的 JSON
  • Assert.notNull(null, ...) 抛出 BusinessException
  • GlobalExceptionHandler 能正确处理各种异常

练习 2:进阶题

为 TaskFlow 增加 R<T> 响应包装的 code 字段(业务码),与 HTTP 状态码解耦:

  • HTTP 401 → 业务码 401(未登录)
  • HTTP 200 + 业务码 500(业务异常)
  • HTTP 200 + 业务码 200(成功)

练习 3:思考题

Common 模块应该包含什么、不应该包含什么?比如:JWT 工具类放在 Common 还是 Security 模块?


下一章第 86 章:用户与认证模块

本站基于 VitePress 构建 · 由 Codebook 团队维护