女生做蛋糕甜品屋宝宝
105.50M · 2026-04-17
上周五下午4点,财务紧急来电:
“老张!用户支付1000元,系统显示‘支付成功’,但钱没到账!已经17笔了!”
我秒开日志,定位到支付回调接口:
@PostMapping("/pay/callback")
public String handlePayCallback(@RequestBody PayRequest request) {
payService.process(request); // 这里抛了异常!
return "success"; // 但前端收到这个!
}
问题在哪?
全局异常处理器@RestControllerAdvice捕获了异常,但返回了HTTP 200 + 错误JSON!
而支付平台要求:失败必须返回非200状态码或特定字符串!
结果:支付平台以为成功,用户扣款,但订单没生成。
直接损失:1.7万元。
这已经是今年第2次同类事故。
上回是短信回调没处理异常,导致验证码失效……
今天,我把血泪总结的3大陷阱+生产级方案掏出来,手把手教你写出稳如老狗的异常处理。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统繁忙");
}
}
后果:
所有接口(包括支付回调、短信回调、Webhook)都返回{"code":500,"msg":"系统繁忙"} + HTTP 200
但第三方平台要求:
fail字符串<return_code>FAIL</return_code>你的“友好提示”,成了别人的“成功信号”!
// 1. 普通API异常处理器(返回JSON)
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler {
@ExceptionHandler(ServiceException.class)
public Result<?> handleServiceException(ServiceException e) {
return Result.error(e.getCode(), e.getMessage());
}
}
// 2. 支付回调专用处理器(返回字符串)
@RestControllerAdvice(assignableTypes = PayCallbackController.class)
public class PayCallbackExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handlePayException(Exception e) {
log.error("支付回调异常", e);
// 支付宝/微信要求:失败返回"fail"
return ResponseEntity.status(200).body("fail");
}
}
// 3. Webhook专用处理器(返回5xx)
@RestControllerAdvice(assignableTypes = WebhookController.class)
public class WebhookExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<Void> handleWebhookException() {
// 明确返回500,触发第三方重试
return ResponseEntity.status(500).build();
}
}
关键注解:
basePackages:按包路径隔离assignableTypes:按Controller类隔离(更精准!)验证技巧:
用Postman调支付回调接口,故意传错参数,看是否返回fail而非JSON!
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusiness(BusinessException e) {
// 只返回错误,不记日志!
return Result.error(e.getMessage());
}
后果:
老板灵魂拷问:“为什么没告警?为什么没日志?”
我哑口无言——因为异常被“静默”处理了!
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusiness(BusinessException e, HttpServletRequest request) {
// 1. 记录WARN日志(业务异常需关注)
log.warn("业务异常 | URI:{} | User:{} | Error:{}",
request.getRequestURI(),
getCurrentUserId(), // 从Token解析
e.getMessage()
);
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<?> handleSystem(Exception e, HttpServletRequest request) {
// 2. 记录ERROR日志(系统异常需告警)
String traceId = MDC.get("TRACE_ID"); // 链路ID
log.error("系统异常 | TraceId:{} | URI:{} | Params:{}",
traceId,
request.getRequestURI(),
maskSensitiveParams(request.getParameterMap()) // 脱敏!
, e);
// 3. 触发告警(可选)
alertService.send("系统异常", e.getMessage());
return Result.error("系统繁忙,请稍后重试");
}
// 敏感参数脱敏工具
private String maskSensitiveParams(Map<String, String[]> params) {
Map<String, String> safeParams = new HashMap<>();
for (String key : params.keySet()) {
if (key.contains("password") || key.contains("card")) {
safeParams.put(key, "******");
} else {
safeParams.put(key, String.join(",", params.get(key)));
}
}
return JsonUtil.toJson(safeParams);
}
日志分级:
BusinessException → WARN(业务规则,如库存不足)Exception → ERROR(系统故障,需告警)必须记录:
血泪教训:上次没脱敏,日志里打印了用户银行卡号,被安全审计通报!
// Controller
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
// 全局处理器
@ExceptionHandler(IllegalArgumentException.class)
public Result<?> handleIllegalArg(IllegalArgumentException e) {
return Result.error(400, e.getMessage()); // 返回400
}
@ExceptionHandler(NullPointerException.class)
public Result<?> handleNPE(NullPointerException e) {
return Result.error(500, "系统错误"); // 返回500
}
后果:
前端无法区分“用户不存在”(客户端错)和“系统错误”(服务端错)
所有错误弹窗都是“系统繁忙”,用户体验极差
更糟的是:
400表示参数错4001表示用户不存在200但code=500...前端:你们后端能不能统一一下?!
public enum ErrorCode {
// 客户端错误(4xx)
USER_NOT_FOUND(4001, "用户不存在"),
INVALID_PARAM(4002, "参数无效"),
// 服务端错误(5xx)
SYSTEM_ERROR(5000, "系统繁忙"),
DB_ERROR(5001, "数据库异常");
private final int code;
private final String msg;
ErrorCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
// getter...
}
// 业务异常(客户端错)
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMsg());
this.errorCode = errorCode;
}
}
// 系统异常(服务端错)
public class SystemException extends RuntimeException {
public SystemException(String message) {
super(message);
}
}
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
User user = userService.findById(id);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND); // 明确错误类型
}
return user;
}
@RestControllerAdvice
public class UnifiedExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusiness(BusinessException e) {
log.warn("业务异常: {}", e.getErrorCode().getMsg());
// 返回统一格式:{code: 4001, msg: "用户不存在"}
return Result.error(e.getErrorCode().getCode(), e.getErrorCode().getMsg());
}
@ExceptionHandler(Exception.class)
public Result<?> handleSystem(Exception e) {
log.error("系统异常", e);
return Result.error(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMsg());
}
}
// axios拦截器
axios.interceptors.response.use(
response => {
const { code, msg } = response.data;
if (code === 4001) {
router.push('/login'); // 用户不存在,跳登录
} else if (code >= 5000) {
Toast.error('系统繁忙'); // 服务端错,友好提示
}
return response;
}
);
错误码规范:
4000-4999:客户端错误(可修复)5000-5999:服务端错误(需告警)禁止:
IllegalArgumentException等JDK异常if (xxx) return error("xxx")终极好处:
新人入职,看ErrorCode.java就知道所有错误场景!
表格
| 项目 | 必须做 | 验证方式 |
|---|---|---|
| 协议隔离 | 按Controller分异常处理器 | Postman测试回调接口返回值 |
| 日志记录 | 业务异常WARN,系统异常ERROR | 查日志关键词 |
| 敏感脱敏 | 密码、卡号、手机号打码 | 日志搜索"password"应无明文 |
| 错误码统一 | 所有异常走ErrorCode枚举 | grep -r "throw new" 检查 |
| 状态码合规 | Webhook失败返回5xx | curl -I 测试 |
异常处理不是“加个@RestControllerAdvice就完事”:
记住老张的话:
下次再写异常处理器,先问自己:
“如果明天出事故,我的日志能10分钟定位问题吗?”