06-统一响应与全局异常-ApiResponse三段式
黒漂技术佬 · AI 伙伴(AI-Partner)「数据接口部署与二次开发」系列 06
上一篇的 19 个接口返回格式全都长一个样:{"code":0,"message":"success","data":...}。这套"三段式"是怎么实现的?报错时 HTTP 状态码和业务码怎么配合?为什么业务代码里几乎看不到 try-catch?这篇把 AI 伙伴的 common 包三个类拆干净——总共不到一百行代码,却是整个接口层体验的压舱石。
一、ApiResponse:三段式统一响应
// 项目源码(common/ApiResponse.java)@Data@NoArgsConstructor@AllArgsConstructorpublicclassApiResponse<T>{privateintcode;// 业务码:0 成功,非 0 失败privateStringmessage;// 提示信息privateTdata;// 业务数据publicstatic<T>ApiResponse<T>ok(Tdata){returnnewApiResponse<>(0,"success",data);}publicstatic<T>ApiResponse<T>ok(){returnnewApiResponse<>(0,"success",null);}publicstatic<T>ApiResponse<T>fail(intcode,Stringmessage){returnnewApiResponse<>(code,message,null);}publicstatic<T>ApiResponse<T>fail(Stringmessage){returnnewApiResponse<>(500,message,null);}}三个字段各司其职:code给程序判断(0 成功、非 0 失败),message给人看(错误提示或固定 “success”),data装业务数据(成功时装实体,失败时为 null)。四个静态工厂覆盖了所有出口语义:
| 静态工厂 | code | message | data | 使用场景 |
|---|---|---|---|---|
ok(data) | 0 | success | 业务数据 | 查询/创建成功 |
ok() | 0 | success | null | 无返回值的操作 |
fail(code, msg) | 自定义 | 错误信息 | null | 明确知道错误码的失败 |
fail(msg) | 500 | 错误信息 | null | 不细分码的失败(单参默认 500) |
二、code=0 vs HTTP 200:两派的取舍
业界对"成功怎么表达"有两派。一派信 HTTP 语义:200 就是成功,404/500 各表其义,无需业务码。另一派(国内后端主流,微信/支付宝开放平台都是)是本项目的做法:HTTP 状态码只表达传输层结果,业务结果放进 body 的 code 字段。
本项目的组合更微妙:两层都在用。业务成功时 HTTP 200 + code=0;业务异常时 HTTP 状态码也会变(见下节,BusinessException 返回 HTTP 400)。也就是说它没有走"永远 200、错误全靠 code"的极端派,而是让 HTTP 状态承担粗分类(4xx 客户端的锅、5xx 服务器的锅),业务码承担细分类。
| 维度 | 纯 HTTP 派 | 纯业务码派(永远 200) | 本项目混合派 |
|---|---|---|---|
| 网关/监控识别错误 | 天然支持(按状态码告警) | 失效,需解析 body | 部分支持 |
| 前端统一处理 | 要枚举各种状态码 | 只判 code | 状态码粗判 + code 细判 |
| 中间件友好度(重试/熔断) | 好 | 差 | 较好 |
取舍本身没有标准答案,重要的是全项目一致——这恰恰是三段式包装最大的价值:前端只需要写一次拦截器,判断 code 是否为 0,非 0 弹 message,齐活。
三、BusinessException:把业务错误变成数据
// 项目源码(common/BusinessException.java)@GetterpublicclassBusinessExceptionextendsRuntimeException{privatefinalintcode;publicBusinessException(Stringmessage){super(message);this.code=500;}publicBusinessException(intcode,Stringmessage){super(message);this.code=code;}}注意它继承的是RuntimeException(非受检异常)——业务代码抛它不用层层声明 throws。两个构造函数语义分明:单参抛"我不关心错误码"的通用错误(code 默认 500),双参抛"这个错误值得一个专属码"的精确错误。项目里的实际用法,比如对话服务里:
// 项目源码(ChatService 内,节选)thrownewBusinessException(400,"大模型 API Key 未配置…");thrownewBusinessException("AI 服务暂时不可用,请稍后再试");抛出之后业务代码就撒手不管了——接下来的活全是全局异常处理器的。
四、GlobalExceptionHandler:三类拦截,全项目兜底
// 项目源码(common/GlobalExceptionHandler.java)@Slf4j@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(BusinessException.class)publicResponseEntity<ApiResponse<Void>>handleBusiness(BusinessExceptione){returnResponseEntity.badRequest().body(ApiResponse.fail(e.getCode(),e.getMessage()));}@ExceptionHandler(MethodArgumentNotValidException.class)publicResponseEntity<ApiResponse<Void>>handleValidation(MethodArgumentNotValidExceptione){FieldErrorfieldError=e.getBindingResult().getFieldError();Stringmessage=fieldError==null?"参数校验失败":fieldError.getDefaultMessage();returnResponseEntity.badRequest().body(ApiResponse.fail(400,message));}@ExceptionHandler(Exception.class)publicResponseEntity<ApiResponse<Void>>handleOther(Exceptione){log.error("系统异常",e);returnResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(500,"系统繁忙,请稍后再试"));}}@RestControllerAdvice让它成为所有 Controller 的"常驻保安",三类拦截各有讲究:
**第一类:BusinessException → HTTP 400 + 业务码透传。**业务员主动抛的错(设备不存在、提醒创建失败),code 从异常里取出来原样进 body。注意 HTTP 状态固定给 400——这类错误的共同点是"请求本身有问题",客户端改改参数就能重试。
第二类:MethodArgumentNotValidException → HTTP 400 + code 400。DTO 上@NotNull/@NotBlank校验失败时由@Valid触发。消息取首个FieldError 的 defaultMessage(比如"userId 不能为空"),一个字段一个字段地修,体验友好;一个 FieldError 都没有就退回兜底文案"参数校验失败"。
**第三类:Exception 兆底 → HTTP 500 + 固定文案。**任何没被预料到的异常,统一返回"系统繁忙,请稍后再试",同时log.error("系统异常", e)把完整堆栈打进日志。这里有个教科书级的细节:对外文案模糊、对内日志详尽。错误堆栈里的类名、SQL 片段绝不能透给前端(信息泄露风险),但日志里必须留全,否则排查问题两眼一抹黑。
五、为什么参数校验错误要用 400
有同学会问:反正前端只看 code,HTTP 状态码随便给不行吗?行,但浪费了。HTTP 4xx 和 5xx 的分工是整个互联网基础设施的共识:
- 4xx = 客户端的错:网关不会告警、重试也没用(参数不变结果不变)、前端该提示用户改输入;
- 5xx = 服务器的错:监控系统该告警、运维该介入、客户端重试可能恢复。
参数校验失败 100% 是客户端请求的问题,标 400 后,APM 工具、Nginx 日志分析、前端 axios 拦截器都能自动把它归入"用户输入问题"处理,而不是误报"服务挂了"。一行状态码,省掉一整层沟通成本。
六、异常交给全局处理:业务代码零 try-catch
这套机制的最终红利是:Controller 和 Service 里几乎看不到 try-catch。对比一下两种写法:
// 示意:没有全局处理器的世界,每个接口都要这样包@PostMapping("/api/chat")publicApiResponse<ChatResult>chat(@Valid@RequestBodyChatRequestreq){try{returnApiResponse.ok(chatService.chat(...));}catch(BusinessExceptione){returnApiResponse.fail(e.getCode(),e.getMessage());}catch(Exceptione){log.error("chat error",e);returnApiResponse.fail(500,"系统繁忙,请稍后再试");}}19 个接口 × 每个都写一遍 = 维护灾难。而 AI 伙伴的实际 Controller 长这样:
// 项目源码(ChatController,节选)@PostMappingpublicApiResponse<ChatService.ChatResult>chat(@Valid@RequestBodyChatRequestrequest){returnApiResponse.ok(chatService.chat(request.getUserId(),request.getMessage(),request.getSessionType(),Boolean.TRUE.equals(request.getNeedTts())));}干净得像伪代码。校验失败、业务错误、意外异常各走各的拦截通道,横切关注点(异常处理)被彻底从业务代码里剥离——这就是 AOP 思想在异常处理上的落地。
七、当前缺少的异常类型与补齐建议
全局处理器三类拦截能兜住大局,但有两类异常目前会掉进"Exception 兜底",体验打折:
- IllegalArgumentException:Service 里
throw new IllegalArgumentException("openId 不能为空")这类参数问题,现在会被 500 兜底返回"系统繁忙"——明明是客户端的错却报成服务器故障。补一个 Handler 返回 400 即可。 - 鉴权异常:项目无鉴权体系,未来引入 Spring Security 或登录拦截器后,
AccessDeniedException/401 场景必须有专属处理,否则未登录用户会看到"系统繁忙"而不是"请先登录"。
补齐示例(示意):
// 示意:建议新增的两个 Handler@ExceptionHandler(IllegalArgumentException.class)publicResponseEntity<ApiResponse<Void>>handleIllegalArgument(IllegalArgumentExceptione){returnResponseEntity.badRequest().body(ApiResponse.fail(400,e.getMessage()));}八、"异常 → HTTP 状态 → 业务码 → 前端处理"对照表
| 异常来源 | HTTP 状态 | 业务码 code | message | 前端建议处理 |
|---|---|---|---|---|
| 业务成功 | 200 | 0 | success | 渲染 data |
| BusinessException(双参) | 400 | 自定义(如 400) | 具体业务提示 | toast 展示 message |
| BusinessException(单参) | 400 | 500 | 具体业务提示 | toast 展示 message |
| @Valid 校验失败 | 400 | 400 | 首个字段的校验文案 | 高亮对应表单项 |
| 未捕获异常 | 500 | 500 | “系统繁忙,请稍后再试” | 通用错误页,引导重试 |
| (建议补)IllegalArgumentException | 400 | 400 | 参数问题提示 | 按输入错误处理 |
| (建议补)鉴权异常 | 401/403 | 401/403 | 请登录/无权限 | 跳登录页 |
九、合规提醒
异常处理是隐私泄露的常见暗门。二次开发时守住三条:兜底异常的对外文案保持模糊(堆栈、SQL、表结构一律不外泄);log.error的日志里如果含用户对话、健康数值等敏感数据,要按公司日志规范脱敏并限制留存期;健康类业务错误(如"心率数据格式错误")的 message 措辞避免下诊断结论——系统只报数据问题,医疗判断永远留给专业医生。
小结:ApiResponse三段式 +BusinessException+ 三类全局拦截,不到一百行代码撑起了 19 个接口的统一出口。HTTP 状态码管粗分类、业务码管细分类、业务代码零 try-catch——这就是"小项目也有工程尊严"的样子。至此,从表设计到接口出口的整条数据链路你都过了一遍,接下来无论是把系统部署上线还是动手二次开发,心里都有底了。