小程序支付回调丢了三笔单:微信支付 V3 签名、验签与对账的实战笔记

2026-09-21 01:35:46 0 次浏览
微信小程序微信支付微信支付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/jsapiprepay_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-TimestampWechatpay-NonceWechatpay-SignatureWechatpay-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_datebill_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 下单、回调验签、幂等设计、支付状态机、日对账单

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。