扫码支付这件事,看起来只是"生成一个二维码,用户扫一下付钱"这么简单,但真到落地的时候,坑全藏在细节里:二维码里的链接怎么拼、订单状态怎么轮询、回调怎么验签、重复通知怎么幂等、超时订单怎么关单。我见过不少团队在演示环境跑得挺顺,一上真实交易就出问题,要么是二维码过期了页面还在转圈,要么是用户付了钱系统没收到通知,要么是同一笔订单被重复发货。这篇就把"扫码支付示例源码"这类项目里最值得讲清楚的东西拆开说,从整体链路到代码骨架,再到联调排错,尽量给到能直接抄作业的程度。
需要先说明一点:下面所有代码和参数都是示意性的,接口地址、字段名、密钥管理方式都以你实际对接的支付服务文档为准。不同服务商的字段命名差异不小,但核心链路和设计思路是通用的,理解了这套骨架,换任何一家都能快速迁移。
1. 先搞清楚扫码支付到底走了哪条链路
很多人一上来就写代码,结果写到一半发现不知道"钱到底是怎么从用户手里到商户账上的",于是各种状态对不上。所以第一步不是写代码,而是把链路在脑子里跑一遍。
1.1 四方角色与数据流向
扫码支付(业内常说的 Native 模式)本质上是商户后台、支付服务端、用户手机、商户前端四方的协作。完整链路大致是这样:
- 用户在商户页面点击"购买",前端把订单信息(商品、金额、订单号)发给商户后台。
- 商户后台调用支付服务的"统一下单"接口,把订单信息 + 商户身份 + 签名一起提交。
- 支付服务返回一个二维码内容(通常是一个短链接或支付串),商户后台把它交给前端。
- 前端把这段内容渲染成二维码图片,用户用手机扫码。
- 用户在手机上完成付款,支付服务异步向商户后台推送支付结果通知。
- 商户后台验签、校验金额、更新订单状态,并给支付服务返回"已收到"的应答。
- 前端通过轮询或长连接得知支付成功,跳转结果页。
这里有个关键认知:二维码只是"入口",真正的交易状态以支付服务的异步通知为准。前端轮询只是给用户看的体验层,绝不能拿轮询结果当发货依据。我见过有项目直接在前端轮询到"成功"就发货,结果被伪造请求薅了一波,这是典型的把体验层当成了业务层。
1.2 同步返回、异步通知、主动查询三者的分工
这三个概念是新手最容易混淆的,必须掰开:
| 类型 | 触发时机 | 可信度 | 典型用途 |
|---|---|---|---|
| 同步返回 | 调用下单接口立即返回 | 中(只代表下单成功) | 拿到二维码内容、订单号 |
| 异步通知 | 用户付款后服务端推送 | 高(需验签) | 更新订单、触发发货 |
| 主动查询 | 商户主动调接口查 | 高(需验签) | 补偿通知丢失、对账 |
同步返回只能说明"下单这个动作成功了",不代表用户付了钱。异步通知是交易结果的权威来源,但它可能丢失、可能重复、可能延迟。主动查询是兜底手段,用来补偿通知丢失的场景。三者配合,才能保证状态最终一致。
1.3 为什么必须做"最终一致"而不是"实时一致"
支付系统的现实是:网络会抖、通知会丢、服务会重试。你不可能保证"用户付款的同一毫秒,你的数据库就更新了"。所以正确的心态是接受短暂不一致,用机制保证最终一致。具体做法就是:异步通知来了就处理,没来就靠定时任务主动查询补偿,查询也失败就进人工对账队列。这套组合拳是行业标准做法,不是过度设计。
2. 下单接口的请求构造:签名是最容易翻车的地方
链路清楚了,接下来看代码。整个示例源码里,最值得细看的就是下单接口的请求构造,因为签名错了,后面全白搭,而且报错信息往往很含糊。
2.1 参数组装与签名字段的取舍
下单请求通常包含这些要素:商户号、应用标识、订单号、金额(单位通常是分)、商品描述、通知地址、交易类型、随机串、时间戳。组装时有两个坑:
- 金额单位:绝大多数服务商用"分"作为整数单位,1 元 = 100 分。用浮点数传金额是灾难,0.1 + 0.2 这种精度问题会让对账永远对不平。示例里统一用整数分。
- 空值字段:签名时是否包含空值字段,各家规则不同。有的要求"参数值为空不参与签名",有的要求"全部参与"。这个必须逐字读文档,签错一个字段就是签名失败。
下面是一个示意性的参数组装与签名函数(语言用 Python,逻辑通用):
import hashlib import hmac import time import random import string def build_sign_params(params: dict, secret: str) -> str: # 1. 过滤空值(按实际文档规则调整) filtered = {k: v for k, v in params.items() if v not in (None, "")} # 2. 按 key 的字典序升序排列 sorted_keys = sorted(filtered.keys()) # 3. 拼成 key=value&key=value 形式,注意不要 URL 编码 raw = "&".join(f"{k}={filtered[k]}" for k in sorted_keys) # 4. 末尾拼接密钥,做 HMAC-SHA256 raw_with_key = raw + "&key=" + secret sign = hmac.new( secret.encode("utf-8"), raw_with_key.encode("utf-8"), hashlib.sha256 ).hexdigest().upper() return sign def build_order_request(order_no: str, amount_fen: int, notify_url: str, merchant_id: str, app_id: str, secret: str) -> dict: params = { "merchant_id": merchant_id, "app_id": app_id, "out_trade_no": order_no, "total_fee": amount_fen, # 单位:分,整数 "body": "示例商品", "notify_url": notify_url, "trade_type": "NATIVE", "nonce_str": "".join(random.choices(string.ascii_letters + string.digits, k=32)), "timestamp": str(int(time.time())), } params["sign"] = build_sign_params(params, secret) return params这段代码里,nonce_str(随机串)和timestamp(时间戳)的作用是防重放:服务端会校验时间戳与当前时间的偏差(通常 5 分钟内有效),随机串保证同一秒内的多次请求签名不同。少了这两个,攻击者截获一次请求就能反复重放。
2.2 密钥管理:绝对不能写死在代码里
示例源码为了演示方便,往往把密钥直接写在配置文件里。但真实项目里,密钥泄露等于资金风险。正确做法是:
- 密钥放在环境变量或专用的密钥管理服务里,代码只读不存。
- 不同环境(开发、测试、生产)用不同密钥,绝不混用。
- 密钥定期轮换,轮换时新旧密钥并存一段时间做平滑过渡。
- 日志里打印请求参数时,必须对密钥、签名做脱敏。
我踩过一次坑:调试时把完整请求体打进了日志,结果日志被同步到了公共的日志平台,密钥就这么暴露了。后来加了统一的日志脱敏中间件才解决。这个教训值得每个做支付的人记一笔。
2.3 下单成功后的二维码内容处理
下单接口返回的二维码内容,通常是一个形如weixin://wxpay/bizpayurl?pr=xxxx的字符串,或者一个短链接。前端拿到后有两种渲染方式:
- 前端库渲染:用二维码生成库(如 qrcode 类库)把字符串转成图片,纯前端完成,不占服务端资源。
- 服务端渲染:服务端生成图片返回给前端,好处是可以在图片上叠加 logo、有效期提示。
示例源码一般用前端渲染,简单直接。但要注意:二维码内容本身不要做二次加工,比如你自己在字符串前后加东西,扫码就失败了。另外二维码有有效期(通常 2 小时),过期后需要重新下单获取新的二维码,前端要能识别"二维码过期"状态并提示用户刷新。
3. 异步通知的接收与验签:整个系统最关键的十行代码
如果说下单是"发起",那异步通知就是"确认",这一段代码写错,前面全白干。而且这段代码往往只有几十行,却承担了整个交易闭环的信任基础。
3.1 通知报文的解析与验签顺序
收到通知后,正确的处理顺序是:
- 先验签,再解析业务。绝不能先信任报文内容再验签,否则验签就失去意义了。
- 验签时,把通知里的签名字段剔除,其余字段按同样规则重新计算签名,与报文中的签名比对。
- 验签通过后,再校验金额、商户号、订单号是否与本地记录一致。
- 全部通过才更新订单状态。
这里有个细节:通知报文可能是 JSON,也可能是表单格式,取决于服务商。解析时要注意字符编码(统一 UTF-8),以及某些字段可能是数字或字符串类型不一致的问题。示例代码里建议做一层类型归一化。
def verify_notify(payload: dict, secret: string) -> bool: received_sign = payload.pop("sign", None) if not received_sign: return False expected = build_sign_params(payload, secret) # 使用恒定时间比较,防时序攻击 return hmac.compare_digest(expected, received_sign)注意hmac.compare_digest这个用法,它做的是恒定时间比较,避免攻击者通过响应时间差逐位猜签名。虽然实际场景里这种攻击门槛很高,但既然标准库提供了,就没理由不用。
3.2 金额校验:防止"一分钱买大件"
验签通过不代表业务正确。必须校验通知里的金额与本地订单金额一致。我见过真实案例:攻击者构造了一个金额为 1 分的通知(虽然签名过不了,但如果验签有漏洞就危险了),或者利用某些服务商的"金额字段可选"特性做文章。所以金额校验是第二道防线,不能省。
校验逻辑很简单:拿通知里的total_fee和本地订单的应付金额比对,不一致就拒绝处理并告警。同时校验商户号、应用标识,确保通知确实是发给自己的。
3.3 幂等处理:同一笔通知可能来很多次
支付服务的通知机制是"至少一次",意味着同一笔交易的通知可能重复推送多次(网络超时重试、服务端主动补发等)。如果你的处理逻辑不幂等,就会重复发货、重复加积分。
幂等的实现方式有几种:
- 数据库唯一约束:给订单号加唯一索引,重复插入直接失败。
- 状态机判断:更新前先查当前状态,只有"待支付"才能流转到"已支付",已支付的直接返回成功。
- 分布式锁:按订单号加锁,串行处理。
示例源码里最常用的是状态机判断,简单可靠。核心逻辑是:
def handle_notify(order_no: str, paid_amount: int) -> str: order = query_order(order_no) if order is None: return "FAIL" # 订单不存在,返回失败让服务端重试或告警 if order.status == "PAID": return "SUCCESS" # 已处理过,直接应答成功,避免重复处理 if order.status != "PENDING": return "FAIL" if order.amount != paid_amount: alert("金额不一致", order_no) return "FAIL" update_order_status(order_no, "PAID") trigger_delivery(order_no) # 发货等后续动作 return "SUCCESS"注意返回值的语义:返回SUCCESS表示"我已收到并处理",服务端就不再重推;返回FAIL或其他,服务端会按策略重试。所以只有确定处理成功才返回 SUCCESS,处理失败要返回失败让服务端重试,千万别为了"看起来正常"而无脑返回成功。
4. 主动查询与超时关单:给系统加一层保险
异步通知再可靠也有丢失的可能,所以必须有主动查询兜底。同时,用户下单后不付款的订单会一直占着库存,需要超时关单。
4.1 定时补偿查询的设计要点
补偿查询的典型做法是:定时任务扫描"待支付"且"创建时间超过 N 分钟"的订单,逐笔调用支付服务的查询接口。设计时有几个要点:
- 扫描频率:太频繁浪费资源,太稀疏补偿不及时。常见做法是每分钟扫一次,只查 5 分钟前创建的订单。
- 查询限流:批量查询时要注意服务商的 QPS 限制,加个简单的令牌桶或分批处理。
- 结果处理:查到已支付就按通知逻辑处理(复用同一套幂等逻辑),查到已关闭就更新本地状态,查不到就继续等下一轮。
- 避免重复补偿:同一订单不要被多个任务实例同时处理,用分布式锁或任务分片解决。
这里有个经验:补偿查询和异步通知必须复用同一套订单状态更新逻辑,否则两条路径的处理规则不一致,会出现"通知路径发货了、查询路径又发一次"的问题。把状态更新抽成一个函数,两个入口都调它,是最省心的做法。
4.2 超时关单的时机与顺序
超时关单不是简单地把本地订单标记为"已关闭"就完事,顺序很重要:
- 先调用支付服务的"关闭订单"接口,确认服务端也关闭了。
- 服务端确认关闭后,再更新本地订单状态。
- 释放库存、优惠券等资源。
如果顺序反了,先关本地再关服务端,可能出现"本地已关闭但用户还能付款"的尴尬局面,钱收了订单却关了,退款流程很麻烦。所以先关远端,再关本地是铁律。
另外,关单接口调用也可能失败(网络问题、订单已支付等),失败时要区分原因:如果是"订单已支付",说明用户刚好在关单前付了款,这时应该走支付成功逻辑而不是关单;如果是网络错误,就重试。这个分支处理是示例源码里经常省略、但生产环境必须补上的。
4.3 对账:最后一道防线
再完善的实时链路,也需要日终对账来兜底。对账的逻辑是:拉取支付服务当天的交易流水,与本地订单逐笔比对,找出"服务端已支付但本地未更新"、"本地已支付但服务端无记录"、"金额不一致"等差异,生成差异报告人工处理。
对账文件通常是按日下载的文本或压缩包,解析后入库比对。这一步看似笨重,但它是发现资金差异的唯一可靠手段。我建议哪怕业务量很小,也要把对账跑起来,因为它是你发现"通知丢了、补偿也没查到"这类极端情况的最后窗口。
5. 联调阶段那些文档里不会写的坑
代码写完了,真正的考验在联调。这一段我按踩坑频率从高到低排,都是血泪经验。
5.1 签名失败但不知道错在哪
签名失败是联调第一大坑,报错往往只有一句"签名错误",不告诉你哪个字段错了。排查方法:
- 打印参与签名的原始串(脱敏后),逐字符和服务商提供的签名工具比对。
- 检查是否有字段值为空却被包含/排除,这是最高频的原因。
- 检查编码:中文商品描述是否 UTF-8,是否有不可见字符。
- 检查大小写:有的服务商要求签名大写,有的小写。
- 检查时间戳:服务器时间是否准确,偏差过大会被判无效。
我一般会写一个"签名自检"小工具,输入参数直接输出原始串和签名,联调时对着服务商的在线签名工具一比,问题立刻现形。这个工具后来成了团队标配。
5.2 通知收不到:先查网络可达性
异步通知收不到,第一反应不该是怀疑代码,而是查网络。常见原因:
- 通知地址是内网地址或 localhost,服务商根本访问不到。联调阶段必须用公网可达的地址。
- 防火墙或安全组没放行服务商的出口 IP。
- 通知地址返回了非 200 状态码,或者响应体不是约定的成功标识。
- HTTPS 证书有问题,导致服务商校验失败。
排查顺序建议:先用工具从外网访问你的通知地址,确认能通;再看服务商的"通知记录"页面(多数服务商提供),看它到底推没推、推的结果是什么。这个页面能省掉大量瞎猜。
5.3 二维码扫出来是空白或报错
这种情况通常是二维码内容被污染了。检查:
- 二维码内容是否被 URL 编码了两次,或者被截断。
- 前端渲染时是否对内容做了 trim 或转义。
- 二维码图片的容错级别是否过低,导致部分遮挡就扫不出。
还有一个隐蔽的坑:某些前端二维码库默认会把内容当 URL 处理,遇到weixin://这种自定义协议会出问题,需要显式指定"按纯文本编码"。
5.4 测试环境正常、生产环境失败
这类问题最折磨人,常见根因:
| 现象 | 可能根因 | 排查手段 |
|---|---|---|
| 生产签名失败 | 生产密钥配错/未更新 | 核对密钥指纹 |
| 生产通知收不到 | 生产域名未备案/证书过期 | 检查证书有效期 |
| 金额对不上 | 生产用了不同币种或单位 | 核对金额单位配置 |
| 偶发失败 | 生产并发高,连接池耗尽 | 看连接池监控 |
核心思路是把环境差异列成清单逐项核对:密钥、域名、证书、金额单位、超时配置、并发配置。列清单比瞎试高效得多。
6. 从示例源码到生产可用,还差哪几步
示例源码的价值在于"跑通链路",但离生产可用还有距离。这一段说说需要补的东西,也是很多团队上线后才发现缺失的部分。
6.1 日志与可观测性
支付链路的日志必须做到"一笔订单全程可追溯"。建议给每笔订单分配一个追踪 ID,从下单、通知、查询、关单全链路带上,日志里能一键串起来。关键节点(下单请求、通知接收、验签结果、状态变更)都要打点,且敏感字段脱敏。
监控指标至少包括:下单成功率、通知到达率、验签失败率、订单状态流转耗时、补偿查询命中率。这些指标异常往往是大故障的前兆。
6.2 异常兜底与人工干预通道
再自动化的系统也需要人工兜底。要准备:
- 手动补单工具:通知和查询都失败时,运营能凭支付流水号手动补单,且操作留痕。
- 退款通道:金额不一致、重复支付等场景需要能发起退款。
- 告警分级:验签失败、金额不一致属于高危,要即时告警;通知延迟属于中危,可汇总告警。
6.3 安全加固清单
最后给一份安全自查清单,上线前逐项过一遍:
- 密钥不落代码、不落日志、不落前端。
- 所有回调必须验签,验签用恒定时间比较。
- 金额、商户号、订单号三重校验。
- 通知处理幂等,重复通知不重复发货。
- 时间戳校验防重放,随机串保证唯一。
- 关单先远端后本地。
- 对账任务每日运行,差异有人跟进。
- 敏感操作(补单、退款)有权限控制和审计日志。
这套东西看起来繁琐,但每一条背后都对应过真实的资金损失案例。示例源码帮你跑通"能付钱",这些加固帮你做到"敢收钱"。
我在实际项目里最大的体会是:扫码支付的代码量其实不大,难的是把各种异常路径想全。写第一版可能两天,把异常路径补全可能要两周。所以拿到示例源码后,别急着改业务,先把上面这些兜底逻辑补齐,后面会省掉无数个半夜被叫起来查账的夜晚。