You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot MVC与REST API混合应用错误处理优化方案咨询

问题描述

我正在为同时包含MVC与REST API的Spring Boot应用确定最优错误处理方案:/admin路径提供Thymeleaf模板页面,其余路径为REST API接口。

当前项目配置

  • Thymeleaf错误模板位于/src/main/resources/templates/error/{id}.html,已定义400、401、403、404、500状态模板;
  • 默认Thymeleaf错误模板为/src/main/resources/templates/error.html;
  • HttpSecurity配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
    (...)
    http
        .anonymous();
    http
        .exceptionHandling()
            .authenticationEntryPoint((request, response, authException) -> response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()))
            .accessDeniedHandler(new AccessDeniedHandlerImpl());
    (...)
}
  • 适配MVC与REST的全局异常处理器:
@ControllerAdvice
public class ExceptionHandlers {

    private final BasicErrorController basicErrorController;

    public ExceptionHandlers(BasicErrorController basicErrorController) {
        this.basicErrorController = basicErrorController;
    }

    @ExceptionHandler(Exception.class)
    public Object handleAllExceptions(Exception e, HttpServletRequest request, HttpServletResponse response) {
        return handle(e, request, response, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(EntityNotFoundException.class)
    public Object handleEntityNotFoundException(EntityNotFoundException e, HttpServletRequest request, HttpServletResponse response) {
        return handle(e, request, response, HttpStatus.NOT_FOUND, I18nCodes.ENTITY_NOT_FOUND);
    }

    /**
     * 排除所有继承自AccessDeniedException的异常,不进行自定义处理
     */
    @ExceptionHandler(AccessDeniedException.class)
    public Object handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request, HttpServletResponse response) {
        throw e;
    }

    /**
     * 排除所有继承自AppBaseException的异常,不进行自定义处理
     */
    @ExceptionHandler(AppBaseException.class)
    public Object handleAppBaseException(AppBaseException e, HttpServletRequest request, HttpServletResponse response) {
        throw e;
    }

    private Object handle(Exception e, HttpServletRequest request, HttpServletResponse response, HttpStatus status) {
        return handle(e, request, response, HttpStatus.INTERNAL_SERVER_ERROR, I18nCodes.getCodeByStatus(status));
    }

    private Object handle(Exception e, HttpServletRequest request, HttpServletResponse response, HttpStatus status, String message) {
        String header = request.getHeader("Accept");
        if (header != null && header.contains("text/html")) {
            setErrorCode(request, response, status);
            return basicErrorController.errorHtml(request, response);
        }
        return createJsonResponse(message, status, request.getRequestURI());
    }

    private ResponseEntity<ErrorResponseDTO> createJsonResponse(String message, HttpStatus status, String path) {
        ErrorResponseDTO errorResponseDTO = new ErrorResponseDTO()
                .setTimestamp(new Timestamp(System.currentTimeMillis()))
                .setStatus(status.value())
                .setMessage(message)
                .setPath(path)
                .setError(status.name().toLowerCase());
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_JSON);
        return ResponseEntity.status(status).headers(httpHeaders).body(errorResponseDTO);
    }


    private void setErrorCode(HttpServletRequest request, HttpServletResponse response, HttpStatus httpStatus) {
        request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, httpStatus.value());
        response.setStatus(httpStatus.value());
    }

}
  • 带@ResponseStatus的自定义异常示例:
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public class AccountInfoException extends AppBaseException {
    protected AccountInfoException(String message) {
        super(message);
    }

    protected AccountInfoException(String message, Throwable cause) {
        super(message, cause);
    }

    public static AccountInfoException emailAlreadyExists() {
        return new AccountInfoException(I18nCodes.EMAIL_EXIST);
    }

    public static AccountInfoException accountNotFound() {
        return new AccountInfoException(I18nCodes.ACCOUNT_NOT_FOUND);
    }
}

遇到的问题

未添加全局异常处理器时,错误处理符合预期:未认证返回401,权限不足返回403;带@ResponseStatus的自定义异常能正确映射状态码与消息,且跳转至对应Thymeleaf错误页面。

