Portfolio
← 返回博客列表
一次讲透支付回调幂等:支付全流程、幂等三板斧、HTTPS 握手与数字证书

2026-09-16 · 阅读 17

一次讲透支付回调幂等:支付全流程、幂等三板斧、HTTPS 握手与数字证书

回调幂等,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)

0 / 1000

还没有评论,来抢沙发吧~