ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

支付回调验签与订单查询的四个坑排查实录

支付回调验签与订单查询的四个坑排查实录 一、现象收款成功了订单却还挂着未支付先交代来源这个问题是我在做一款本地化部署的微信自动回复工具时踩到的工具按坐席收年费需要一个能用的在线收款通道于是接了一家聚合支付的网关。整体流程是教科书式的服务端创建订单拿网关返回的二维码展示给客户客户扫码付款网关异步回调我们的notify_url我们验签、更新订单、发货。逻辑简单但真跑起来之后四个意料之外的坑接连冒了出来每一个单看都不难叠在生产环境里都足以造成资损或者客诉。第一坑的表象是客户明确付了款也确实在我们对账后台看到了入账但我们系统里的订单状态纹丝不动还是待支付发货动作迟迟不触发。第二坑的表象是回调验签通过了按文档主动查询订单补单时网关却返回Order is not found而订单明明是今天刚创建的。第三坑的表象是一部分订单查询永远查不到CreateAt 一算才发现创建时间已经过去了两天多。第四坑是自找的压测轮询接口时把对方的查询接口打出了限流二维码还没过期查询配额先烧完了。这篇文章把四个坑逐一拆开现象、排查过程、根因原理、修复代码最后给一份可以直接对照自查的清单。代码全部是 Node.js/Express 风格签名算法是这家网关用的 MD5-ASCII-排序方案也是国内支付文档里最常见的套路思路可以直接平移到 SHA1/SHA256 变体上。二、第一坑验签失败的真正原因是排序和拼接不是密钥2.1 现象与第一轮排查回调处理上线当天测试环境全绿用 Postman 模拟网关回调验签通过订单状态正常流转。切到生产之后验签成功率却掉到了一个很低的水平——网关的重试机制会不断补发通知我们的日志里全是验签失败的记录而成功的那几笔看起来毫无规律。第一直觉是密钥配错了。把商户后台的密钥、配置文件里的密钥、发起支付请求时用的密钥三方比对完全一致。密钥没问题时验签失败只剩一种可能我们这边算出来的签名和网关带过来的签名参与计算的内容不一样。也就是说问题出在签名串的构造这一步。先把这家网关文档里的签名规则抄下来这也是绝大多数 MD5 签名网关的通用规则把所有请求参数不含sign本身按参数名的 ASCII 码从小到大排序排序后按keyvalue用拼接得到待签名串末尾不拼key直接拼接商户密钥或按文档拼在末尾带keyxxx以网关文档为准对拼接串做 MD5转大写十六进制得到sign验签时用收到的参数重新算一遍与收到的sign做比较。规则很清楚坑全在规则的执行细节里。2.2 拼出来的待签名串对不上我在验签失败时把待签名串打了出来同时用网关后台提供的签名校验工具算了一份标准答案两相对比我们拼的amount10.00mch_id20250923001notify_urlhttps://a.b/notifyout_trade_no...status1time1727068800 标准答案amount10.00mch_id20250923001out_trade_no...status1time1727068800差异一眼可见我们的串里多了notify_url和time两个参数。多出来的来源有两种典型情况我这两种都中了。第一种是参与签名的字段范围理解错了。文档写的是所有参数参与签名但很多网关会在回调报文里附加一些网关自己加的字段比如notify_url、time、nonce这些字段不参与签名。判断标准只有一个看网关文档里回调报文的字段说明表逐个字段核对是否参与签名这一列。没有文档说明兜底时就用穷举法把收到的字段做全集然后按组合逐个排除尝试——字段数量不多时组合数是可控的。第二种是空值参数的处理。MD5 签名的通用惯例是值为空的参数不参与签名。我们有一个可选参数attach商户附加数据下单时没传网关回调时把它原样带回来了一个空字符串我们老老实实把它拼进了待签名串网关那边却把它排除了。这一个字符的差异MD5 就是两个完全不同的值。2.3 修复后的验签代码把三件事做对ASCII 排序、按文档圈定参与签名的字段并排除空值、比对时防时序攻击。修复后的验签函数如下constcryptorequire(crypto);constexpressrequire(express);constappexpress();// 网关回调一般用 application/x-www-form-urlencodedapp.use(express.urlencoded({extended:false}));constMCH_KEYprocess.env.PAY_MCH_KEY;// 密钥只走环境变量不进代码库// 文档明确不参与签名的字段硬编码白名单外一律以文档字段表为准constEXCLUDE_KEYSnewSet([sign,sign_type]);functionbuildSignStr(params){returnObject.keys(params).filter((k){if(EXCLUDE_KEYS.has(k))returnfalse;// 签名本身不参与constvparams[k];if(vundefined||vnull||v)returnfalse;// 空值排除returntrue;}).sort()// Array.prototype.sort 默认按 UTF-16 码元排序// 对纯 ASCII 参数名等价于 ASCII 升序含非 ASCII 键时需用 localeCompare 定制.map((k)${k}${params[k]}).join();}functionverifySign(params){constreceivedString(params.sign||).toUpperCase();constsignStrbuildSignStr(params)keyMCH_KEY;constexpectedcrypto.createHash(md5).update(signStr,utf8).digest(hex).toUpperCase();// 防时序攻击用 timingSafeEqual 而不是 constaBuffer.from(expected);constbBuffer.from(received);if(a.length!b.length)returnfalse;returncrypto.timingSafeEqual(a,b);}app.post(/pay/notify,(req,res){constparamsreq.body;if(!verifySign(params)){// 注意验签失败也要按网关文档返回失败应答否则网关会按重试策略疯狂补发returnres.send({code:FAIL,message:verify sign error});}// 验签通过后的业务处理见第三节res.send({code:SUCCESS});});app.listen(3000);这里有两个容易忽略的工程细节。一是sort()的排序语义JavaScript 的默认排序按字符串码元比较对纯 ASCII 的参数名支付网关的字段名基本都是小写字母加下划线正好等价于 ASCII 升序但如果字段名里混入了大写或特殊符号_0x5F和大写字母0x41-0x5A的先后关系会跟直觉不一样稳妥做法是显式用(a, b) (a b ? -1 : a b ? 1 : 0)的码元比较并保证和网关侧算法一致。二是验签失败的应答体不同网关对收到但验签失败和根本没收到的处理策略不同有的会无限重试所以失败应答也要严格按文档格式返回避免回调风暴把日志打爆。2.4 延伸为什么必须排序以及 MD5 还能不能用最后把第一坑背后的原理层补全回答两个常被追问的问题。第一个问题为什么签名一定要按 ASCII 排序根源在于 HTTP 报文里的字段本身没有顺序承诺。表单编码也好、JSON 也好规范都不保证键序稳定同一个请求换一种序列化方式字段的物理顺序就变了。而签名要求双方对同一份内容算出同一个摘要这就必须先约定一种规范化canonicalization规则把无序的键值对折叠成唯一确定的字符串。ASCII 升序是所有规范化方案里实现成本最低、跨语言歧义最小的一种——任何语言里一行 sort 都能得出完全一致的结果。理解了这一层就会明白排序规则不是网关的怪癖而是签名协议成立的前提条件同理“空值是否参与”字段名大小写是否敏感这些边角规则本质上都是在补齐规范化的定义缺了任何一条两边的规范化结果就可能不同验签就会随机失败。第二个问题MD5 都被碰撞攻击打穿了为什么还在用MD5 的抗碰撞性确实早已被打破但本方案的用法是密钥拼接后做摘要安全目标是防伪造而不是防碰撞截至目前没有公开的、能在真实网关场景下稳定伪造有效签名的通用攻击。话虽如此新接入的网关若提供 SHA256 或 HMAC 结构的变体应当优先选择被迫使用 MD5 老协议时靠两道额外防线兜底一是验签通过后再校验关键业务字段——金额、订单号、商户号——与本地订单库是否一致防止签名合法但内容被构造的极端情况二是把回调来源 IP 限制在网关文档公布的网段白名单内让攻击必须先过网络层这一关。签名是第一道闸但它从来不该是唯一一道。三、第二坑查询接口返回Order is not found其实是参数名拼错了3.1 一个误导性极强的报错验签修好之后回调链路通了。但支付回调不是万能保险——网关重试有次数上限我们自己的服务也可能在回调到达那一刻正在重启。所以任何接支付的团队都会做第二条腿主动查询兜底。对本地状态还是待支付、又过了合理时间的订单调网关的订单查询接口用真实支付状态修正本地状态。实现很简单几十行代码。结果一跑就撞上了新的报错{code:ORDER_NOT_EXIST,message:Order is not found}订单明明是刚创建的二维码刚展示出去客户端也能拉起支付。第一反应是订单号传错了反复核对后发现订单号完全正确。第二反应是是不是要等网关侧落库加了延迟重试照样报错。这个报错文本极具误导性——它让你拼命怀疑订单本身而真正的问题出在请求参数名上。3.2 根因out_trade_no 还是 out_trade_order把请求报文原样打出来对照文档逐字段看问题找到了。文档的查询接口参数表里商户订单号的字段名是out_trade_no而我们实现时因为内部数据库的表字段叫out_trade_order建表时照着回调报文里的某个字段名抄的抄串了查询请求里也就跟着发了一个out_trade_order过去。两个字段名只差最后两个字母。网关收到一个它不认识的参数不报参数错误而是把请求当成没有传商户订单号来处理——按照它的参数解析逻辑out_trade_no缺省于是走到按订单号查库、查不到的分支返回了Order is not found。这个坑的教训不在于粗心而在于三个系统性问题网关对未知参数静默忽略。很多 HTTP 接口对多余参数不校验out_trade_order发过去就像没发过一样。如果网关对未知参数直接拒绝400 Bad Request这个 bug 会在第一分钟暴露静默容忍反而把问题藏到了业务语义层报错文本还指错了方向。报错语义与真实原因错位。“Order is not found” 描述的是业务结果掩盖的是参数错误。遇到这类报错时正确动作是先把请求报文和文档参数表逐字段 diff而不是顺着报错去查订单系统。内部字段名与外部协议字段名交叉污染。我们建表时用了回调报文的字段名风格写查询代码时又想当然地以为内部名等于外部名。正确的做法是凡是跨出进程边界的字段名HTTP 请求体、回调报文必须以网关文档为准单独维护一份映射绝不从数据库字段名推理。3.3 修复与防复发修复本身是改一个词的事防复发靠的是把外部协议字段收敛到一个常量模块里并给查询接口包一层统一的错误归因// pay-protocol.js —— 网关协议字段唯一出口与网关文档逐字对齐module.exports{// 网关文档《订单查询接口》参数表原文out_trade_no商户订单号QUERY_ORDER:/api/v1/order/query,FIELD_OUT_TRADE_NO:out_trade_no,// 注意不是 out_trade_orderFIELD_MCH_ID:mch_id,};// pay-query.js —— 主动查询兜底const{QUERY_ORDER,FIELD_OUT_TRADE_NO,FIELD_MCH_ID}require(./pay-protocol);asyncfunctionqueryGatewayOrder(outTradeNo){constparams{[FIELD_MCH_ID]:process.env.PAY_MCH_ID,[FIELD_OUT_TRADE_NO]:outTradeNo,time:Math.floor(Date.now()/1000),};params.signmd5Sign(params,process.env.PAY_MCH_KEY);constrespawaitfetch(QUERY_ORDER,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify(params),});constdataawaitresp.json();if(data.codeORDER_NOT_EXIST){// 关键先做参数自检再相信订单不存在这个语义constsentKeysObject.keys(params).filter((k)k!sign);constrequiredKeys[FIELD_MCH_ID,FIELD_OUT_TRADE_NO,time,sign];constmissingrequiredKeys.filter((k)!(kinparams)||params[k]);if(missing.length0){thrownewError(查询参数缺失怀疑字段名错误: missing${missing}sent${sentKeys});}}returndata;}这段代码里最重要的一行是missing自检把参数名拼错这种低级但高发的错误从网关报错后人工排查前移到代码自动归因。从那以后我们所有的网关接口调用都走同一个封装字段名全部走pay-protocol.js常量新接一个接口的成本从通读文档降到抄字段表。四、第三坑过期订单被网关清理了——WP 和 OD 两个状态码的含义4.1 查不到的订单有共同特征补单逻辑上线后又出现一类查询失败同样是Order is not found但这次参数名核对过无数遍确实没错。把查不到的订单拉出来看发现了共同特征全部是创建后超过 48 小时仍然未支付的订单。对照网关文档的订单状态说明找到了答案。这家网关对未支付订单有一个自动清理机制状态码含义是否可查询是否可继续支付WPWait Pay待支付可查询可ODOver Due已过期一定期限内可查否需重新下单CLClosed已关闭并被清理不可查询返回订单不存在否SUSuccess支付成功可查询否文档里写得明明白白未支付订单超过有效期我们接入的这档产品是 48 小时不同网关不同产品线差异很大必须按自己的文档确认后状态先转为过期OD过期订单在网关侧保留一段时间后会被物理清理清理之后再查询返回的就是Order is not found——和参数名拼错的报错一模一样。这就是最迷惑的地方同一个报错文本对应两种完全不同的成因。参数名拼错是开发期问题订单被清理是运行期问题处理动作完全不同前者改代码后者改流程。4.2 对补单逻辑的连锁影响这个发现直接否定了我们补单逻辑的一个隐含假设只要查网关总能告诉我们订单的最终状态。实际上网关对超期未付订单的记忆是有限的。对这类订单补单的正确姿势不是死磕查询接口而是接受现实订单已死让用户重新下单。具体到状态机设计补单流程应该是这样constPAY_RESULT_MAP{SU:PAID,WP:PENDING,OD:EXPIRED,CL:EXPIRED,};asyncfunctionreconcileOrder(order){constdataawaitqueryGatewayOrder(order.out_trade_no);if(data.codeORDER_NOT_EXIST){// 两种成因在此分流创建时间很短 → 参数问题告警创建时间很久 → 视为过期constageHours(Date.now()-order.createdAt)/3600_000;if(ageHoursGATEWAY_ORDER_TTL_HOURS){awaitmarkOrder(order.id,EXPIRED,gateway-cleaned);awaitreleaseStock(order.id);// 释放占用的库存/名额return{action:expired};}// 订单还很新却查不到大概率是协议问题必须告警而不是静默吞掉alertOps(新订单查询不到:${order.out_trade_no}, age${ageHours.toFixed(1)}h);return{action:alert};}constgatewayStatePAY_RESULT_MAP[data.trade_state];if(gatewayStatePAIDorder.status!PAID){awaitmarkOrder(order.id,PAID,reconcile);awaitdeliverGoods(order.id);return{action:paid};}return{action:noop};}这里的GATEWAY_ORDER_TTL_HOURS是一个必须写进配置而不是写死在代码里的量——它取决于网关产品线的清理策略换产品线、换网关都要重新确认。另一个实践细节是releaseStock超期订单如果不释放库存名义库存会被僵尸订单慢慢吃光这是比查不到订单更隐蔽的资损。五、第四坑轮询压测——二维码过期后的退避节奏5.1 把查询接口打出限流的一次压测前三坑都修完之后链路理论上闭环了但我想验证兜底查询的真实成功率于是写了个脚本模拟 500 个用户同时扫码、扫完不付观察补单任务的查询流量。结果脚本跑起来不到两分钟网关开始返回 429随后部分请求直接被断开。是补单任务把查询接口打出了限流。复盘流量构成问题出在轮询节奏上。我们最初的实现是固定间隔轮询每 5 秒查一次直到订单过期。这个节奏对单个订单毫无压力但它的数学期望算一下就吓人500 个未付订单 × 12 次/分钟 6000 QPS 的查询需求而补单任务又是全量并发扫表的等于把所有僵尸订单同时怼到了查询接口上。固定间隔的问题在于它与订单生命周期的实际价值不匹配。二维码的有效期通常是 2 到 5 分钟我们设的是 3 分钟。在前 3 分钟里用户随时可能付款轮询有意义3 分钟一过二维码已经死了用户不可能再扫它付款此时还以 5 秒一次的频率查询本质上是在用 6000 QPS 去确认一个几乎不可能发生的事件。5.2 退避设计5 秒起步30 秒封顶二维码过期后转惰性修正后的轮询策略分三个阶段阶段时间窗轮询间隔说明活跃期0 ~ 二维码有效期5 秒用户随时可能付款实时性优先衰减期有效期 ~ 有效期×330 秒网关回调大概率已丢低频兜底归档期衰减期之后不轮询只等定时对账交给每日对账任务处理对应代码核心是一个带退避的调度函数constQR_TTL_MS3*60*1000;// 与下单时传给网关的 expire_time 保持一致constACTIVE_INTERVAL_MS5*1000;constDECAY_INTERVAL_MS30*1000;constDECAY_WINDOW_MSQR_TTL_MS*3;functionnextDelayMs(order){constageDate.now()-order.createdAt;if(ageQR_TTL_MS)returnACTIVE_INTERVAL_MS;if(ageDECAY_WINDOW_MS)returnDECAY_INTERVAL_MS;returnnull;// 退出轮询交给每日对账}asyncfunctionpollOnce(order){constdelaynextDelayMs(order);if(delaynull){awaitmarkOrder(order.id,POLL_GIVEUP,decay-done);return;}awaitsleep(delay);constresultawaitreconcileOrder(order);if(result.action!paidorder.statusPENDING){schedulePoll(order);// 继续下一轮}}除了退避还有两个并发层面的闸门必须加。一是全局并发上限补单任务的扫表 SQL 必须带LIMIT配合一个固定大小的 worker 池把对网关查询接口的瞬时 QPS 压在配额以内我们按网关文档给的限流阈值打了对折留余量。二是抖动如果 500 个订单都是整点创建的即便 30 秒一次也会形成 30 秒一次的脉冲。给每次轮询加 0 到 5 秒的随机抖动把脉冲摊平。这两个手段合起来压测的 429 再没有出现过。另外补一个容易忽略的点轮询查到已支付后要先落库再发货且落库要用状态机的条件更新UPDATE orders SET statusPAID WHERE id? AND statusPENDING防并发——因为回调通道和轮询通道可能在同一秒内同时发现支付成功两条链路都触发发货就是双重发货。轮询结果同时驱动前端的交互活跃期的查询响应里带上二维码剩余有效秒数前端据此做倒计时一旦进入衰减期二维码已死接口返回明确的状态标记前端把二维码置灰并引导用户重新下单。这个细节在客服侧的体感很明显——早先版本里二维码死了页面还在原地转圈用户以为付了款没到账这类客诉比发货慢还多。六、四个坑沉淀成一张自查清单四个坑走完回头看它们其实是同一个主题的四个切面你与网关之间的契约远比文档第一页写的发起支付、接收回调要细得多。最后把这次的全部教训整理成一份自查清单接入任何一家新的支付网关时逐条过一遍序号检查项本次事故对应1验签字段范围逐字段核对文档是否参与签名列排除 sign、空值字段确认排序算法第一坑2排序语义确认网关的 ASCII 排序规则与代码排序结果一致大小写、下划线第一坑3验签失败应答按文档返回失败应答避免回调重试风暴第一坑4外部协议字段名集中管理请求体字段名只从协议常量模块取不从内部字段名推理第二坑5查询报错归因收到订单不存在先做参数完整性自检再相信业务语义第二坑6确认网关订单清理策略TTL 多长、清理后是否可查、状态码全表含义第三坑7补单逻辑对订单不存在做年龄分流新订单告警老订单判过期并释放库存第三坑8轮询节奏活跃期/衰减期/归档期三段式间隔随订单年龄退避第四坑9查询限流保护全局并发上限 随机抖动压在网关配额以内第四坑10双通道幂等回调与轮询可能同时到发货前用条件更新抢锁第四坑最后一句话收尾支付链路的所有坑本质上都是你以为网关会怎样和网关实际怎样之间的差值。把每一处差值用文档核对、用真实请求验证、用清单固化下来这个差值就会越来越小——这也是这套微信自动回复工具后来再没出过支付类工单的原因。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进