☰
接口报错一堆堆栈给前端?Spring Boot 全局异常处理 + 统一响应体
2026/10/14 18:00:35 网站建设 项目流程

接口报错一堆堆栈给前端?Spring Boot 全局异常处理 + 统一响应体

导读:前端同事拿着 500 的堆栈截图来找你:“这个报错啥意思?”——接口直接吐堆栈给前端,既暴露了内部细节,又没人看得懂。qkl-boot 的做法是:统一响应体 R + 全局异常处理器,业务异常返回友好文案,系统异常记日志返回兜底消息。这套东西一小时能搭完,收益是长期的。

先看两个问题:响应体不统一——成功返回{data: ...},报错返回{msg: ...},前端每次都要猜结构;异常裸奔——SQL 异常、空指针直接甩给前端,堆栈里全是服务器路径和表名。全局异常处理要解决的就是这两件事。

统一响应体:所有接口一个形状

先定义一个泛型响应体,成功失败一个结构:

@DatapublicclassR{privateintcode;privateStringmsg;privateTdata;privatelongtimestamp=System.currentTimeMillis();publicstaticRok(Tdata){Rr=newR<>();r.setCode(200);r.setMsg("success");r.setData(data);returnr;}publicstaticRfail(intcode,Stringmsg){Rr=newR<>();r.setCode(code);r.setMsg(msg);returnr;}publicstaticRfail(BizExceptione){returnfail(e.getCode(),e.getMessage());}}

业务代码只关心成功分支:

@RestController@RequestMapping("/job")publicclassJobController{@GetMapping("/{id}")publicRdetail(@PathVariableLongid){returnR.ok(jobService.detail(id));// 业务逻辑里只管返回,异常交给全局处理}}

code用 HTTP 语义还是业务码?qkl-boot 用的是业务码——200 成功,非 200 失败,具体码段按模块分(401 未登录、403 无权限、5001 业务冲突等)。HTTP 状态码统一 200,业务码放 body,前端只看 body 的 code 判断。这样网关层不用改,业务语义也不丢。

业务异常:带码带消息,全局处理

业务校验失败(余额不足、名额已满、重复报名)都属于可预期异常,定义一个 BizException:

@Data@EqualsAndHashCode(callSuper=true)publicclassBizExceptionextendsRuntimeException{privateintcode;publicBizException(Stringmsg){super(msg);this.code=500;}publicBizException(intcode,Stringmsg){super(msg);this.code=code;}}

业务代码里直接抛:

// 报名名额不足if(quota<=0){thrownewBizException("该岗位名额已抢完");}// 权限不足thrownewBizException(403,"无权操作该用工单");

全局异常处理器:一个类管住所有异常

核心是@RestControllerAdvice,集中处理异常,业务异常返回它的消息,未知异常记录日志后返回兜底文案:

@RestControllerAdvicepublicclassGlobalExceptionHandler{privatestaticfinalLoggerlog=LoggerFactory.getLogger(GlobalExceptionHandler.class);/** 业务异常:消息可直接展示给用户 */@ExceptionHandler(BizException.class)publicRhandleBiz(BizExceptione){log.warn("业务异常: code={}, msg={}",e.getCode(),e.getMessage());returnR.fail(e.getCode(),e.getMessage());}/** 参数校验异常:@Valid 校验失败会抛这个 */@ExceptionHandler(MethodArgumentNotValidException.class)publicRhandleValid(MethodArgumentNotValidExceptione){Stringmsg=e.getBindingResult().getFieldErrors().stream().map(f->f.getField()+": "+f.getDefaultMessage()).collect(Collectors.joining("; "));returnR.fail(400,msg);}/** 未登录 / token 失效 */@ExceptionHandler(AuthenticationException.class)publicRhandleAuth(AuthenticationExceptione){returnR.fail(401,"登录已失效,请重新登录");}/** 兜底:未知异常记日志,返回通用文案,绝不把堆栈甩给前端 */@ExceptionHandler(Exception.class)publicRhandleOther(Exceptione){log.error("系统异常",e);returnR.fail(500,"系统开小差了,请稍后再试");}}

关键点在最后那个兜底 handler:未知异常一定要打完整日志(含堆栈),但返回给前端的是通用文案。前端拿到"系统开小差了"不会懵,后端日志里有完整堆栈可以查。日志是给排查用的,响应是给用户看的,两者不能混。

日志里要有 traceId:报错能对上号

全局异常处理器把堆栈收进日志了,但问题来了:前端报"系统开小差了",后端日志里一天几十条"系统异常",对不上是哪次请求。前端把 timestamp 报过来,后端按时间翻日志,慢还容易翻错。解法是给每次请求一个唯一 traceId,贯穿请求头和日志:

// 过滤器:为每个请求生成 traceId,写进 MDC(日志上下文)@ComponentpublicclassTraceIdFilterextendsOncePerRequestFilter{@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{StringtraceId=request.getHeader("X-Trace-Id");if(traceId==null||traceId.isEmpty()){traceId=UUID.randomUUID().toString().replace("-","").substring(0,16);}MDC.put("traceId",traceId);response.setHeader("X-Trace-Id",traceId);try{chain.doFilter(request,response);}finally{MDC.remove("traceId");}}}

日志配置里把 traceId 打出来:

%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n
// 全局异常处理器兜底时,把 traceId 一起放进响应@ExceptionHandler(Exception.class)publicRhandleOther(Exceptione,HttpServletResponseresponse){log.error("系统异常, traceId={}",MDC.get("traceId"),e);Rr=R.fail(500,"系统开小差了,请稍后再试");response.setHeader("X-Trace-Id",MDC.get("traceId"));returnr;}

前端报障时把响应里的 X-Trace-Id 一发,后端grep traceId一条日志定位完整链路。traceId 是全局异常处理真正能落地的最后一环——没有它,异常日志和用户报障永远对不上号。注意finally里要MDC.remove,不然线程池复用会串 traceId。

踩坑实录:全局处理器写好了,异常还是吐堆栈

现象:全局异常处理器上线后,测一个越权操作,返回的 JSON 里居然还带着org.springframework.dao.DataIntegrityViolationException的完整堆栈。

排查过程:先看返回结构,确认走到了全局处理器;再看代码,@RestControllerAdvice注解、包路径都没问题。然后发现关键线索——报错场景是权限拦截器(Filter)里抛的异常,不是 Controller 层。

定位思路:@RestControllerAdvice只对Controller 层(DispatcherServlet 内)的异常生效。Filter 在 DispatcherServlet 之前执行,Filter 里抛的异常根本到不了全局异常处理器,直接被容器兜底,返回的是默认错误页/堆栈。

最终解决:Filter 里不能抛异常给容器,要自己捕获并写出统一响应:

@ComponentpublicclassJwtAuthFilterextendsOncePerRequestFilter{@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{try{// 认证逻辑:token 校验失败抛 AuthenticationExceptionchain.doFilter(request,response);}catch(AuthenticationExceptione){// Filter 层自己兜:写出统一响应体,别让异常冒到容器response.setStatus(200);response.setContentType("application/json;charset=UTF-8");response.getWriter().write("{\"code\":401,\"msg\":\"登录已失效,请重新登录\",\"data\":null}");}catch(Exceptione){log.error("Filter 异常",e);response.setStatus(200);response.setContentType("application/json;charset=UTF-8");response.getWriter().write("{\"code\":500,\"msg\":\"系统开小差了,请稍后再试\",\"data\":null}");}}}

同理,拦截器(Interceptor)的 preHandle 里抛异常也到不了 @RestControllerAdvice——Spring MVC 的异常处理器只管 handler 执行阶段。拦截器异常要么自己在 preHandle 里处理,要么通过 HandlerExceptionResolver 兜底。这个边界理不清,全局异常处理就是"看起来有、实际漏了一截"。

可以直接抄的结论

  1. 统一响应体R:code/msg/data/timestamp,成功失败一个形状;
  2. 业务异常定义BizException(code + msg),可预期错误主动抛;
  3. @RestControllerAdvice集中处理:业务异常透传消息、校验异常拼字段错误、未知异常记日志返回兜底文案;
  4. Filter 和 Interceptor 的异常到不了 @RestControllerAdvice,要自己 catch 并写统一响应;
  5. 兜底 handler 必须log.error(e)打完整堆栈,响应里只给通用文案;
  6. 前端统一看 body 的 code 判断业务状态,HTTP 状态码保持 200。

全局异常处理的价值不在代码多,而在"错误到前端是一个形状、到日志是一份完整线索"。搭好这套,前后端联调吵架的次数能少一半。

项目源码:https://gitee.com/gzqkl/qkl-boot

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询