但添加处理Exception.class的全局处理器后,出现以下问题:

  1. 无法获取自定义异常的@ResponseStatus状态码;
  2. Spring Security认证入口与权限拒绝处理器失效,未认证与权限不足均被视为AccessDeniedException;
  3. 丢失部分默认异常映射(如LockedException应返回401)。

目前临时方案为重抛特定异常,现寻求更合理的永久解决方案。

最优解决方案

1. 调整全局异常处理器的范围,避免覆盖核心逻辑

Spring异常处理遵循具体异常优先于通用异常的规则,且Spring Security的异常处理逻辑在DispatcherServlet之前执行。因此需要:

  • 移除@ExceptionHandler(Exception.class)的通用处理,只针对业务特定异常编写处理逻辑;
  • 保留对AccessDeniedException和AppBaseException的重抛逻辑,让Spring Security和默认机制处理这些异常。

修改后的全局异常处理器核心代码:

@ControllerAdvice
public class ExceptionHandlers {

    private final BasicErrorController basicErrorController;

    public ExceptionHandlers(BasicErrorController basicErrorController) {
        this.basicErrorController = basicErrorController;
    }

    // 仅处理业务特定异常,不再覆盖通用Exception
    @ExceptionHandler(EntityNotFoundException.class)
    public Object handleEntityNotFoundException(EntityNotFoundException e, HttpServletRequest request, HttpServletResponse response) {
        return handle(e, request, response, HttpStatus.NOT_FOUND, I18nCodes.ENTITY_NOT_FOUND);
    }

    @ExceptionHandler(AccessDeniedException.class)
    public void handleAccessDeniedException(AccessDeniedException e) throws AccessDeniedException {
        throw e; // 交给Spring Security的accessDeniedHandler处理
    }

    @ExceptionHandler(AppBaseException.class)
    public void handleAppBaseException(AppBaseException e) throws AppBaseException {
        throw e; // 交给默认的@ResponseStatus处理机制
    }

    // 新增其他需要自定义处理的业务异常...

    private Object handle(Exception e, HttpServletRequest request, HttpServletResponse response, HttpStatus status, String message) {
        String header = request.getHeader("Accept");
        if (header != null && header.contains("text/html")) {
            setErrorCode(request, response, status);
            return basicErrorController.errorHtml(request, response);
        }
        return createJsonResponse(message, status, request.getRequestURI());
    }

    // 保留createJsonResponse和setErrorCode方法...
}

2. 复用Spring Boot默认错误处理机制

Spring Boot的BasicErrorController已默认支持根据Accept头返回HTML或JSON格式的错误响应,无需自行处理通用异常:

  • 确保Thymeleaf错误模板路径正确,Spring Boot会自动根据状态码匹配error/{id}.html模板;
  • 自定义异常的@ResponseStatus注解会被默认机制识别,自动映射对应状态码并返回合适的响应格式。

3. 确保Spring Security异常处理逻辑生效

保持HttpSecurity配置中的异常处理逻辑不变,确保:

  • 认证入口点(authenticationEntryPoint)处理未认证请求,返回401;
  • 权限拒绝处理器(accessDeniedHandler)处理权限不足请求,返回403;
  • Spring Security特定异常(如LockedException)会由认证入口点自动处理,返回401。

4. 统一业务异常的响应格式

对于需要自定义响应的业务异常,在@ExceptionHandler中根据Accept头分支处理:

  • HTML响应:设置请求状态属性后调用BasicErrorController.errorHtml,复用已有的Thymeleaf模板;
  • JSON响应:返回统一的ErrorResponseDTO实体,保证格式一致性。

5. 验证关键场景

测试以下核心场景,确保逻辑正常:

  • 访问/admin路径未认证:返回401的Thymeleaf模板;
  • 访问REST API未认证:返回401的JSON响应;
  • 触发AccountInfoException:返回400的对应模板或JSON;
  • 触发LockedException:返回401的对应模板或JSON;
  • 权限不足访问资源:返回403的对应模板或JSON。

内容的提问来源于stack exchange,提问作者Zdrzalik Przemyslaw

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.12 04:10:20