小程序支付回调丢了三笔单:微信支付 V3 签名、验签与对账的实战笔记
适用读者:写过微信小程序(Mini Program)、接过微信支付 JSAPI 下单,但还没被支付回调(Notify Callback)毒打过的后端同学。示例以 C# 与 Python 为主,Node 与 Go 思路一致。
对账那天,账单上浮出三笔差额
预约类小程序上线满一个月,财务拉了商户平台的日对账单(Trade Bill)逐笔核对:微信侧成功收款 128 笔,我们订单表里标记已支付的只有 125 笔。

钱进了商户号,预约名额没放出去,用户端小程序弹了"支付成功",订单却卡在待支付,客服当天接了三个投诉。
翻日志确认不是下单丢单,是回调链路断了三次,原因各不相同:一次验签(Signature Verification)失败被代码吞掉,一次业务超时被判定失败后重试风暴打穿连接池,还有一次是重复通知把状态打回了中间态。
证书与密钥:先分清谁签谁验
微信支付 API v3 里有两套证书,很多人一开始是混着用的。商户 API 证书(Merchant API Certificate)是你自己保管的私钥证书,用来给请求签名;平台证书(Platform Certificate)是微信侧的公钥证书,用来验响应和回调的签名。
方向别搞反:你用商户私钥签名,微信拿你的公钥验;微信用它自己的私钥签名,你拿平台证书验。搞反了的表现是下单一直返回 401,日志里只有一句 SIGN_ERROR。
| 名称 | 谁持有 | 干什么用 | 我们踩到的坑 |
|---|---|---|---|
| 商户私钥 apiclient_key.pem | 你,服务端 | 构造 Authorization 请求头签名 | 私钥被误提交进代码仓库,紧急轮换 |
| 商户 API 证书 apiclient_cert.pem | 你,服务端 | 声明商户身份与证书序列号 | 换证书忘了同步序列号,签名全被拒 |
| 证书序列号 Serial No | 请求头里带 | 告诉微信该用哪把公钥验你 | 多证书灰度部署时取到旧序列 |
| API v3 密钥 APIv3 Key | 你,服务端 | 解密回调通知的加密报文 | 和商户平台登录密码、商户号密钥混淆 |
| 平台证书 | 微信,需你定期下载 | 验回调与响应签名 | 轮换后本地没刷新,验签大面积失败 |
平台证书不是一次下载用一辈子,微信会做证书轮换(Certificate Rotation)。我们的做法是启动时拉一次,之后每小时调"下载平台证书列表"刷新,本地按序列号缓存多份,验签时用通知头里的 Wechatpay-Serial 去匹配。
只缓存单份、轮换期间不刷新,是验签突然大面积失败的头号原因。它还有滞后性:旧证书会继续生效一段时间,等真正过期才报错,那时没人会联想到几个月前那次下载。
JSAPI 下单:Authorization 头到底拼了什么
下单这一步要签两次名,新手常常漏掉第二次。服务端调 /v3/pay/transactions/jsapi 拿 prepay_id 是一次,把支付参数(Payment Parameters)交给 wx.requestPayment 唤起收银台又是一次,两次的待签名串格式完全不同。
服务端这次的待签名串由五行拼成,每行末尾都要带换行符,包括最后一行:
HTTP请求方法\nURL路径\n时间戳\n随机串\n请求报文主体\n
拼好之后用商户私钥做 RSA-SHA256 签名并 Base64,塞进 WECHATPAY2-SHA256-RSA2048 这个认证类型(Auth Type)的 Authorization 头里。下面是手工版本,方便对照文档排查;生产上更推荐直接用库。
// 依赖:SKIT.FlurlHttpClient.Wechat.TenpayV3(.NET 最常用的微信支付 v3 SDK)
// 也可以选官方维护的微信支付 .NET SDK,或旧项目里常见的 WxPaySDK(仅支持 v2,不建议新项目用)
// 这里的写法刻意不封装,目的是把待签名串的每一行都摊开给你看
using System.Security.Cryptography;
using System.Text;
// 商户号、证书序列号、商户私钥,建议全部走配置或密钥托管,不要硬编码
// 私钥文件是 PKCS#8 格式的 PEM,.NET 5+ 可以直接 ImportFromPem
string mchId = "1900000001";
string serialNo = "444F4864EAxxxxxxxx";
string privateKeyPem = File.ReadAllText("apiclient_key.pem");
// 构造一次 JSAPI 下单请求
// method 必须与实际请求方法一致,大小写敏感,必须是大写
string method = "POST";
// urlPath 只取路径部分,不包含域名,但要保留前导斜杠
string urlPath = "/v3/pay/transactions/jsapi";
// 时间戳单位是秒,不是毫秒,写错会直接 SIGN_ERROR
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// 随机串建议用 32 位,不要用短随机,碰撞概率不值得省这点事
string nonceStr = Guid.NewGuid().ToString("N");
// body 必须与真正发出去的请求体逐字节一致,序列化后再改一个空格都会验不过
string body = "{\"appid\":\"wx1234567890\",\"mchid\":\"1900000001\",\"description\":\"预约单\","
+ "\"out_trade_no\":\"YD20260921001\",\"notify_url\":\"https://api.example.com/pay/notify\","
+ "\"amount\":{\"total\":9900,\"currency\":\"CNY\"},"
+ "\"payer\":{\"openid\":\"oUpF8uMuAJO_M2pxb1Q9zNjWeS6o\"}}";
// 五段待签名串,注意末尾那个 "\n",漏掉它就是最常见的签名失败原因
string message = $"{method}\n{urlPath}\n{timestamp}\n{nonceStr}\n{body}\n";
// 用商户私钥做 SHA256withRSA 签名
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
byte[] signature = rsa.SignData(Encoding.UTF8.GetBytes(message),
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Authorization 头的固定格式,字段顺序在文档里是固定的
// serial_no 是商户 API 证书序列号,不是平台证书序列号
string authorization = "WECHATPAY2-SHA256-RSA2048 "
+ $"mchid=\"{mchId}\","
+ $"nonce_str=\"{nonceStr}\","
+ $"signature=\"{Convert.ToBase64String(signature)}\","
+ $"timestamp=\"{timestamp}\","
+ $"serial_no=\"{serialNo}\"";
// 实际发请求时还要带上 Accept、Content-Type、User-Agent 这几个头
// 返回体里的 prepay_id 就是下一步给小程序用的那张"票"
Console.WriteLine(authorization);
拿到 prepay_id 后要拼第二次签名,待签名串只有四行,package 固定为 prepay_id=xxx:
// 第二次签名:给小程序 wx.requestPayment 用的 paySign
// 待签名串 = 小程序 appId + 时间戳 + 随机串 + package,四行都以 \n 结尾
string appId = "wx1234567890";
string timeStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string nonceStr2 = Guid.NewGuid().ToString("N");
string package = "prepay_id=" + prepayId;
// 注意这里用的是 appId 而不是 mchid,写混了小程序端会报支付签名验证失败
string payMessage = $"{appId}\n{timeStamp}\n{nonceStr2}\n{package}\n";
// 同样用商户私钥签,结果做 Base64 得到 paySign
byte[] paySignBytes = rsa.SignData(Encoding.UTF8.GetBytes(payMessage),
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
string paySign = Convert.ToBase64String(paySignBytes);
// 返回给小程序的五个参数必须与签名时的入参完全一致
// 尤其是 timeStamp,必须和签名用的那个字符串是同一个,重新生成一次就对不上
var payParams = new { timeStamp, nonceStr = nonceStr2, package, signType = "RSA", paySign };
// 第二次签名失败的表现很隐蔽:服务端下单一切正常,小程序端却弹"支付签名验证失败"
// 绝大多数情况是 timeStamp 被重新生成了一遍,或者 package 多写了东西
回调通知:五秒超时决定了你必须先落库
微信对回调的超时要求很硬:五秒内没返回 2xx,它就算你失败。而且不会立刻重试,是按梯度来,间隔大致 15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟这样递增,累计约 15 次。
所以回调处理函数里绝不能放发短信、生成凭证、调第三方接口这类慢动作。第二笔丢单就是这么来的:回调里同步调了内部工单系统,那个接口平均 4.8 秒,偶发抖到 6 秒,微信判定失败开始重试。
完整时序见下图,重点看末尾两行。
sequenceDiagram
participant U as 小程序端
participant S as 业务服务端
participant W as 微信支付
U->>S: 提交预约单,创建待支付订单
S->>W: JSAPI 下单(携带 Authorization 签名)
W-->>S: 返回 prepay_id
S-->>U: 返回 timeStamp/nonceStr/package/paySign
U->>W: 唤起收银台,用户完成支付
W-->>U: 同步返回支付成功提示
W->>S: POST notify_url(加密报文 + 签名头)
S->>S: 验签 → AES 解密 → 原始报文落库
S-->>W: 200 {"code":"SUCCESS","message":"成功"}
Note over S,W: 未收到 2xx 或返回非 SUCCESS 时按梯度重试约 15 次
S->>S: 异步队列执行发货、发券、通知
U->>S: 前端轮询订单状态拿到最终结果
回调里只做三件事:验签、解密、原始报文原样落库并返回成功,其余丢进异步队列。这条规矩定下来之后,超时重试的问题当场消失。
回调验签与 AES-256-GCM 解密
回调头里有四个关键字段:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。验签待签名串是三行,末尾同样带换行:
时间戳\n随机串\n报文主体\n
# 依赖:pip install wechatpayv3 (Python 生态里用得最广的微信支付 v3 SDK)
# 也可以直接用 cryptography 自己实现,下面刻意写成半手工版方便排查
# 生产环境建议用官方 SDK 的验签装饰器,但内部逻辑值得你亲手走一遍
# 顺序是先验签再解密:验签过了才谈解密,别反过来
# 解密用 API v3 密钥做 AES-256-GCM(认证加密),GCM 自带完整性校验
# resource 里的 ciphertext、nonce、associated_data 三段一起参与,任一字节被动过都会抛异常
import base64
import json
import time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
# API v3 密钥,32 字节,在商户平台自己设置的那个,不是商户号的 API 密钥
API_V3_KEY = b"0123456789abcdef0123456789abcdef"
# 平台证书公钥,按 serial 从本地缓存里取,缓存每小时刷新一次
PLATFORM_CERTS = {
"5157F09EFDC096DE15EBE81A47057A72": open("platform_cert_5157.pem", "rb").read(),
}
# 验签:用平台证书公钥验 Wechatpay-Signature,防的是报文被中间人篡改
# 待签名串固定三行,顺序不能变,末尾必须有换行符
def verify_signature(headers, body: bytes) -> bool:
# 从响应头里取出时间戳、随机串、签名值和平台证书序列号
timestamp = headers.get("Wechatpay-Timestamp", "")
nonce = headers.get("Wechatpay-Nonce", "")
signature = headers.get("Wechatpay-Signature", "")
serial = headers.get("Wechatpay-Serial", "")
# 先用序列号找到对应那份平台证书,找不到说明本地缓存过期了
cert_pem = PLATFORM_CERTS.get(serial)
if not cert_pem:
# 这里不要直接吞掉,应该主动触发一次证书刷新再验一次
raise RuntimeError(f"平台证书缺失,serial={serial},请触发刷新")
# 加载公钥,注意是平台证书里的公钥,不是商户私钥
pubkey = serialization.load_pem_public_key(cert_pem)
# 拼三行待签名串,body 必须是收到的原始字节,不能先 json.loads 再 dumps
message = f"{timestamp}\n{nonce}\n".encode() + body + b"\n"
# 时间戳同时要做五分钟防重放校验,过期报文直接拒绝
if abs(time.time() - int(timestamp)) > 300:
return False
try:
# Base64 解签名后做 RSA 公钥验签,padding 用 PKCS1v15,摘要用 SHA256
pubkey.verify(base64.b64decode(signature), message,
padding.PKCS1v15(), hashes.SHA256())
return True
except InvalidSignature:
# 验不过就返回 False,由上层记日志并返回失败,让微信重试
return False
# 解密:AES-256-GCM,密钥就是 API v3 密钥,nonce 与 associated_data 都在报文里
def decrypt_resource(resource: dict) -> dict:
# ciphertext 是 Base64 编码的密文,密文里已经包含了 GCM 的认证标签
ciphertext = base64.b64decode(resource["ciphertext"])
# nonce 长度固定 12 字节,直接编码成字节即可
nonce = resource["nonce"].encode()
# associated_data 是附加认证数据,可以为空字符串,但不能不传
aad = resource["associated_data"].encode()
# 用 API v3 密钥构造 GCM 解密器
aesgcm = AESGCM(API_V3_KEY)
# 任一字节被篡改,这里都会抛 InvalidTag,等于免费做了一次完整性校验
plain = aesgcm.decrypt(nonce, ciphertext, aad)
# 明文是 UTF-8 JSON,解析后就是订单信息
return json.loads(plain.decode("utf-8"))
第一笔丢单就栽在这里:一段很早写的 try...except 把验签异常 pass 掉了,日志级别还是 Debug,线上根本看不到。证书轮换期间那批通知全部验签失败,被静默丢弃,最后靠对账单才发现。
验签失败不要静默吞异常,必须打 Error 日志并带上 Wechatpay-Serial,这个字段能让你三秒内判断是不是证书缓存问题。
至少一次投递(at-least-once)的原理,以及为什么必须幂等
微信支付的通知语义是至少一次投递(At-Least-Once Delivery),不是恰好一次(Exactly-Once),意思是同一条通知可能被送达一次、两次甚至十几次。原因很简单:微信无法区分"你没收到"和"你收到了但处理失败",它只看有没有拿到约定格式的成功响应。
由此有两个推论。推论一,重复通知是常态而非异常,网络抖动、服务重启、响应慢半秒都会触发重试,任何"收到回调就无条件 +1"的代码都是错的。
推论二,幂等(Idempotency)的正确性不能依赖"先查一遍有没有处理过",必须依赖数据库的唯一性约束。两条通知同时进来,两个事务都会查到"没处理过",然后一起往下走。
第三笔丢单就是这个形态的变体。代码判断当前是"已支付"就跳过,看着挺幂等。但异步发货把状态临时改成了"发货中",第二条通知进来一看不等于"已支付",于是重走发货流程,名额发重了,人工回滚一笔后账上就少了一笔。
验签防篡改的原理是对称的另一半:微信用平台私钥对"时间戳 + 随机串 + 报文主体"签名,你用平台证书公钥验。中间人改了报文签名必然对不上,他没有微信私钥也伪造不出新签名。时间戳与随机串进签名串是为了防重放(Replay Attack),旧通知反复发会在时间窗被拒。
幂等落地:唯一索引加状态机
做法是两层防护。第一层靠数据库:out_trade_no(商户订单号)加唯一索引,或者单独建一张支付流水表,以 transaction_id(微信支付订单号)做唯一性约束,重复插入由数据库报错拦住。
第二层靠状态机(State Machine):状态只允许沿预设边迁移,非法迁移直接拒绝并记日志。
# 幂等落库:唯一性约束 + 状态机双保险
# 依赖:SQLAlchemy 2.x,MySQL 8(InnoDB,事务隔离级别 READ COMMITTED)
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
# 允许的状态迁移,键是当前状态,值是允许迁移到的目标状态
# 这张表就是状态机的全部定义,改状态前必须先改这里
ALLOWED_TRANSITIONS = {
"PENDING": {"PAYING"}, # 待支付只能进入支付中
"PAYING": {"PAID", "CLOSED"}, # 支付中可以变已支付或已关闭
"PAID": {"DELIVERING"}, # 已支付才能发货
"DELIVERING": {"DELIVERED"}, # 发货中只能到已发货,不能退回
"DELIVERED": set(), # 终态,不再迁移
"CLOSED": set(), # 终态
}
# 处理一条已验签并解密的回调报文,整个函数在事务里执行
def handle_notify(session, notify: dict) -> str:
# 取出商户订单号与微信支付订单号,这两个是幂等的关键字
out_trade_no = notify["out_trade_no"]
transaction_id = notify["transaction_id"]
# 先尝试插入支付流水表,transaction_id 上有唯一索引
# 插入成功说明是第一次见到这条通知,插入失败说明已经处理过
try:
session.execute(
"INSERT INTO pay_flow (transaction_id, out_trade_no, raw) "
"VALUES (:tid, :ono, :raw)",
{"tid": transaction_id, "ono": out_trade_no, "raw": json.dumps(notify)},
)
# 立即 flush,让唯一索引冲突在此处就抛出来,而不是等 commit
session.flush()
except IntegrityError:
# 唯一索引冲突,说明这条通知已经处理过,直接返回成功
session.rollback()
return "DUPLICATE"
# 第一次处理,开始走状态机
order = session.execute(
select(Order).where(Order.out_trade_no == out_trade_no).with_for_update()
).scalar_one()
# 目标状态由回调里的 trade_state 映射而来
target = "PAID" if notify["trade_state"] == "SUCCESS" else "CLOSED"
# 检查当前状态到目标状态是否是允许的迁移
# 关键:DELIVERING 到 PAID 不在表里,所以重复通知不会把发货中的单打回去
if target not in ALLOWED_TRANSITIONS.get(order.status, set()):
# 非法迁移,记日志后返回成功,避免微信无意义重试
logger.warning("非法状态迁移 %s -> %s,订单 %s", order.status, target, out_trade_no)
return "SKIP"
# 合法迁移,更新状态并提交事务
order.status = target
order.transaction_id = transaction_id
order.paid_at = datetime.now()
session.commit()
# 提交成功后才投递异步消息,由队列去发货、发券、通知用户
# 这一步放在事务外,避免队列抖动把事务拖超时
queue.publish("order.paid", {"out_trade_no": out_trade_no})
return "OK"
# 两个易忽略的细节:flush() 让唯一索引冲突提前抛出,别等 commit 才炸
# 非法迁移同样要返回成功,让微信继续重试没有意义,只会制造重试风暴
状态机的关键是:终态之前的所有中间态,都不允许被重复通知重新触发业务动作。
主动查单兜底:别把身家性命全押在回调上
回调再稳也只是"通知",不能作为支付结果的权威来源。兜底分两层:实时补偿(Compensation)与 T+1 对账。
实时补偿就是订单进入支付中之后,后台任务按梯度主动调 /v3/pay/transactions/out-trade-no/{out_trade_no} 查单接口(Payment Query)问微信这笔到底成没成。查单是主动拉取,不存在投递语义问题。
flowchart TD
A[补偿任务 每 30 秒扫一次] --> B{订单状态为支付中<br/>且已等待超过 90 秒}
B -- 否 --> A
B -- 是 --> C[调用查单接口 主动问微信]
C --> D{trade_state 是什么}
D -- SUCCESS --> E[走同一套幂等逻辑置为已支付]
D -- USERPAYING --> F[继续等 累计上限 30 分钟后关闭]
D -- NOTPAY --> F
D -- CLOSED 或 PAYERROR --> G[关闭本地订单并释放名额]
F --> A
E --> H[T+1 拉取日对账单 tradebill]
G --> H
H --> I{微信侧有 本地没有的 SUCCESS}
I -- 存在差额 --> J[按 out_trade_no 补单并告警]
I -- 一致 --> K[对账通过 归档]
补偿时机要讲梯度。用户刚跳到收银台的前 30 秒不要查,那是正常支付耗时,查了也是 USERPAYING,白白消耗接口配额。我们的节奏是 90 秒、3 分钟、10 分钟、30 分钟各一次,之后仍未支付就关单。
// 依赖:npm i wechatpay-node-v3 (Node.js 侧常用的 v3 SDK)
// 也可以用官方的 wechatpay-axios-plugin,两者都支持自动下载并缓存平台证书
const fs = require('fs');
const WxPay = require('wechatpay-node-v3');
// 初始化客户端,私钥与证书走文件路径,不要把 PEM 内容写进代码
const pay = new WxPay({
appid: 'wx1234567890',
mchid: '1900000001',
// 商户 API 证书序列号
serial_no: '444F4864EAxxxxxxxx',
// 商户私钥文件
privateKey: fs.readFileSync('./cert/apiclient_key.pem'),
// 商户证书文件
publicCert: fs.readFileSync('./cert/apiclient_cert.pem'),
// API v3 密钥,用于解密回调报文
key: '0123456789abcdef0123456789abcdef',
});
// 主动查单:用商户订单号问微信这笔的真实状态
// 注意要带 mchid 查询参数,且必须做 URL 编码
async function queryOrder(outTradeNo) {
// 拼查询串,mchid 是必填参数
const url = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=1900000001`;
// 发 GET 请求,SDK 内部会完成签名与响应验签
const resp = await pay.request({ method: 'GET', url });
// trade_state 取值:SUCCESS / NOTPAY / USERPAYING / CLOSED / REFUND 等
return resp;
}
// 补偿任务的调度节奏,单位是毫秒,刻意做成梯度而不是固定间隔
// 前 30 秒不查,等用户正常支付;之后逐步拉长间隔
const RETRY_LADDER = [90_000, 180_000, 600_000, 1_800_000];
// 扫描待补偿订单,按等待时长决定查到第几档
async function compensate(pendingOrders) {
for (const order of pendingOrders) {
// 计算订单已经等待了多久
const waited = Date.now() - order.createdAt;
// 找出当前等待时长对应应该查的档位
const step = RETRY_LADDER.findIndex((t) => waited >= t);
// 还没到第一档就跳过,避免无谓的接口调用
if (step < 0 || order.queryCount > step) continue;
// 调查单接口拿真实状态
const r = await queryOrder(order.outTradeNo);
// 成功则走与回调完全相同的幂等处理函数,保证两条路径行为一致
if (r.trade_state === 'SUCCESS') {
await handleNotifySameAsCallback(r);
} else if (['CLOSED', 'PAYERROR', 'REVOKED'].includes(r.trade_state)) {
// 明确失败或已关闭,关掉本地订单释放预约名额
await closeOrder(order.outTradeNo);
}
// 无论结果如何都累加查询次数,防止同一档位被反复执行
order.queryCount += 1;
}
}
// 一条容易忽略的纪律:补偿路径和回调路径必须调用同一个处理函数
// 我们早期把查单结果另写了一套更新逻辑,两边判定差一个字段
// 结果后半夜补偿把订单改成已支付,发货队列却没触发
日对账单:tradebill 该怎么核
实时补偿解决分钟级延迟,日对账单(Trade Bill)解决"压根没进系统"的极端情况,比如回调全挂。
流程不复杂:第二天上午调 /v3/bill/tradebill 申请账单,传 bill_date 与 bill_type=ALL,拿 download_url 下载 gzip 包,解压出 CSV 后逐行与本地订单比对。
比对要双向做:微信有、本地没有,按 out_trade_no 补单;本地有、微信没有,多半是脏数据或测试单,人工确认。金额也要比,只看笔数会漏掉金额被改过的异常单。
还有个坑:账单金额单位是元,接口里的 total 是分。第一次写脚本时没注意,跑出来差一百倍,白紧张半小时。
丢失原因与修复方式对照
三笔丢单加上排查中发现的隐患整理成下表,每条都对应文中某一节。
| 现象 | 根因 | 修复方式 | 验证方法 |
|---|---|---|---|
| 账上有钱、订单未支付 | 验签异常被 try/except 静默吞掉 | 异常改 Error 日志并带上证书序列号 | 造一条错误签名的通知,看是否告警 |
| 回调反复重试、连接池打满 | 回调内同步调用耗时第三方接口 | 只验签解密落库,其余丢异步队列 | 压测回调接口,P99 降到 50ms 以内 |
| 预约名额重复发放 | 只按"是否已支付"判重,漏了中间态 | 状态机 + 唯一索引双重幂等 | 同一条通知连发十次,检查只发货一次 |
| 验签突然大面积失败 | 平台证书轮换后本地缓存未刷新 | 按序列号缓存多份,每小时刷新 | 切换证书序列号,看是否自动恢复 |
| 漏单无人知晓 | 没有 T+1 对账任务 | 加日对账单比对与差额告警 | 手工插一笔假已支付单,看是否告警 |
修完之后固化成四条纪律贴在 README 上:证书按序列号缓存并定时刷新;回调只做落库;状态变更走唯一索引加状态机;支付结果以微信侧为准,本地只是缓存。
参考与延伸
- 微信支付 API v3 签名生成与验签规则:https://pay.weixin.qq.com/docs/merchant/development/interface-rules/signature-generation.html
- 支付通知 API(回调验签与解密):https://pay.weixin.qq.com/docs/merchant/apis/jsapi-payment/payment-notice.html
- JSAPI 下单接口:https://pay.weixin.qq.com/docs/merchant/apis/jsapi-payment/direct-jsapi/jsapi-prepay.html
- 申请交易账单 tradebill:https://pay.weixin.qq.com/docs/merchant/apis/bill-download/download-trade-bill.html
关键词:微信小程序支付、微信支付 API v3、JSAPI 下单、回调验签、幂等设计、支付状态机、日对账单