一、背景:虚拟支付的全链路,以及它最容易挂在哪

背景一句话:我在自己的内容站点上做了个小程序商城,用户付费解锁内容包(文档/脚本类数字商品)。个人主体小程序走的是 2026 年 8 月新政下的微信虚拟支付——不用企业认证、不用商户号,但有月收款 10 万的限额。整条链路长这样:

小程序前端 wx.requestVirtualPayment
        ↓
后端「双签名下单」→ 调微信 xpay 下单接口
        ↓
用户支付成功
        ↓
微信 → 发货推送(HTTP 回调,XML 报文)→ 后端验签 + 解密 + 幂等记账
        ↓
「已购内容」页取解锁 → 返回对象存储(COS)上的商品包下载链接

代码部分(下单、验签、解密、幂等、查单兜底)此前已经全部写好,本地签名自检也全过。真正挂掉的全是「上线最后一公里」,而且三个故障(502、校验失败、拒付风险)看起来互不相干,实际各有一根独立的刺。虚拟支付系统里,代码通常不是最脆弱的那一环——部署、网关、与平台的对齐才是。

本文结论

四个坑:① 进程管理指向 /tmp 易失目录 → 重启即丢代码 → 回调 502;② 线上跑旧版、新代码 untracked 没部署(部署漂移);③ nginx 反代强制 JSON 请求头 + CDATA 未剥离 + 验签公式明文/安全模式差异,三连坑让消息推送永远校验失败;④ 后台道具 ID 与代码商品 ID 不一致 → 微信拒付。

二、502 的根因:PM2 指向 /tmp,重启即丢

症状:微信后台「消息推送」配的回调地址 https://xxx.im/api/pay/notify 直接 502 Bad Gateway。上云主机一看 PM2:有个支付进程状态 errored,已经重启 19 次。

pm2 describe likun-pay
  script path   /tmp/likun-pay/server.js   ← 致命点
  status        errored, restarts 19
MODULE_NOT_FOUND   ← /tmp 目录已被系统清理

根因很朴素:当初图省事把后端代码扔在 /tmp 里。Linux 的 /tmp 是易失目录,重启或定时清理就把代码目录整个清掉,PM2 守护的进程反复拉起、反复 MODULE_NOT_FOUND。nginx 反代到它的 3000 端口,自然全是 502。而微信发货推送就指向这个 502——支付成功后发货通知根本到不了后端,等于用户付了钱、系统发不了货。

修复动作(全部在生产机执行):

# 1. 代码迁到持久目录
mkdir -p /www/likun-pay && tar xzf likun-pay-src.tgz -C /www/likun-pay
# 2. 补硬依赖:这台机原来没有 MySQL,装 MariaDB + 建库建用户 + 导入 schema
dnf install -y mariadb-server mariadb
mariadb -uroot < schema.sql
# 3. 重写生产 .env(数据库密码 + 4 个虚拟支付凭证,chmod 600),指向持久目录
pm2 delete likun-pay
pm2 start server.js --name likun-pay --cwd /www/likun-pay
pm2 save   # 重启后自动拉起

