一、场景与痛点
在电商中台系统中,微信支付是目前最主流的在线支付方式。用户在前端发起支付后,微信支付服务端会异步向商户后台发送支付结果通知(即"回调")。这个看似简单的流程,实则暗藏多个技术陷阱:
- 通知重试与幂等:微信支付回调最多会重试 9 次(间隔递增),如果接口不是幂等的,同一笔订单可能被重复处理多次,导致库存多扣、订单状态覆盖错误甚至资金损失。
- 签名验证:回调接口暴露在公网,任何人都能伪造 POST 请求。必须严格验证微信签名,防止恶意请求修改订单状态。
- 回调与前端跳转竞态:用户支付成功后浏览器同步跳转到"支付成功页",此时回调可能还没到达。前端不能依赖跳转参数做最终状态判断,必须以异步通知为准。
- 通知安全与重放攻击:通知数据中包含商户订单号、支付金额等敏感信息,需要验签 + 防重放的组合保障。
本文以 Spring Boot 3.x + 微信支付 v3 API 为例,完整展示回调处理的安全设计与实现。
二、依赖准备
微信支付 v3 推荐使用官方 SDK wechatpay-apache-httpclient,它封装了签名生成、验签和 HTTP 请求等基础能力。
<!-- pom.xml -->
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.4.10</version>
</dependency>
<!-- 需配合 Apache HttpClient 5.x -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
同时需要准备好商户平台下载的证书序列号、API v3 密钥,以及通过 CertificateDownloader 下载的平台证书(用于验签)。
三、回调接收接口设计
微信支付以 POST 方式回调,Content-Type 为 application/json。我们把它设计为两个独立切面:准入校验(签名、时间戳、随机串) → 业务处理(幂等、订单更新)。
3.1 配置类
@Configuration
public class WechatPayConfig {
@Value("${wechat.pay.mch-id}")
private String mchId;
@Value("${wechat.pay.api-v3-key}")
private String apiV3Key;
@Value("${wechat.pay.merchant-serial-no}")
private String merchantSerialNo;
@Value("${wechat.pay.private-key-path}")
private String privateKeyPath;
@Bean
public HttpClient httpClient() throws Exception {
// 加载商户私钥
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new FileInputStream(privateKeyPath));
// 构建自动签名 HttpClient
HttpClients httpClients = new HttpClients();
return httpClients.createHttpClient(merchantPrivateKey,
merchantSerialNo, mchId);
}
@Bean
public AutoUpdateCertificatesVerifier verifier() throws Exception {
// 自动更新平台证书(推荐)
return new AutoUpdateCertificatesVerifier(
httpClient(), apiV3Key.getBytes(StandardCharsets.UTF_8));
}
}
3.2 回调 DTO
@Data
public class WechatPayCallbackRequest {
private String id; // 通知 ID
private String createTime; // 通知创建时间
private String eventType; // 事件类型(TRANSACTION.SUCCESS)
private String resourceType; // 资源类型(encrypt-resource)
private Resource resource; // 加密资源对象
@Data
public static class Resource {
private String algorithm; // 加密算法(AEAD_AES_256_GCM)
private String ciphertext; // 密文
private String associatedData;
private String nonce;
private String originalType; // transaction
}
}
@Getter
@Setter
public class PaymentCallbackResponse {
private String code; // SUCCESS / FAIL
private String message;
}
3.3 核心回调处理
@RestController
@Slf4j
public class WechatPayCallbackController {
@Autowired
private AutoUpdateCertificatesVerifier verifier;
@Autowired
private OrderService orderService;
@PostMapping("/api/payment/wx-notify")
public ResponseEntity<PaymentCallbackResponse> handleNotify(
HttpServletRequest request,
@RequestHeader("Wechatpay-Signature") String signature,
@RequestHeader("Wechatpay-Timestamp") String timestamp,
@RequestHeader("Wechatpay-Nonce") String nonce,
@RequestHeader("Wechatpay-Serial") String serial) {
// ----------------- 第一步:防重放攻击 -----------------
long now = System.currentTimeMillis() / 1000;
long reqTs = Long.parseLong(timestamp);
// 只接受 5 分钟内的通知(防止重放攻击)
if (now - reqTs > 300) {
log.warn("回调时间戳超限,视为重放攻击: {}", timestamp);
return ResponseEntity.ok(
new PaymentCallbackResponse("FAIL", "timestamp expired"));
}
// ----------------- 第二步:签名验证 -----------------
String body;
try {
body = request.getReader().lines()
.collect(Collectors.joining(System.lineSeparator()));
} catch (IOException e) {
log.error("读取回调 body 失败", e);
return ResponseEntity.status(500).body(
new PaymentCallbackResponse("FAIL", "read body error"));
}
// 构建待验签字符串
String signStr = timestamp + "\n" + nonce + "\n" + body + "\n";
// 获取对应序列号的平台证书
X509Certificate certificate = verifier.getCertificate(serial);
if (certificate == null) {
log.warn("未找到序列号 {} 对应的平台证书", serial);
return ResponseEntity.ok(
new PaymentCallbackResponse("FAIL", "cert not found"));
}
// 执行验签
boolean verified = verify(certificate, signStr, signature);
if (!verified) {
log.warn("微信回调签名验证失败, serial={}", serial);
return ResponseEntity.ok(
new PaymentCallbackResponse("FAIL", "signature invalid"));
}
// ----------------- 第三步:解密资源 -----------------
WechatPayCallbackRequest callbackDTO = JSON.parseObject(body,
WechatPayCallbackRequest.class);
if (!"TRANSACTION.SUCCESS".equals(callbackDTO.getEventType())) {
// 非支付成功事件,直接返回成功
return ResponseEntity.ok(
new PaymentCallbackResponse("SUCCESS", "ignored"));
}
String plaintext;
try {
plaintext = AesUtil.decryptToString(
apiV3Key.getBytes(StandardCharsets.UTF_8),
callbackDTO.getResource().getAssociatedData(),
callbackDTO.getResource().getNonce(),
callbackDTO.getResource().getCiphertext());
} catch (Exception e) {
log.error("回调资源解密失败", e);
return ResponseEntity.ok(
new PaymentCallbackResponse("FAIL", "decrypt error"));
}
// 解析解密后的支付订单数据
JSONObject payResult = JSON.parseObject(plaintext);
String outTradeNo = payResult.getString("out_trade_no");
String transactionId = payResult.getString("transaction_id");
Integer totalFee = payResult.getInteger("total_fee"); // 单位:分
// ----------------- 第四步:幂等处理 + 业务更新 -----------------
try {
orderService.processPaymentCallback(outTradeNo,
transactionId, totalFee);
} catch (Exception e) {
log.error("订单 {} 回调处理异常", outTradeNo, e);
// 返回非 200 状态码,微信会重试
return ResponseEntity.status(500).body(
new PaymentCallbackResponse("FAIL", "process error"));
}
// 返回 SUCCESS 告知微信不再重试
return ResponseEntity.ok(
new PaymentCallbackResponse("SUCCESS", "ok"));
}
private boolean verify(X509Certificate certificate,
String signStr, String signature) {
try {
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initVerify(certificate);
sign.update(signStr.getBytes(StandardCharsets.UTF_8));
return sign.verify(Base64.getDecoder().decode(signature));
} catch (Exception e) {
log.error("验签异常", e);
return false;
}
}
}
四、幂等处理的关键实现
微信支付的通知存在以下特征:
- 同一笔订单可能收到多次相同回调
- 不同通知的
id(通知 ID)不同但out_trade_no相同 - 回调与用户主动查询并存(前端轮询"查订单"接口)
因此幂等不能依赖通知 ID 去重,必须以订单维度加分布式锁 + 状态机判断。
@Service
@Slf4j
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderMapper orderMapper;
private static final String PAY_LOCK_KEY = "pay:lock:";
private static final int LOCK_SECONDS = 30;
@Transactional(rollbackFor = Exception.class)
public void processPaymentCallback(String outTradeNo,
String transactionId,
Integer totalFee) {
// ---------- 第一步:分布式锁 ----------
String lockKey = PAY_LOCK_KEY + outTradeNo;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(LOCK_SECONDS));
if (Boolean.FALSE.equals(locked)) {
// 有另一个线程正在处理,这里直接抛异常让微信重试
// 更好的做法是等待锁释放,但简单场景下重试更安全
throw new BusinessException("订单 " + outTradeNo + " 正在处理中");
}
try {
// ---------- 第二步:状态机判断 ----------
Order order = orderMapper.selectByOutTradeNo(outTradeNo);
if (order == null) {
log.warn("回调订单 {} 不存在", outTradeNo);
return; // 直接返回 SUCCESS,不再重试
}
// 订单已经支付完成,直接跳过(幂等)
if ("PAID".equals(order.getStatus())
|| "REFUNDING".equals(order.getStatus())) {
log.info("订单 {} 已支付,跳过重复回调", outTradeNo);
return;
}
// 金额核对:微信返回的金额与本地订单金额必须一致
if (!totalFee.equals(order.getActualAmount())) {
log.error("订单 {} 金额不一致: 微信={}, 本地={}",
outTradeNo, totalFee, order.getActualAmount());
// 严重异常,人工介入
alertSystem("PAY_AMOUNT_MISMATCH", outTradeNo);
return;
}
// ---------- 第三步:更新订单 ----------
int updated = orderMapper.updatePaySuccess(
outTradeNo,
transactionId,
LocalDateTime.now());
if (updated == 0) {
log.warn("订单 {} 更新失败(并发冲突), 等待重试", outTradeNo);
throw new BusinessException("订单更新冲突");
}
// ---------- 第四步:后置处理 ----------
// 1. 发送订单支付成功 MQ 消息(用于积分赠送、短信通知等)
// 2. 清除订单超时延迟任务(如果有 Redis 延迟队列)
// 3. 记录支付流水日志
log.info("订单 {} 支付回调处理成功, transactionId={}",
outTradeNo, transactionId);
} finally {
// 注意:锁不能提前释放,要在事务提交后才释放
// 这里用「事务同步」机制确保锁在事务提交后释放
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete(lockKey);
}
});
}
}
}
五、踩坑记录
5.1 锁释放时机错误
现象:在 finally 块直接释放锁,导致事务还未提交,下一个重试请求就进来了,读到的是旧状态,重复创建支付流水。
解决:使用 TransactionSynchronizationManager.registerSynchronization 注册在 afterCommit 阶段释放锁,确保事务可见性。
5.2 验签失败误返回 200
现象:验签失败返回了 HTTP 200 + "SUCCESS",微信认为回调成功不再重试,实际业务没处理。
解决:签验失败必须返回 非 200 状态码(建议 500 或在 200 body 中带 "FAIL"),让微信继续重试。
5.3 忽略 resource 解密异常
现象:解密失败时返回 200 + "SUCCESS",导致丢失支付回调,用户已付款但订单未变为已支付。
解决:解密失败必须返回 "FAIL" 让重试。同时监控告警解密失败次数。
5.4 回调与页面同步跳转的时序竞态
现象:用户支付成功后浏览器跳转到成功页,前端直接调用"查订单接口"发现仍是"待支付"(回调还没到)。用户以为支付失败。
解决:前端在支付成功页启动 轮询(3秒/次,最长 30 秒),直到订单状态变为已支付或超时。后端查询接口本身也做"兜底查询"——如果在缓存中查到该订单已收到过回调处理请求但还在事务中,返回"处理中"状态让前端继续轮询。
六、最佳实践总结
- 严格验签:每次回调都必须用平台证书 + Wechatpay-Signature 验签,缺一不可。验签失败不允许进入业务逻辑。
- 防重放:验证 Wechatpay-Timestamp 距离当前时间不超过 5 分钟,防止攻击者截获通知后重放。
- 幂等是硬要求:基于订单维度的分布式锁 + 状态机判断,确保同一笔订单最多被处理一次。
- 失败必须重试:任何业务处理异常都不要吞掉,返回 FAIL 让微信重试;连续失败需告警人工介入。
- 金额二次核对:回调中的支付金额必须与本地订单金额逐分核对,不一致说明严重异常。
- 事务与锁的生命周期匹配:确保分布式锁在事务提交后才释放,避免脏读。