上周接了个新需求,对接第三方支付平台的退款接口。文档看了两遍,觉得挺简单,不就是POST一个JSON过去,等个响应嘛。结果从下午两点折腾到晚上十点,中间差点把电脑扔出窗外。记录一下这八个小时的痛苦经历。
先说背景。对方提供的接口文档版本是v2.1,要求用HTTPS请求,Content-Type是application/json,签名方式是他们自定义的HMAC-SHA256。退款请求需要传订单号、退款金额、退款原因,还有商户号和签名。
我按照文档写了个Java方法,用的RestTemplate,Spring Boot版本是2.7.3。代码大概长这样:
“`
String url = “https://api.pay.xxx.com/v2.1/refund”;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map
body.put(“merchant_id”, “M20231101”);
body.put(“order_id”, “ORD20231115001”);
body.put(“refund_amount”, 99.99);
body.put(“refund_reason”, “用户退货”);
body.put(“timestamp”, System.currentTimeMillis());
body.put(“sign”, generateSign(body, secretKey));
HttpEntity
看着没问题吧?我测了一下本地环境,返回了{“code”:400,”msg”:”invalid sign”}。签名错误。我第一反应是签名算法写错了,翻文档看了三遍签名规则:把所有参数按key排序,拼接成key1=value1&key2=value2格式,最后加上&key=密钥,再做HMAC-SHA256。我对照着文档一行一行改代码,测试了十几次,还是签名错误。
这时候我开始怀疑是不是编码问题。检查了字符串的编码格式,确认是UTF-8。又怀疑是不是密钥传错了,跟对方要了测试环境的密钥,重新配置。依然报错。
我决定用Postman手动发一次请求。把同样的参数填进去,签名算法手动算了一遍,用在线工具生成签名,发出去。结果返回了成功。这就奇怪了,同样的参数,Postman能通,Java代码不行。
仔细对比了Postman发送的请求和Java发送的请求。用Wireshark抓包看了一下,发现Java那边发送的请求体里,refund_amount的值是99.99,而Postman里我填的是”99.99″。我代码里用的是Double类型,序列化成JSON后变成了99.99,而文档要求金额字段必须是字符串。对方服务器在验签时,把金额当字符串处理了,导致签名验证的原文跟我计算的不一致。
改掉这个,把refund_amount改成字符串”99.99″。重新跑,签名验证通过了,但是返回了新的错误:{“code”:500,”msg”:”internal error”}。
这又是什么鬼?我查日志,对方服务器返回了500。联系对方技术支持,对方说他们日志里看到请求参数多了个字段。我检查代码,发现我传了timestamp,但文档里根本没这个字段。我一开始以为时间戳是通用字段就加上了,结果对方服务器不认。
去掉timestamp,重新发。这次返回了{“code”:200,”msg”:”success”,”refund_id”:”RF2023111500123″}。总算通了。
我以为这就结束了,开始写集成测试。测试用例里我模拟了重复退款的情况,就是同一笔订单调用两次退款接口。第一次返回成功,第二次应该返回订单已退款的错误码。结果第二次调用时,程序直接抛出了异常:org.springframework.web.client.HttpServerErrorException: 500 null。
我打开对方文档的错误码说明,发现重复退款应该返回code=400,msg=”order already refunded”。但实际返回了500。我怀疑是对方服务器的问题,又联系技术支持。对方查了半天,说是因为我第二次请求的refund_reason字段值变了。第一次我传的是”用户退货”,第二次传的是”重复退款”。而他们的业务逻辑里,退款原因不同会导致内部异常。这设计也是醉了。
好吧,我把退款原因固定成同一个字符串,再测。这次返回了code=400,msg=”order already refunded”。但是我的代码里,用RestTemplate的postForEntity方法,遇到400状态码会直接抛HttpClientErrorException,我根本没机会读到响应体里的错误信息。
我需要处理非200的状态码。解决方案是改用RestTemplate的exchange方法,捕获异常或者用ResponseEntity来拿状态码和响应体:
“`
ResponseEntity
if (response.getStatusCode().is2xxSuccessful()) {
// 处理成功
} else {
// 读取response.getBody()里的错误信息
}
“`
但restTemplate.exchange在遇到4xx或5xx时还是会抛异常。我查了一下,可以通过配置ErrorHandler来改变这个行为:
“`
restTemplate.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public void handleError(ClientHttpResponse response) throws IOException {
// 什么都不做,让调用方自己处理状态码
}
});
“`
或者更干净的做法,用try-catch捕获HttpStatusCodeException,从异常里拿到响应体:
“`
try {
ResponseEntity
} catch (HttpStatusCodeException e) {
String errorBody = e.getResponseBodyAsString();
// 解析errorBody获取错误码和错误信息
}
“`
我选了第二种,因为这样能区分正常响应和异常响应,而且异常里包含了完整的响应信息。
改完代码,所有测试用例都过了。但事情还没完。上线后第二天,运维反馈说退款接口偶尔超时。我看了一下日志,有个别请求耗时超过了5秒。对方接口的SLA承诺是2秒以内,这明显有问题。
排查发现,超时的请求都是退款金额比较大的订单,比如5000块以上的。我怀疑对方做了风控校验,大额退款需要额外的审核流程。问技术支持,对方确认了,大额退款会触发人工审核,接口会阻塞直到审核完成。但文档里没写这一点。
我能怎么办?只能把超时时间从5秒改成30秒,并且在代码里加了个异步处理,把退款请求丢到消息队列里,让用户不用等结果。同时加了个定时任务去查询退款状态。
总结一下这次踩的坑:
第一,参数类型要跟文档严格一致。文档说金额是字符串,就别用数字。文档说没有timestamp字段,就别自作主张加。签名算法涉及到所有参与签名的参数,多一个少一个都不行。
第二,状态码处理别偷懒。不要以为接口只会返回200,4xx和5xx都要考虑。尤其是对接外部系统,对方可能返回任何状态码。
第三,文档没写的东西不代表不存在。大额退款有风控、重复退款会因为原因不同而报500,这些都是实际跑起来才知道的。建议对接初期先做一轮冒烟测试,把各种边界情况都试一遍。
第四,RestTemplate的默认行为是抛出异常处理非2xx状态码。要么配置ErrorHandler,要么用try-catch。推荐后者,因为你能拿到具体的错误信息。
最后说一句,做API对接,心态一定要稳。遇到问题先怀疑自己,再怀疑文档,最后怀疑对方。别一上来就觉得是对方接口有问题。我这次浪费了四个小时在签名问题上,结果是自己类型搞错了。