
2026-09-16 · 阅读 17
一次讲透支付回调幂等:支付全流程、幂等三板斧、HTTPS 握手与数字证书
一次讲透支付回调幂等:支付全流程、幂等三板斧、HTTPS 握手与数字证书
本文是《一次讲透 XSS 与 CSRF》《一次讲透 JWT 与 双 Token 机制》《一次讲透 Redis》《一次讲透 React 渲染与性能优化》《一次讲透 NestJS 与 PostgreSQL》《一次讲透数据库与 Redis 实战》《一次讲透认证与授权》的续篇。这一1篇是 D5 支付+安全的完整版:支付流程为什么绕不开"回调幂等"、幂等三板斧怎么落地、防重复回调伪代码逐行拆解,再到数字证书和 HTTPS 握手全过程。最后附一个我在本机亲手跑通的幂等回调 Demo(含 Windows 下的 curl 踩坑实录)。全文按"能口述出来"的标准整理,每节末尾附我自己的背诵版口述。
Part 1 支付流程 + 回调幂等
幂等:支付系统的核心是在不稳定的网络环境下,确保用户付了钱这个事情只处理一次。如果不做幂等,微信重试三次,就发货三次、加三次余额。
1. 完整支付流程
用户点击支付,后端创建订单、生成唯一的商户单号,调用统一下单 API 返回一个 prepay_id,拉起收银台,输密码付款。扣款成功之后,微信进行异步回调 POST /pay/notify——这是核心,可能重复发。验签之后进行幂等处理、改状态、应答成功,最后使用定时任务调订单查询 API 来对账。
下单 → 统一下单 → 拉起收银台 → 异步回调 → 验签幂等改状态 → 查询对账兜底
1.2 幂等的三板斧
| 手段 | 做法 | 一句话 |
|---|---|---|
| 唯一单号 | 使用 out_trade_no 建唯一索引,重复插入直接报错 | 同一笔钱只有一个"身份证号" |
| 状态机 | UPDATE orders SET status = 'paid' WHERE order_no = ? AND status = 'pending',看影响行数 | 单向流转:待支付→已支付,已支付的再来直接失效 |
| 幂等键 | 处理前先 INSERT 幂等记录(唯一键)/ SETNX,冲突 = 处理过 | 先占坑再干活 |
1.3 Demo:防重复回调伪代码
// POST /pay/notify/wechat -- 微信异步回调
app.post('/pay/notify/wechat', async (req, res) => {
const notify = req.body;
// 1. 验签:用微信平台公钥进行验签,防止攻击者伪造"支付成功"
if (!verifySign(notify)) return res.send('FAIL');
// 2. 幂等核心:状态机 + 条件更新(乐观锁)
const [result] = await db.query(
`UPDATE orders
SET status = 'paid', paid_at = NOW(), transaction_id = ?
WHERE order_no = ? AND status = 'pending'`,
[notify.transaction_id, notify.out_trade_no]
);
// 3. 影响行数 = 0:订单已是 paid(重复回调命中幂等)直接应答成功
if (result.affectedRows === 0) {
log('重复回调,已经忽略', notify.out_trade_no);
return res.send('SUCCESS'); // 必须应答 SUCCESS,否则微信永远重试
}
// 4. 只有首次(影响行数 = 1)才走到这里,后续业务天然只执行一次
await grantBenefit(notify.out_trade_no); // 发货/加余额/发优惠券
res.send('SUCCESS');
});
幂等的全部秘密就在 AND status = 'pending' + 影响行数判断,一行 SQL 的事。
1.4 追问五连
- 回调丢了怎么办? 定时任务扫描 pending 超过 N 分钟的订单,调微信「订单查询 API」主动核对——这就是对账
- 怎么防伪造回调? 验证签名(微信用平台公钥/密钥校验签名),所以绝对不能不验证签名就信 body
- 先发货还是先改状态? 先改状态再发货,并且发货失败要有重试/补偿机制
- 幂等键除了 MySQL 还能用什么? Redis
SETNX order_no占坑位,性能更好,但是 Redis 和 DB 双写又有一致性的问题 - 本质是什么? 乐观锁的思想,和秒杀防超卖的 DB 兜底是同一层
1.5 口述:支付回调为什么必须幂等
支付回调必须幂等,因为网络不可靠,微信的回调会重复发,用户也可能重复支付,不幂等就会重复发货造成资产损失。实现上使用状态机 + 唯一单号:下单的时候生成唯一的商户单号建唯一索引,回调来了先验证签,然后用条件更新
UPDATE ... WHERE order_no = ? AND status = 'pending',只有影响行数为 1 才执行后续的业务,为 0 说明是重复回调直接应答成功。本质上是乐观锁的思想,和秒杀超卖 DB 兜底是同一层。
Part 2 数字证书 + HTTPS 握手
HTTPS = HTTP + TLS,解决明文传输的窃听、篡改、冒充的问题。数字证书解决公钥认证的问题。
证书内容 = 服务器公钥 + 域名等身份信息 + CA 的数字签名(CA 用它的私钥对证书摘要加密生成)。
2.1 HTTPS 建立连接的过程
客户端 服务器
│ 1. Client Hello:TLS版本+随机数1+支持的加密套件 ──►
│ 2. ◄── Server Hello:随机数2 + 数字证书(含服务器公钥)
│ 3. 验证证书(CA根证书验签) → 生成预主密钥
│ 用服务器公钥加密预主密钥 ──────────────────► 私钥解出预主密钥
│ 4. 双方用 随机数1+随机数2+预主密钥 算出对称的会话密钥
│ ◄════════ 之后全部用对称加密通信 ════════►
客户端发出 Client Hello:将 TLS 版本号 + 随机数和支持的加密套件发送到服务器。服务器返回 Server Hello:随机数 2 和包含服务器公钥的数字证书。客户端验证证书(CA 根证书验签),生成预主密钥,用服务器公钥加密预主密钥传过去,服务器私钥解出预主密钥。之后双方用随机数 1 + 随机数 2 + 预主密钥计算出对称的会话密钥,在这之后全部用对称的加密通信。
口诀:两个随机数 + 一张证书 + 一个预主密钥,拼凑出会话密钥。
2.2 三个追问
- 为什么对称和非对称混用? 非对称加密慢,只是在握手的时候用来安全地传预主密钥,之后大数据量通信用对称加密(快)
- 为什么凑三个随机数? 保证每次会话密钥都不同,防重放
- TLS 1.3 改进? 握手从 2-RTT 压缩到 1-RTT,更快
2.3 口述:HTTPS 建立连接过程
HTTPS:先 TCP 三次握手再 TLS 握手。客户端发随机数和加密套件,服务器返回随机数和数字证书,客户端用内置 CA 根证书验签,确认公钥没被掉包;生成预主密钥用服务器公钥加密传输过去,双方用三个随机数算出对称会话密钥,之后通信用对称加密。设计核心是非对称只用来握手换取密钥、对称用来传数据,因为非对称很慢。数字证书解决的是公钥信任问题,防中间人。
Part 3 XSS 和 CSRF(速记版)
XSS 是跨站脚本攻击,CSRF 是跨站请求伪造。完整版见《一次讲透 XSS 与 CSRF》。
- XSS 是恶意注入脚本,防输出转义和 HttpOnly
- CSRF 是借 cookie 冒充用户,防 CSRF Token 和 SameSite
Part 4 实战 Demo:亲手跑一个防重复回调
光背伪代码没有底气,我在本机用 Express 跑了一个可运行的最小版本——内存 Map 模拟订单表,真实项目里换成 MySQL 的条件 UPDATE 即可。
4.1 完整代码(pay-demo/index.js)
const express = require('express');
const app = express();
app.use(express.json());
// 模拟订单表:真实项目里是 MySQL 的 UPDATE ... WHERE status = 'pending'
const orders = new Map();
// 模拟用户下单
app.post('/order', (req, res) => {
const orderNo = 'ORD-' + Date.now();
orders.set(orderNo, { status: 'pending' }); // 唯一单号
res.json({ orderNo });
});
// 微信回调
app.post('/pay/notify', (req, res) => {
const { orderNo } = req.body;
const order = orders.get(orderNo);
if (!order) return res.send('SUCCESS'); // 非法单号也直接应答
if (order.status === 'pending') { // ★ 状态机:只有 pending 才处理
order.status = 'paid';
console.log('首次回调 → 执行发货:', orderNo);
} else {
console.log('重复回调 → 忽略:', orderNo);
}
res.send('SUCCESS');
});
app.listen(3000, () => console.log('listening on 3000'));
4.2 运行与验证
mkdir pay-demo && cd pay-demo
npm init -y && npm i express
node index.js # 看到 listening on 3000
下单,然后拿同一个单号连发 3 次回调,模拟微信的重试:
curl.exe -X POST localhost:3000/order
# 返回 {"orderNo":"ORD-1789465721572"}
curl.exe -X POST localhost:3000/pay/notify -H "Content-Type: application/json" -d '{\"orderNo\":\"ORD-1789465721572\"}'
# 同一条命令再发两次
4.3 实测结果(服务端日志)
listening on 3000
首次回调 -》 执行发送: ORD-1789465009867
重复回调 + 忽略 ORD-1789465009867
重复回调 + 忽略 ORD-1789465009867
同一单号 3 次回调,只发货 1 次、忽略 2 次,且 3 次都应答 SUCCESS(微信不会继续重试)——这就是幂等的完整行为。
写在最后
支付幂等一句话:非对称解决"钥匙怎么安全递过去",对称负责"之后天天干活";而幂等解决的是"同一件事只干一次"。判断标准很简单——把回调当成"会重复响的到账喇叭",认钱不认响声:状态机 + 唯一单号 + 对账兜底,三件套齐活。
评论 (0)
还没有评论,来抢沙发吧~