顺手暴露出第二个隐患(下一节):/health 是 200,但 /api/xpay/* 全 404——线上跑的根本没有这套新路由。

三、对齐的坑:部署漂移 + 道具 ID 错位

3.1 部署漂移:线上旧版、本地未提交

探测线上接口发现 /api/xpay/notify 返回 404,但 /health 正常——说明占着域名的是旧版后端。回本地仓库一看:整套虚拟支付代码(路由/签名/商品/建表)全部处于 untracked 状态——没提交、没部署,只存在于开发机磁盘上。代码写得再对,没进版本库、没上服务器,就等于不存在。这是「部署漂移」最典型的一种:开发机与线上两个真相源各说各话。

3.2 道具 ID 错位:后台是权威,代码必须跟它对齐

微信虚拟支付里,前端 requestVirtualPayment 和后端下单接口传的商品 ID,必须与小程序后台「道具管理」里创建的道具 ID 一字不差,价格也要一致(单位分)。我的代码里写的是 codex-cli-pack-v1,后台实际发布的道具 ID 是 ai_tools_v1——一字之差,微信直接拒付。

容易踩的连带坑:商品 ID ≠ 存储路径

对象存储里商品包的 Key 是 products/codex-cli-pack-v1/package.zip——这是存储键,和商品 ID 是两回事。对齐时只改商品 ID(5 处引用:商品目录、路由注释、网站桥接组件 3 处默认值),存储路径一个字不能动,动了就是 404。

后台是权威,代码是它的影子。ID 对不齐,前面所有链路都是白搭。

四、消息推送三连环:nginx 头覆盖、CDATA、验签公式

修完 502 后,去后台点「提交」做回调 URL 校验,仍然失败。这一节是全文最值钱的三个坑。

4.1 坑一:nginx 反代里藏着一行「强制 JSON」

模拟一笔发货推送到线上,后端直接 500 not valid JSON。翻 nginx 配置,在 location /api/pay/ 里找到真凶:

# 这行会把任何请求(包括微信的 XML 推送)的 Content-Type 都改成 JSON
proxy_set_header Content-Type 'application/json; charset=utf-8';

后果:微信发来的 XML 报文被强制贴上 JSON 的 Content-Type,后端按 JSON 解析 → 炸 500。删掉这行(备份后改配置 + reload),XML 才能原样到达后端。

4.2 坑二:CDATA 包裹没剥离,验签必 -1

加密模式下推送报文的形态是 <Encrypt><![CDATA[base64 密文]]></Encrypt>。我原来的正则 /<Encrypt>([\s\S]*?)<\/Encrypt>/ 会把整段 CDATA 标记一起捕获,拿它去算签名、去 base64 解码,双双失败。正确做法是先剥壳:

function parseEncrypt(xml) {
  if (!xml) return '';
  let m = xml.match(/<Encrypt>([\s\S]*?)<\/Encrypt>/);
  if (!m) return '';
  let v = m[1].trim();
  // 剥离 CDATA 包裹(微信固定带 CDATA,兼容裸 base64)
  let c = v.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/);
  return c ? c[1].trim() : v;
}

4.3 坑三(最隐蔽):验签公式,明文 3 项 / 安全 4 项

前两个坑修完,点「提交」还是失败。微信后台配的是明文模式,而我本地的验签测试全绿。为什么?

因为本地自测时,我构造请求用的签名公式和后端验签用的公式是同一套——都是 4 项 sha1(排序(token, timestamp, nonce, 消息体)),自洽当然全过。但微信明文模式的 signature 只算 3 项(不含消息体/echostr),只有安全模式的 msg_signature 才把加密内容算成第 4 项:

// 明文模式(signature):3 项,不含消息体
sha1(sort([token, timestamp, nonce]).join(''))
// 安全/兼容模式(msg_signature):4 项
sha1(sort([token, timestamp, nonce, encrypt]).join(''))

// 修复:encrypt 为空串时自动退化成 3 项
function calcMsgSignature(token, timestamp, nonce, encrypt) {
  const parts = [token, timestamp, nonce];
  if (encrypt) parts.push(encrypt);
  return sha1(parts.sort().join(''));
}

定位的关键动作:从线上访问日志里抄出微信真实发来的那笔请求参数,本地拿同一组参数反算签名,和微信给的 signature 对比——3 项公式立刻对上了。参数名本身就是模式信号:明文带 signature、安全带 msg_signature,两个都读、任一匹配即通过,后台配哪种模式都能接。

修完后按「明文发货 / 安全模式发货 / 伪造签名」三种推送各打一笔线上:

场景结果说明
明文模式发货(后台当前配置)ErrCode 0验签通过,走入账分支
安全模式发货(带 Encrypt)ErrCode 0验签 + AES 解密双通过
伪造推送(乱签名)ErrCode -1正确拒绝

五、定位方法论:别拿「本地自测通过」当结论

这整天的排障里,最该记住的不是某个具体修复,而是定位手法:

A本地自测是「自洽」,不是「正确」

构造请求和验证请求用的是同一套(可能错)的公式,本地当然全绿。凡是和外部平台交互的签名/加密/协议,必须拿平台真实发来的请求做标定,而不是拿自己的模拟请求。

B读线上日志,抄真实参数反算

网关访问日志里保留了微信校验请求的完整 query(signature/timestamp/nonce/echostr)。把这组参数本地反算签名并与微信给的对比,公式差异一眼现形——这一步省掉了至少半天的猜测。

C参数名是模式信号

微信同一类回调在不同加密模式下,参数名不同(signature vs msg_signature)。健壮的做法是两个都读、任一匹配即放行,后台改配置模式也不影响线上。

六、上线前自检清单(照抄可用)

把这次的教训固化成一份可重复执行的 checklist,以后任何「微信回调类」上线前过一遍:

  • 部署目录持久化:后端代码不放在 /tmp、/var/tmp 等易失目录;pm2 describe 里 script path 必须指向持久路径,且 pm2 save 已执行
  • 无部署漂移:线上可访问 POST /notify(GET 应返回 400「missing echostr」而非 502/404);线上代码版本 = 本地已提交版本(git log 对得上)
  • 网关不污染请求头:检查 nginx/反代的 proxy_set_header,不要强制改写 Content-Type;XML 回调必须原样透传
  • 推送 URL 可达:浏览器/ curl 直接访问回调 URL,确认 200/400 后端接管(502 = 后端没跑,404 = 路由不存在,返回 SPA 页面 = 反代规则没匹配上)
  • 道具 ID / 价格 1:1:后台「道具管理」的 ID 与价格(分)= 代码商品目录;存储路径是独立概念,不要跟着 ID 改
  • 验签双模式标定:用平台真实请求参数反算 signature(明文 3 项 / 安全 4 项);再用模拟推送跑三场景(明文 0 / 安全 0 / 伪造 -1)
  • 幂等 + 兜底:发货通知按平台订单号去重;另配查单接口兜底推送丢失的场景
  • 限流与合规知悉:个人主体虚拟支付月限额 10 万;费率 Android 1% / iOS 12%;结算 T+3

七、留一句话

这次的 502、校验失败、拒付风险,没有一个是「代码写错了」——代码每一处都是对的。挂掉的全是代码之外:一个图省事丢在 /tmp 的目录、一行没人认领的 nginx 请求头覆盖、一个没和后台对齐的 ID、一套没被真实请求标定过的验签公式。跟外部平台打交道的系统,「本地自测全绿」永远只是及格线;拿平台发来的真实请求做标定,才是发布前最后一道、也是最关键的一道验收。工具可以修,标定习惯要写进流程才作数。

返回随笔