最近我们在重构公司的支付中台,说实话,这是一个让无数后端工程师头秃的活儿。尤其是当我们要同时接入支付宝和微信支付,并且还要保证高并发下的数据一致性时,那种“如履薄冰”的感觉,体验过的人懂。今天我不跟你扯什么高大上的架构理论,就把它当成是一次深夜的代码复盘,咱们把这层窗户纸捅破,看看这套系统到底是怎么跑起来的。
为什么要把支付宝和微信“强行”统一?
一开始,产品同学只想要一个“一键支付”的功能,觉得简单得不行。但作为技术人员,你得想到,前端调用支付宝 SDK 和调用微信 JSAPI 的入参、回调逻辑、签名方式完全是两套语言。如果让业务方直接对接两套 SDK,那后续的维护成本就是灾难性的。
我们要做的,是一个统一收单网关。它的核心价值很简单:对外(业务方)提供统一的接口,对内(支付渠道)负责协议转换、消息路由和异常处理。这就好比一个跨国会议的同声传译,不管对方说中文还是英文,最后输出的信息必须是一致的。
核心链路设计:从“想支付”到“成功到账”
整个流程我把它拆解为四个阶段:下单、路由、支付、回调。这四个环节环环相扣,任何一个环节掉链子,钱就没了。
1. 下单阶段:生成唯一交易号
当用户点击“立即支付”,业务系统会调用我们的 createOrder 接口。这时候,最关键的动作不是调支付宝,而是本地建单。
public class PaymentOrderService {
@Transactional
public PaymentOrder createOrder(String orderId, Long amount, Integer channel) {
// 1. 检查订单是否已存在,防止重复创建
PaymentOrder existing = paymentOrderMapper.selectByOrderId(orderId);
if (existing != null) {
return existing;
}
// 2. 生成内部唯一交易流水号(UUID + 时间戳 + 随机数)
String tradeNo = generateTradeNo();
// 3. 构建初始订单状态
PaymentOrder order = new PaymentOrder();
order.setOrderId(orderId);
order.setTradeNo(tradeNo);
order.setAmount(amount);
order.setChannel(channel); // 1: 支付宝, 2: 微信
order.setStatus(PaymentStatus.WAITING);
order.setCreateTime(new Date());
// 4. 落库
paymentOrderMapper.insert(order);
return order;
}
}
这里有个细节,tradeNo(交易流水号)是我们在系统中的主键标识。无论支付宝那边生成什么商户订单号,我们都要用自己的 tradeNo 来关联后续所有状态。这是为了避免后续对账时出现“张冠李戴”的问题。
2. 路由阶段:如何优雅地调起第三方支付
拿到 tradeNo 后,我们需要调用具体渠道。这里我推荐用策略模式,而不是满屏的 if-else。
public interface PaymentChannel {
// 下单
CommonResponse preCreate(PaymentContext context);
// 查询
CommonResponse query(PaymentContext context);
// 关闭
CommonResponse close(PaymentContext context);
}
@Component
public class AlipayChannel implements PaymentChannel {
@Override
public CommonResponse preCreate(PaymentContext context) {
// 构造支付宝 SDK 请求参数
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
request.setBizContent(JSON.toJSONString(context.toAlipayParams()));
try {
// 调起支付宝,获取支付表单或 URL
String form = alipayClient.pageExecute(request).getBody();
return CommonResponse.success(form);
} catch (AlipayApiException e) {
log.error("支付宝下单失败", e);
return CommonResponse.fail("支付通道异常");
}
}
}
通过一个 PaymentChannelFactory 根据 channel 类型动态获取对应的 PaymentChannel 实现类。这样以后如果想接云闪付或者银联,直接加个实现类就行,不用动核心代码。
3. 支付阶段:前端的“盲盒”
用户跳转到支付宝/微信页面完成支付后,他们不会立刻看到我们系统的变化。这时候,我们进入了一个异步等待期。
这里有个大坑:不能轮询数据库来检查支付状态。如果并发量大,你的数据库会被查爆。正确的做法是双重确认:
- 等待异步通知:支付宝/微信会主动 POST 一个回调给到我们指定的
notify_url。 - 主动查询兜底:如果在
notify_url超时或失败的情况下,用户点击“支付结果”按钮,或者后台定时任务,我们需要主动调用支付宝/微信的查询接口来同步状态。
public class PaymentNotifyController {
@PostMapping("/alipay/notify")
public String alipayNotify(@RequestBody String notifyData, HttpServletRequest request) {
// 1. 验签!验签!验签!这是安全底线,没验签等于裸奔
if (!AlipaySignature.rsaCheckV1(notifyParams, ALIPAY_PUBLIC_KEY, "UTF-8", "RSA2")) {
log.warn("支付宝验签失败,疑似篡改数据");
return "failure";
}
// 2. 业务幂等性检查
String outTradeNo = request.getParameter("out_trade_no");
if (isProcessed(outTradeNo)) {
return "success"; // 重复通知,直接返回成功
}
// 3. 更新订单状态
handleSuccess(outTradeNo);
return "success"; // 告诉支付宝别再发了
}
}
4. 回调处理:幂等性是核心中的核心
第三方支付的通知具有不可控性。支付宝可能会因为网络波动给你发两次通知,第一次失败了,它还会重发。所以,你的 handleSuccess 方法必须是幂等的。
public void handleSuccess(String tradeNo) {
// 使用乐观锁或分布式锁保证同一笔订单只处理一次
int updated = paymentOrderMapper.updateStatusToPaid(tradeNo);
if (updated > 0) {
// 只有状态真正改变了,才去触发后续业务(如发货、积分增加)
transactionManager.publish(new PaymentSuccessEvent(tradeNo));
} else {
log.info("订单 {} 已处理过,跳过", tradeNo);
}
}
防重放攻击:别让你的系统成为提款机
除了幂等性,防重放也是支付安全的重中之重。黑客如果截获了你和支付宝之间的请求包,复制一发,系统如果没做防护,就可能重复扣款或重复发货。
我们通常采用 Nonce + Timestamp + Sign 的方案:
- Nonce(随机数):每次请求生成一个唯一的随机字符串。
- Timestamp(时间戳):记录请求时间。
- Sign(签名):将 Nonce、Timestamp、业务参数、以及只有服务端知道的
secretKey进行拼接,然后进行 MD5 或 SHA256 加密。
服务端收到请求后:
- 检查
Timestamp是否在合理范围内(比如 5 分钟内),防止旧请求重放。 - 检查
Nonce是否在 Redis 中存在过,存在则拒绝。 - 重新计算签名,对比是否一致。
public boolean verifyRequest(Map<String, String> params, String signature) {
// 1. 时间戳校验
long timestamp = Long.parseLong(params.get("timestamp"));
if (System.currentTimeMillis() - timestamp > 5 * 60 * 1000) {
return false;
}
// 2. Nonce 唯一性校验 (Redis)
String nonce = params.get("nonce");
if (redisTemplate.opsForValue().setIfAbsent("pay:nonce:" + nonce, "1", 10, TimeUnit.MINUTES)) {
// 3. 签名校验
String calculatedSign = buildSign(params);
return StringUtils.equals(signature, calculatedSign);
}
return false; // Nonce 已使用,视为重放攻击
}
对账与异常处理:最后的防线
支付链路中最可怕的不是下单失败,而是“钱到了,但订单没更新”或者“钱没到,订单却显示成功”。这就是所谓的长短款。
对账逻辑
我们每天凌晨会发起一次对账。流程如下:
- 拉取对账文件:从支付宝/微信后台下载前一天的账单 CSV/Excel 文件。
- 解析与入库:将对账单数据解析,存入临时的
reconciliation_record表。 - 三方比对:
- 我方成功 vs 渠道成功:应该一致。如果不一致,可能是回调丢失。
- 我方成功 vs 渠道失败:这是长款,我们需要退款或标记异常。
- 我方失败 vs 渠道成功:这是短款,最危险,需要立即人工介入,补单或联系用户退款。
-- 简单的对账差异查询逻辑示意
SELECT
a.trade_no,
a.status as my_status,
b.status as channel_status,
a.amount as my_amount,
b.amount as channel_amount
FROM payment_order a
LEFT JOIN reconciliation_record b ON a.trade_no = b.trade_no
WHERE a.status = 'SUCCESS'
AND (b.status != 'SUCCESS' OR a.amount != b.amount);
异常处理机制
如果发现了差异,系统会自动触发差错处理流程:
- 自动重试:对于因网络抖动导致的回调丢失,系统会定期主动查询支付宝/微信,如果发现已支付,则自动补单。
- 人工审核队列:对于金额不一致或状态严重冲突的订单,生成工单推送到财务后台,由人工介入处理。
- 告警通知:一旦发现短款,立即发送钉钉/邮件告警给技术负责人和财务人员,必须在规定时间内响应。
结语
这套系统跑起来之后,你会发现,支付不仅仅是一个技术接口的问题,它是一场关于数据一致性、安全性和用户体验的综合博弈。
刚开始做的时候,我们遇到过支付宝回调延迟导致用户投诉“付了钱没到账”,也遇到过微信签名验证失败搞得一晚上的工单。但正是这些坑,让我们把幂等、验签、对账这三个环节打磨得无比坚固。
现在,每当看到后台那一串串绿色的“Success”,心里的那块石头才算真正落地。希望这次的拆解,能帮正在挣扎在支付中台开发路上的你,少掉几根头发。如果有具体的代码问题,随时来聊,咱们一起撸起袖子加油干。
