上一篇完成了统一支付网关的设计,这篇进入真正的"脏活"——对接微信支付V3和支付宝开放平台。两套API风格迥异,踩坑不少,记录下来。
统一支付接口抽象
在对接具体渠道之前,先把抽象层定好。不管微信还是支付宝,对上层业务来说只有一个接口:
public interface PayChannel {
/**
* 统一下单
*/
UnifiedOrderResult unifiedOrder(UnifiedOrderRequest request);
/**
* 查询订单
*/
OrderQueryResult queryOrder(String outTradeNo);
/**
* 关闭订单
*/
void closeOrder(String outTradeNo);
/**
* 处理异步回调
*/
CallbackResult handleCallback(HttpServletRequest request);
/**
* 退款
*/
RefundResult refund(RefundRequest request);
}
UnifiedOrderRequest 包含业务订单号、金额(分)、商品描述、回调URL等通用字段。每个渠道的实现类负责把通用参数转成各自的API格式。
微信支付V3 API对接
微信支付V3相比V2有了质的提升——用JSON替代了XML,签名方式从MD5换成了RSA。但复杂度也上来了。
签名机制
V3的签名流程:
- 构造签名串:
HTTP方法\nURL\n时间戳\n随机串\n请求体\n - 用商户API私钥(从证书文件加载)做SHA256withRSA签名
- 将签名放到Authorization头
public class WechatPaySigner {
private final PrivateKey merchantPrivateKey;
private final String merchantSerialNo;
private final String mchId;
public String sign(String method, String url, String body) {
long timestamp = System.currentTimeMillis() / 1000;
String nonceStr = UUID.randomUUID().toString().replace("-", "");
// 构造签名串
String signStr = String.join("\n",
method, url, String.valueOf(timestamp), nonceStr, body, "");
// RSA签名
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(merchantPrivateKey);
signature.update(signStr.getBytes(StandardCharsets.UTF_8));
String sign = Base64.getEncoder().encodeToString(signature.sign());
// 组装Authorization
return String.format(
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\","
+ "timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\"",
mchId, nonceStr, timestamp, merchantSerialNo, sign);
}
}
证书管理
微信支付V3需要管理两套证书:
- 商户API证书:用于请求签名,从商户平台下载的apiclient_key.pem
- 平台证书:用于验证回调签名,需要通过API定期下载
平台证书会轮换,所以必须做自动更新。我用了一个定时任务每12小时拉取一次:
@Scheduled(fixedRate = 12 * 3600 * 1000)
public void refreshPlatformCert() {
// GET /v3/certificates
// 响应中的证书用APIv3密钥解密(AES-256-GCM)
String certPem = decryptAesGcm(encryptCertificate, apiV3Key);
this.platformCert = loadX509Cert(certPem);
log.info("微信平台证书刷新成功, serial={}", platformCert.getSerialNumber());
}
下单实现
JSAPI下单(小程序/公众号场景):
@Override
public UnifiedOrderResult unifiedOrder(UnifiedOrderRequest req) {
Map<String, Object> body = new HashMap<>();
body.put("appid", config.getAppId());
body.put("mchid", config.getMchId());
body.put("description", req.getDescription());
body.put("out_trade_no", req.getOutTradeNo());
body.put("notify_url", config.getNotifyUrl());
body.put("amount", Map.of("total", req.getAmountInFen(), "currency", "CNY"));
body.put("payer", Map.of("openid", req.getOpenId()));
String jsonBody = objectMapper.writeValueAsString(body);
String auth = signer.sign("POST", "/v3/pay/transactions/jsapi", jsonBody);
HttpResponse resp = httpClient.post(
"https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi",
jsonBody, Map.of("Authorization", auth));
// 响应中拿到 prepay_id
String prepayId = parseJson(resp.body()).getString("prepay_id");
// 组装前端调起支付需要的参数
return buildJsapiPayParams(prepayId);
}
支付宝开放平台对接
支付宝的开发体验比微信好一些,主要是因为有官方SDK,而且文档相对清晰。
RSA2签名
支付宝用RSA2(SHA256WithRSA)签名,流程和微信类似但细节不同:
public class AlipayChannelImpl implements PayChannel {
private final AlipayClient alipayClient;
public AlipayChannelImpl(AlipayConfig config) {
this.alipayClient = new DefaultAlipayClient(
"https://openapi.alipay.com/gateway.do",
config.getAppId(),
config.getPrivateKey(), // 应用私钥
"json", "UTF-8",
config.getAlipayPublicKey(), // 支付宝公钥
"RSA2");
}
@Override
public UnifiedOrderResult unifiedOrder(UnifiedOrderRequest req) {
AlipayTradeCreateRequest request = new AlipayTradeCreateRequest();
request.setNotifyUrl(config.getNotifyUrl());
Map<String, Object> bizContent = new HashMap<>();
bizContent.put("out_trade_no", req.getOutTradeNo());
bizContent.put("total_amount", formatYuan(req.getAmountInFen()));
bizContent.put("subject", req.getDescription());
bizContent.put("buyer_id", req.getBuyerId());
request.setBizContent(objectMapper.writeValueAsString(bizContent));
AlipayTradeCreateResponse resp = alipayClient.execute(request);
if (!resp.isSuccess()) {
throw new PayException("支付宝下单失败: " + resp.getSubMsg());
}
return UnifiedOrderResult.ofAlipay(resp.getTradeNo());
}
}
支付宝的金额单位是元(字符串),微信是分(整数),这个差异在统一接口层就要处理好,否则100块钱变成100分=1块钱,客诉电话直接打爆。
支付回调处理
回调处理是支付系统最容易出bug的地方。核心原则:幂等、幂等、还是幂等。
@PostMapping("/callback/wechat")
public String wechatCallback(HttpServletRequest request) {
try {
// 1. 验签 - 用微信平台证书验证
if (!wechatVerifier.verify(request)) {
log.warn("微信回调验签失败");
return failResponse();
}
// 2. 解密通知体(AES-256-GCM)
String plainText = decryptNotification(request);
WechatPayNotification notification = parse(plainText);
// 3. 幂等处理 - 用订单号做幂等键
String outTradeNo = notification.getOutTradeNo();
boolean processed = orderService.processPaymentResult(
outTradeNo,
notification.getTradeState(),
notification.getTransactionId()
);
if (processed) {
log.info("订单 {} 支付回调处理成功", outTradeNo);
}
// 不管是否已处理过,都返回成功(幂等)
return successResponse();
} catch (Exception e) {
log.error("微信回调处理异常", e);
return failResponse(); // 返回失败让微信重试
}
}
processPaymentResult 内部用数据库乐观锁确保幂等:
public boolean processPaymentResult(String outTradeNo, String state, String transId) {
// UPDATE orders SET status='PAID', channel_trade_no=?
// WHERE out_trade_no=? AND status='PENDING'
int rows = orderMapper.updatePayResult(outTradeNo, state, transId);
if (rows == 0) {
// 已经处理过了,幂等返回
return false;
}
// 触发后续流程:发货通知、积分发放等
eventPublisher.publish(new OrderPaidEvent(outTradeNo));
return true;
}
掉单处理
网络不靠谱,回调可能丢。必须有主动查询的兜底机制:
- 定时轮询:每分钟扫描超过5分钟未收到回调的PENDING订单,主动去微信/支付宝查询
- 延迟队列:下单后往RocketMQ发一条延迟消息(延迟5分钟),消费时检查订单状态
- 对账:每天凌晨下载微信和支付宝的账单文件,和本地订单逐笔核对
@Scheduled(fixedRate = 60_000)
public void pollPendingOrders() {
List<Order> pendingOrders = orderMapper.selectPendingBefore(
LocalDateTime.now().minusMinutes(5));
for (Order order : pendingOrders) {
PayChannel channel = channelFactory.getChannel(order.getChannelType());
OrderQueryResult result = channel.queryOrder(order.getOutTradeNo());
if ("SUCCESS".equals(result.getTradeState())) {
processPaymentResult(order.getOutTradeNo(),
result.getTradeState(), result.getTransactionId());
} else if ("CLOSED".equals(result.getTradeState())) {
orderMapper.updateStatus(order.getOutTradeNo(), "CLOSED");
}
// NOTPAY/USERPAYING 状态继续等待
}
}
踩过的坑
- 微信金额是分,支付宝金额是元:前面提过了,这个坑掉进去一次就够了
- 微信V3的时间戳是秒级,不是毫秒:签名验证怎么都不过,查了半天
- 支付宝异步通知要返回"success"这7个字符:不能有多余的空格或换行
- 微信的商户订单号不能重复使用:即使前一个订单已关闭,同一个out_trade_no也不能再用来下单
- 回调URL必须是HTTPS:本地开发用ngrok穿透
下一篇进入商城系统的开发。