FEATURED · 精选文章

PHP收银台接入微信扫码支付:NATIVE支付、回调与幂等落账实践

发布时间 / 2026/9/15 19:21:25
来源 / 创域科博编辑部
栏目 / 资讯中心
PHP收银台接入微信扫码支付:NATIVE支付、回调与幂等落账实践 简介这份基于微信公众号的商家收银台PHP源码主要面向已通过微信认证服务号并开通微信支付能力的商户解决客户扫码后需手动录入金额与订单信息的收款痛点。压缩包内含1885个文件大小约8.01MB其中PHP源码文件占据绝大多数达1327个并配有JavaScript、CSS、HTML页面以及图片、配置等文件组成一套可直接运行和二次开发的完整Web系统。系统允许创建多个店铺不同店铺可独立设置自定义表单字段涵盖单行文本、多行文本、单选、多选、下拉选择、图片上传和时间选择收款金额可配置为固定金额或由客户手动输入因此能灵活实现快捷收款、面对面收款、商品预约预订等多种扫码支付场景。商户可借助自定义表单精准收集订单数据并完成账款统计提升收银与服务效率。当前已有253人学习下载适合具备一定PHP开发能力的商户或开发者作为微信支付收银台参考实现。1. 用 PHP 收银台接微信扫码支付先想清楚场景再动手店主在收银台输入金额屏幕弹出微信支付二维码顾客扫码确认几秒后界面自动翻成已支付。对顾客是几秒的事对后端却是统一下单、二维码生成、异步回调、主动查单、幂等落账一整条链路。市面上的 PHP 收银台源码大多跑通了下单和回调但双屏收银、网络抖动、回调重复、退款对账放真实门店里才露出来。下面按接手这类项目时的排查顺序讲先理清 NATIVE 扫码支付的接口边界与订单状态机再给出收银台的表结构和 PHP 骨架然后对齐必调参数与踩坑点最后落到上线前的自测和投诉回调。适合拿了收银台源码不知道从哪接微信支付的 PHP 开发者也给进销存系统加扫码收款的实施工程师参考。2. 收银台扫码支付的状态流转与接口边界2.1 NATIVE 支付与收银台的适配原理微信支付针对线下扫码场景提供的是 NATIVE 支付也就是常说的扫码付款。调用统一下单接口时把 trade_type 设为 NATIVE微信返回一个 code_url把它生成二维码展示在收银台屏幕上顾客扫了之后打开确认支付页确认后资金从用户账户划到商户账户。这个模式不要求顾客装特定的支付应用微信自带的扫一扫就能完成所以是收银台接入微信支付最常用的一种方式。收银台和 PC 商城有个显著区别。PC 商城是用户主动下单收银台是收银员触发下单同一时间通常只有一笔进行中的订单。下单前要把上一笔未支付单关掉或标成已取消避免顾客扫到旧码付错金额。二维码必须是动态的金额和 out_trade_no 每次都变。静态收款码没法传订单号对账时只能拿到总额对商家财务不友好。如果是新项目从零接入官方现在推荐 API v3报文换成 JSON验签和加密体系也换了。但存量收银台源码大多是 API v2 的 XML 签名体系扫码支付场景下 v2 依然可用。这里按 v2 讲v3 的差异集中在签名和证书部分订单状态和业务流程不变。2.2 微信支付订单状态的五态模型收银台的订单状态建议拆成五态表里用一个 tinyint 字段表示状态表值含义触发时机CREATED0已下单待扫码统一下单成功拿到 code_urlSCANNED1已被扫码待支付前端轮询标记(可选)PAID2支付成功回调或查单确认CLOSED3已关闭超时未付或收银员关单REFUNDED4已退款退款接口返回成功SCANNED 在第一个版本里可以不做。微信没有顾客扫了码但没付钱的推送要做只能靠前端轮询加一个轻量标记对账意义不大还增加状态复杂度。我一般只在双屏收银机上做这个状态用于副屏提示顾客已扫码等待确认。实现上不需要落库Redis 里设一个 5 分钟过期的 key 即可过期后自然回落到 CREATED 展示不影响后续状态机流转。状态机守住一条原则支付状态只能从低值往高值单向流转不允许从 PAID 退回 CREATED退款也只能从 PAID 到 REFUNDED。任何回调先进来查当前状态再决定是否更新已经 PAID 的订单重复回调直接忽略。这个决策能让并发场景下的状态更新变得简单可靠。2.3 微信支付异步回调与主动查单的双通道支付结果通过 notify_url 异步通知通知可能重复极端情况下还会延迟几分钟偶尔也会丢。收银台不能只依赖回调必须有主动查单兜底。查询接口按 out_trade_no 或 transaction_id 查返回 trade_state 字段SUCCESS 表示支付成功NOTPAY 表示未支付CLOSED 表示已关闭。查询结果是 SUCCESS 时走和回调完全一样的落账逻辑。主动查单有三个触发时机顾客说我付了你们没反应时收银员点按钮触发前端轮询超时后由后端触发每日对账任务扫一遍当天 PAID 但未出库的单子。前端轮询的频率控制在 2 到 3 秒一次但轮询只查本地数据库不直接打微信接口否则收银台数量一多就会被限流。后端维护一个已查过但未确认的订单集合同一笔订单在短时间内只真正查询一次微信。// 收银台后台的订单状态接口:本地未PAID且未进过查询队列时才调微信 if ($order[status] 2 !$this-queriedPool-contains($order[out_trade_no])) { $qr wxpayOrderQuery($order[out_trade_no], $config); if (($qr[trade_state] ?? ) SUCCESS) { $this-orderRepo-markPaid($order[id], $qr[transaction_id], json_encode($qr, JSON_UNESCAPED_UNICODE)); } $this-queriedPool-add($order[out_trade_no], 60); // 60秒内不再重复查 }这份代码放在后台轮询接口里queriedPool 用 Redis 实现key 是 out_trade_noTTL 设 60 秒。这样即使前端十个收银台同时轮询同一笔订单最多一分钟查一次微信其余请求全部从本地库返回。注意查询结果也要写日志后续对账时能看出是回调先到还是查单先到。3. 用 PHP 撸一个收银台支付骨架下单、出码、回调3.1 收银台支付模块的数据库表结构收银台最少需要两张表订单主表和支付日志表。订单表负责业务状态支付日志表记录每一次请求的原文。下面是订单表的 DDLCREATE TABLE cashier_order ( id bigint unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, out_trade_no varchar(32) NOT NULL COMMENT 微信支付商户订单号,同商户下唯一, total_fee int NOT NULL COMMENT 支付金额,单位分, status tinyint NOT NULL DEFAULT 0 COMMENT 0创建 1已扫码 2已支付 3已关闭 4已退款, code_url varchar(256) DEFAULT NULL COMMENT 统一下单返回的二维码链接, transaction_id varchar(64) DEFAULT NULL COMMENT 微信支付单号,回调里写入, paid_at datetime DEFAULT NULL COMMENT 支付时间, notify_raw text COMMENT 最后一次回调原始报文, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_out_trade_no (out_trade_no), KEY idx_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收银台订单表;这份 DDL 有三个细节值得注意。金额用 int 存分而不是 decimal 或 float避免浮点误差。out_trade_no 加唯一索引是幂等的第一道防线重复下单时数据库直接报错不会产生两条同一商户订单号的脏数据。notify_raw 用 text 存最后一次回调原文后面排查微信说通知了你但你库里没有这类问题时这个字段能确认回调到底到没到。支付日志表可以很薄id、out_trade_no、api_type下单/回调/查单、request_raw、response_raw、created_at 六个字段按 out_trade_no 建索引。这个表不参与业务查询只服务于排查每笔请求都落一条。注意建表前先确认字符集。微信回调报文里可能出现 emoji 等四字节字符如果库是旧的 utf8插入时会直接报错导致回调处理中断微信那边会一直重试。3.2 用 PHP 调用统一下单接口并生成二维码下单接口的核心是调用 API v2 的 unifiedorder。报文是 XML签名算法是 MD5 或 HMAC-SHA256。下面函数只做四件事组装参数、算签名、发请求、解析 code_url。function wxpaySign(array $params, string $apiKey, string $signType MD5): string { ksort($params); $parts []; foreach ($params as $k $v) { if ($v || $k sign) { continue; } $parts[] $k . . $v; } $string implode(, $parts) . key . $apiKey; return strtoupper($signType HMAC-SHA256 ? hash_hmac(sha256, $string, $apiKey) : md5($string)); } function createNativeOrder(array $param, array $config): array { $params [ appid $config[appid], mch_id $config[mch_id], nonce_str md5(uniqid(mt_rand(), true)), body $param[body], out_trade_no $param[out_trade_no], total_fee $param[total_fee], spbill_create_ip $config[server_ip], notify_url $config[notify_url], trade_type NATIVE, ]; $params[sign] wxpaySign($params, $config[api_key], MD5); $xml arrayToXml($params); $resp curlPostXml(https://api.mch.weixin.qq.com/pay/unifiedorder, $xml); $data xmlToArray($resp); if (($data[return_code] ?? ) SUCCESS ($data[result_code] ?? ) SUCCESS) { return [code_url $data[code_url], prepay_id $data[prepay_id]]; } throw new RuntimeException(下单失败: . ($data[return_msg] ?? unknown)); }wxpaySign 是验签的公共基础规则是把所有非空参数按 ASCII 升序拼成 keyvaluekeyvalue 形式末尾加 key密钥再按 sign_type 做 MD5 或 HMAC-SHA256结果转大写。拼串时排除空值和 sign 本身这是验签名失败最常见的原因。arrayToXml 和 curlPostXml 是工具函数curl 要设置 5 秒超时避免微信接口抖动时把 PHP-FPM 的 worker 全部挂住。appid 是公众号或小程序的 AppIDmch_id 是商户号api_key 在商户平台账户中心-API 安全里设置是 API v2 的密钥不是 API v3 的商户私钥。spbill_create_ip 传服务器的出口 IP不要传收银机的局域网 IP否则会触发风控。total_fee 单位是分界面输入 12.50 元时转成分用 intval(round($yuan * 100))直接 (int)($yuan * 100) 会在个别浮点数值上少一分。拿到 code_url 后用 phpqrcode 或 endroid/qr-code 生成二维码输出到页面。code_url 有效期两小时生成后落库。收银员误关弹窗再重开时直接读库里未过期的 code_url 重新出码不要重新下单否则老二维码立即失效已经在扫码页上的顾客会看到订单不存在。下面是统一下单接口的核心参数拿到源码后逐个对一遍参数必填说明appid是公众号/小程序 AppIDmch_id是微信支付商户号out_trade_no是商户订单号,同商户下唯一total_fee是订单金额,单位分body是商品描述,会显示在账单交易记录里notify_url是回调地址,HTTPS,不能带参数spbill_create_ip选填调用接口的服务器 IP,建议填trade_type是固定 NATIVE3.3 微信支付回调验签与幂等落账收到支付结果通知后的处理顺序是固定的验签、对金额、幂等更新。顺序不能反验签不过直接回 FAIL对不上金额也回 FAIL剩下的交给微信的重试策略。public function notify(): void { $raw file_get_contents(php://input); $data xmlToArray($raw); if (($data[return_code] ?? ) ! SUCCESS || ($data[result_code] ?? ) ! SUCCESS) { $this-replyXml(FAIL, failed); return; } if (!wxpayVerifySign($data, $this-apiKey, $data[sign_type] ?? MD5)) { $this-replyXml(FAIL, sign error); return; } $order $this-orderRepo-findByOutTradeNo($data[out_trade_no]); if (!$order || (int)$order[status] 2) { $this-replyXml(SUCCESS, OK); return; } if ((int)$data[total_fee] ! (int)$order[total_fee]) { $this-replyXml(FAIL, amount mismatch); return; } $this-orderRepo-markPaid($order[id], $data[transaction_id], $raw); $this-replyXml(SUCCESS, OK); }wxpayVerifySign 的实现就是 wxpaySign 重算一遍再比较唯一要注意的是取 sign_type 时要考虑报文缺省的情况缺省按 MD5 兼容。验签不过的请求一定要把原始 XML 落日志八成原因是密钥配置错了或拼串时带了空值剩下两成是 XML 解析后字符串首尾带空格。金额校验这段最容易漏但绝不能省。回调里的 total_fee 必须和订单表一致不一致说明串单或数据异常回 FAIL 让微信重试同时人工介入查日志。已经 PAID 的订单重复回调直接回 SUCCESS不做二次状态更新这是幂等落账的关键能避免重复发货、重复入账。提示notify_url 必须是公网可访问的 HTTPS 地址。收银台部署在内网时在网关层配置 URL 转发把 /pay/notify 路径转发到内网对应端口并保证原始 body 不被改动。4. 收银台源码必调的参数与踩坑清单4.1 微信支付接口必调的五个参数不管源码是哪个版本这五个参数是最容易出问题的地方。参数约束高危误用out_trade_no32 位以内,同商户唯一直接 time() 拼接导致并发碰撞total_fee单位分,整数float 乘 100 丢精度nonce_str32 位以内,每次随机固定值导致部分请求被拒notify_url公网 HTTPS,不带参数配成内网地址回调不到sign_typeMD5 或 HMAC-SHA256下单和验签两边算法不一致out_trade_no 建议用业务前缀加日期加自增序号比如 CT20250513001。同一秒内两个收银台同时下单时time() 拼接几乎必然碰撞后一个下单会直接失败顾客看到二维码出不来。nonce_str 用 md5(uniqid(mt_rand(), true)) 足够每次请求重新生成不要用固定值。total_fee 的分单位转换统一在整数维度算。折扣、抹零、整单优惠都在分上做加减引入浮点就会出误差。sign_type 换成 HMAC-SHA256 后回调验签必须从报文里取 sign_type 分支取不到按 MD5 兼容。最稳的做法是下单和验签读同一个环境变量避免两边写死不同算法。notify_url 变更后要在商户平台重新保存一次接口配置才会生效改完代码没改平台配置是回调收不到里最普遍的原因。拿到源码后先花十分钟做一次代码审计重点看 api_key 是不是硬编码在类文件里。密钥应该从环境变量或单独配置文件读取不进版本库。4.2 本地联调时 mock 微信回调微信支付没有对外开放的沙箱环境只有测试商户号。本地开发时最实用的办法是自己 mock 回调不依赖外网也能把收银台完整链路跑通。下单接口加一个测试开关命中后不发起真实 HTTP直接返回预设的 code_url前端把二维码渲染成带说明文字的占位图。收银台页面上放一个模拟顾客扫码按钮点击后向本地 notify 接口 POST 一条构造好的 XML。这样断网环境下也能验证从下单到落账的完整流程。curl -s -X POST http://127.0.0.1:8080/pay/notify \ -H Content-Type: text/xml \ -d xmlappidwx123/appidmch_id1900000109/mch_idout_trade_noCT202505130001/out_trade_notransaction_id42000000000000000000/transaction_idtotal_fee1250/total_feereturn_codeSUCCESS/return_coderesult_codeSUCCESS/result_code/xmlmock 构造的通知没有合法签名所以要在验签函数入口加一个本地调试开关开关只存在于独立环境配置文件中线上不加载。最容易犯的错是把开关写死在代码里忘了删上线后验签形同虚设。mock 只能验证代码逻辑真实报文结构和验签流程还是得用真实支付验证一次第一次联调用 0.01 元订单把 mock 报文和微信实际推过来的报文对比一遍。4.3 多收银台高并发回调的幂等更新连锁店场景下多个收银台同时活跃同一个 out_trade_no 会被微信重试和主动查单同时打到两条路径并发更新同一笔订单。处理办法是在 SQL 层做条件更新而不是先 SELECT 再 UPDATE。$affected $this-db-update( UPDATE cashier_order SET status 2, transaction_id ?, paid_at NOW() WHERE id ? AND status 2, [$data[transaction_id], $order[id]] ); if ($affected 0) { // 已被其他请求消费,不再执行副作用操作 $this-replyXml(SUCCESS, OK); return; } // 影响行数为 1 才执行加积分、通知出库、打印小票等副作用UPDATE 条件带 status 2保证只有一笔请求能把状态从 0 或 1 翻成 2。后到的请求影响行数为 0直接回 SUCCESS 结束。这个写法比先读后判少一个竞态窗口而且不需要在业务代码里加锁数据库层面的条件更新就把并发问题解决了。回调接口是公开入口PHP-FPM 的 max_children 要按回调响应时间来估算。如果 worker 被慢日志、慢查询拖住回调接口超时微信重试会把积压越堆越多。收银台凌晨不营业时没人发现问题第二天开业高峰才集中爆发。上线前用 ab 或 wrk 对 notify 接口压一下确认响应 P99 在 200 毫秒以内。回调处理里如果有加积分、更新库存这类副作用把状态更新和副作用写进同一个数据库事务任何一个失败都回滚并回 FAIL 让微信重试。事务里不要放外部 I/O比如调用支付接口或发短信会拖长事务时间。如果副作用比较多更常见的做法是回调只收报文落日志并回 SUCCESS把落账逻辑丢进 PHP 队列Redis 或 RabbitMQ由 worker 异步消费回调接口的响应时间稳定在几十毫秒。5. 微信支付收银台上线前的自测清单与投诉回调5.1 用一分钱订单自测支付全链路上线前用一笔 0.01 元的真实订单走一次完整流程。按顺序检查下单后二维码出图扫码后弹出确认页支付成功 3 秒内收银台页面自动刷新成已支付后台日志里有一条回调记录订单表 transaction_id 正确写入。任何一步断掉都先别上线优先查日志而不是猜原因。提示一分钱自测不要在营业高峰做选打烊后或客流低谷时段避免和真实订单混在一起干扰对账。5.2 投诉回调与每日对账脚本很多收银台源码会漏配投诉回调。顾客在微信账单里对某笔交易发起投诉时微信会向商户平台配置的投诉回调地址推送通知处理不当投诉会一直挂在商户平台影响商户评分。投诉回调走的是 API v3 的通知体系和 v2 的支付回调完全不同请求头带 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signaturebody 是 AES-256-GCM 加密的 JSON。如果源码里还没接这块先在商户平台把通知地址指向一个能落日志的服务把原始请求记下来再补验签和解密逻辑不要直接把投诉回调地址留空。对账脚本放在每天凌晨低峰期跑。逻辑是查所有状态为 PAID 且当天未落对账标记的订单逐笔调订单查询接口核对金额再和微信商户平台导出的账单比对。比对维度只取金额和单号不比对时间微信账单里的时间戳是支付成功时间本地库的时间是回调落库时间两者本来就有差值。有差异的单子落差异表第二天人工处理。这个脚本能稳定捕获三类问题回调延迟导致漏更新、金额被风控拦截但状态没变、out_trade_no 串号。最后留一个这边的习惯签名函数和回调处理类是全项目唯一不允许重构的部分。业务代码随便优化这两块任何一行改动都可能让线上支付静默失败。每次改完代码跑一遍上面的一分钱自测比出事之后再翻日志省力一个量级。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