资讯动态

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

发布时间:2026/10/14 18:00:49 来源:尧图企业网站定制
接口报错一堆堆栈给前端Spring Boot 全局异常处理 统一响应体导读前端同事拿着 500 的堆栈截图来找你“这个报错啥意思”——接口直接吐堆栈给前端既暴露了内部细节又没人看得懂。qkl-boot 的做法是统一响应体 R 全局异常处理器业务异常返回友好文案系统异常记日志返回兜底消息。这套东西一小时能搭完收益是长期的。先看两个问题响应体不统一——成功返回{data: ...}报错返回{msg: ...}前端每次都要猜结构异常裸奔——SQL 异常、空指针直接甩给前端堆栈里全是服务器路径和表名。全局异常处理要解决的就是这两件事。统一响应体所有接口一个形状先定义一个泛型响应体成功失败一个结构DatapublicclassR{privateintcode;privateStringmsg;privateTdata;privatelongtimestampSystem.currentTimeMillis();publicstaticRok(Tdata){RrnewR();r.setCode(200);r.setMsg(success);r.setData(data);returnr;}publicstaticRfail(intcode,Stringmsg){RrnewR();r.setCode(code);r.setMsg(msg);returnr;}publicstaticRfail(BizExceptione){returnfail(e.getCode(),e.getMessage());}}业务代码只关心成功分支RestControllerRequestMapping(/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 判断。这样网关层不用改业务语义也不丢。业务异常带码带消息全局处理业务校验失败余额不足、名额已满、重复报名都属于可预期异常定义一个 BizExceptionDataEqualsAndHashCode(callSupertrue)publicclassBizExceptionextendsRuntimeException{privateintcode;publicBizException(Stringmsg){super(msg);this.code500;}publicBizException(intcode,Stringmsg){super(msg);this.codecode;}}业务代码里直接抛// 报名名额不足if(quota0){thrownewBizException(该岗位名额已抢完);}// 权限不足thrownewBizException(403,无权操作该用工单);全局异常处理器一个类管住所有异常核心是RestControllerAdvice集中处理异常业务异常返回它的消息未知异常记录日志后返回兜底文案RestControllerAdvicepublicclassGlobalExceptionHandler{privatestaticfinalLoggerlogLoggerFactory.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){Stringmsge.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{StringtraceIdrequest.getHeader(X-Trace-Id);if(traceIdnull||traceId.isEmpty()){traceIdUUID.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);RrR.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;charsetUTF-8);response.getWriter().write({\code\:401,\msg\:\登录已失效请重新登录\,\data\:null});}catch(Exceptione){log.error(Filter 异常,e);response.setStatus(200);response.setContentType(application/json;charsetUTF-8);response.getWriter().write({\code\:500,\msg\:\系统开小差了请稍后再试\,\data\:null});}}}同理拦截器Interceptor的 preHandle 里抛异常也到不了 RestControllerAdvice——Spring MVC 的异常处理器只管 handler 执行阶段。拦截器异常要么自己在 preHandle 里处理要么通过 HandlerExceptionResolver 兜底。这个边界理不清全局异常处理就是看起来有、实际漏了一截。可以直接抄的结论统一响应体Rcode/msg/data/timestamp成功失败一个形状业务异常定义BizExceptioncode msg可预期错误主动抛RestControllerAdvice集中处理业务异常透传消息、校验异常拼字段错误、未知异常记日志返回兜底文案Filter 和 Interceptor 的异常到不了 RestControllerAdvice要自己 catch 并写统一响应兜底 handler 必须log.error(e)打完整堆栈响应里只给通用文案前端统一看 body 的 code 判断业务状态HTTP 状态码保持 200。全局异常处理的价值不在代码多而在错误到前端是一个形状、到日志是一份完整线索。搭好这套前后端联调吵架的次数能少一半。项目源码https://gitee.com/gzqkl/qkl-boot

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