实现网银直连:PHP+EXE自动回调与订单对账系统解析
2026/9/16 9:41:00 网站建设 项目流程

简介:这套支付宝包装网银/网银网关支付系统面向中小企业站长与个人开发者,旨在用自有PHP服务端配套EXE监控客户端,替代高手续费的第三方支付平台,解决网站资金周转困难和商品成本过高的问题。系统实现原生网银直连、订单自动查询与零延迟回调,支持商户管理、交易管理、通道管理、账号管理及自动轮询,可全天候无人值守完成即时到账。压缩包约202.12MB,内含PHP交易管理源码、EXE监控客户端程序及配置、说明文档,PHP源码用于服务端业务逻辑,EXE程序负责PC端订单监控,便于在Windows服务器上快速部署和二次开发。目前已有112人关注学习,适合具备PHP基础、希望搭建安全稳定支付通道的技术人员。通过这套源码,可同时获得管理端与监控端的完整实现,降低支付运维成本,提升网站收款自动化程度。

1. 为什么这么多站点回头找“网银直连”:支付网关软件要解决的真实问题

做电商站和充值平台的都知道,接入第三方支付要过审、要交保证金、每笔还被抽成,中小站长最难受的是结算周期压资金。所以当一套号称“支付宝网银网关软件”的源码出现在眼前时,很多人第一反应是“这不就是外挂吗?”实际上拆完这套东西你会发现,它就是一个把“人工盯支付宝订单 → 手动确认打款 → 再给网站回执”这个过程自动化掉的系统。PHP 交易管理系统管商户和订单,PC 端 EXE 监控程序管查询和通知,两边通过 HTTP 回调协同。它能做到 24 小时无人值守,核心价值是替代第三方支付渠道的“支付结果通知”环节,而不是绕过支付工具本身。适合已经有业务流量、想用支付宝原声网银收单但不想被渠道费吃掉的团队,也适合做支付二清研究的开发者。

2. 交易管理系统(PHP)的模块拆解与数据表设计

2.1 系统架构总览

这套系统的部署形态是“PHP 服务端 + EXE 客户端”。PHP 服务端跑在 Linux 或 Windows 的 Web 环境里,负责商户管理、订单录入、通道账号维护、接收 EXE 推送的支付结果并回调商户。EXE 客户端装在一台能访问支付宝网关的 Windows 机器上,定时或轮询查询支付订单状态,一旦发现已支付,就把订单号、金额、交易流水号 POST 回 PHP 服务端。

我在实际部署中会把 PHP 服务端和 EXE 客户端分两台机器跑,PHP 只做 API,不承担轮询任务。这样即使 EXE 所在的网络机故障,Web 服务也能正常接收商户下单请求,订单先落到库,等 EXE 恢复后再补齐状态通知。

2.2 核心数据表结构

先看最关键的几张表。商户表、订单表、通道表、账号表,这四张表构成了整个支付系统的骨架。下面是精简后的 MySQL 建表语句:

-- 商户表 CREATE TABLE `merchant` ( `id` int(11) NOT NULL AUTO_INCREMENT, `mch_id` varchar(32) NOT NULL COMMENT '商户号', `mch_name` varchar(64) NOT NULL, `api_key` varchar(64) NOT NULL COMMENT 'API签名密钥', `notify_url` varchar(255) NOT NULL COMMENT '回调地址', `ip_whitelist` varchar(255) DEFAULT '' COMMENT 'IP白名单,逗号分隔', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', PRIMARY KEY (`id`), UNIQUE KEY `uk_mch_id` (`mch_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '商户订单号', `mch_id` varchar(32) NOT NULL, `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已关闭 3已退款', `channel_id` int(11) NOT NULL COMMENT '通道ID', `account_id` int(11) NOT NULL COMMENT '使用的支付宝账号ID', `trade_no` varchar(64) DEFAULT '' COMMENT '支付宝流水号', `notify_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否已通知商户', `create_time` datetime NOT NULL, `pay_time` datetime DEFAULT NULL, `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status` (`status`), KEY `idx_account` (`account_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 通道表 CREATE TABLE `channel` ( `id` int(11) NOT NULL AUTO_INCREMENT, `channel_name` varchar(32) NOT NULL COMMENT '通道名称', `bank_code` varchar(16) NOT NULL COMMENT '银行代码', `sort_order` int(11) NOT NULL DEFAULT '0', `status` tinyint(4) NOT NULL DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 账号表 CREATE TABLE `pay_account` ( `id` int(11) NOT NULL AUTO_INCREMENT, `channel_id` int(11) NOT NULL COMMENT '所属通道', `alipay_account` varchar(64) NOT NULL COMMENT '支付宝登录账号', `cookie_token` text COMMENT '登录凭证或网关Token', `max_amount` decimal(10,2) NOT NULL DEFAULT '0' COMMENT '单笔限额', `today_amount` decimal(10,2) NOT NULL DEFAULT '0' COMMENT '今日已累计', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1可用 0不可用', PRIMARY KEY (`id`), KEY `idx_channel` (`channel_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单表里的notify_flag很关键,它标记了是否已经通知过商户。EXE 循环查询时,会把已支付订单先更新状态,再调回调接口。如果通知失败,notify_flag保持 0,系统会进入下一轮补单逻辑,而不是直接丢弃。

2.3 商户 API 签名与 IP 白名单

商户接入时,服务端会生成一个mch_idapi_key。商户下单请求必须用这个api_key对参数排序后做 MD5 签名。PHP 侧验签逻辑不能只比对签名,还要校验 IP 和订单金额是否在允许范围内:

function verifySign($params, $apiKey) { unset($params['sign']); ksort($params); $str = urldecode(http_build_query($params)) . '&key=' . $apiKey; $sign = md5($str); return hash_equals($sign, $_POST['sign']); } // 验签后还要做白名单校验 function checkIpWhitelist($mchIp, $whitelist) { if (empty($whitelist)) return false; $list = explode(',', $whitelist); return in_array($mchIp, $list); }

这里有个细节:下单参数里不能带channel_id,让服务端根据金额权重自动选通道。因为如果暴露通道参数,商户可以直接指定某个通道,导致账号负载不均,甚至把限额已经用完的账号暴露出去。服务端选通道的常见做法是:先按单笔限额匹配,再按今日累计排序,取最小负载的那个账号。

3. 客户端 EXE 监控程序的工作原理与轮询策略

3.1 为什么用 EXE 而不是 PHP 常驻进程

很多人问:PHP 不是也能写常驻脚本吗?为什么监控端要单独做一个 EXE?我的理解是:PHP 的 web 生命周期不适合做高频轮询,每次启停进程的资源和请求上下文都是浪费;而 EXE 是原生 Windows 程序,可以直接调用支付宝网银控件或本地加密证书,还能把浏览器会话保持在一个独立进程里。EXE 内部通常开多个线程,每个线程负责一个支付宝账号的轮询,内存管理比 PHP 的进程模型更可控。

EXE 和 PHP 服务端的通信很简单,就是 POST 一个 JSON。EXE 查询到订单后,向http://你的域名/index.php/api/notify发送通知,PHP 接收后更新库并回调商户。这样即使 EXE 和 PHP 不同机器,也可以通过网络联调。

3.2 轮询算法的设计

轮询不是越频繁越好。早期版本有人把间隔设成 1 秒,结果支付宝账号当天就被风控了多笔订单异常。我通常建议轮询间隔按账号的活跃度动态调整:刚下单的订单 5 秒查一次,超过 2 分钟的订单 15 秒查一次,超过 10 分钟的订单 30 秒查一次。EXE 侧维护一个待查询订单队列,订单按创建时间排序,每次从最早的一批开始查。

下面是一个模拟轮询调度逻辑的伪代码:

import time from queue import PriorityQueue pending_queue = PriorityQueue() # 元素为 (next_query_time, order_no, query_count) def order_paid(order_no): # 模拟收到已支付结果 pass def polling_loop(): while True: now = time.time() # 拿到最早需要查询的订单 if pending_queue.empty(): time.sleep(1) continue next_time, order_no, query_count = pending_queue.get() if next_time > now: pending_queue.put((next_time, order_no, query_count)) time.sleep(0.5) continue status = query_alipay(order_no) if status == 'PAID': order_paid(order_no) elif query_count < 20: # 指数退避,最多重试20次 interval = min(30, 5 * (query_count + 1)) pending_queue.put((time.time() + interval, order_no, query_count + 1)) else: # 超时未支付,标记关闭 close_order(order_no)

代码里的 PriorityQueue 是为了避免每次都扫描全队,取最短延时的那一个。query_count控制最大查询次数,防止无限轮询把订单变成死单。

3.3 全自动回调的 PHP 处理

当 EXE 查询到订单已支付,会向 PHP 发送order_notrade_noamountalipay_account。PHP 端处理回调的核心是幂等:同一个订单无论通知多少次,最终商户只会收到一次成功通知,而且金额必须等于订单金额,否则不处理并告警。

public function handleNotify() { $orderNo = $_POST['order_no'] ?? ''; $tradeNo = $_POST['trade_no'] ?? ''; $amount = $_POST['amount'] ?? 0; $order = DB::table('orders')->where('order_no', $orderNo)->first(); if (!$order || $order->status != 0) { exit('OK'); // 已经处理过,直接返回 } // 金额比对,防止低额订单被高额支付后利用 if (bccomp($order->amount, $amount, 2) !== 0) { file_put_contents('/var/log/pay_mismatch.log', date('Y-m-d H:i:s') . " $orderNo mismatch\n", FILE_APPEND); exit('FAIL'); } // 更新订单 DB::table('orders')->where('id', $order->id) ->update(['status' => 1, 'trade_no' => $tradeNo, 'pay_time' => date('Y-m-d H:i:s')]); // 通知商户 notifyMerchant($order->mch_id, $order->order_no, $order->amount); exit('OK'); }

bccomp做金额比较,避免浮点误差。商户通知失败时,需要把notify_flag置 0,并写进重试表,由另一个定时任务每隔 10 分钟重推,最多重推 5 次。

3.4 客户端日志与健康检查

EXE 的稳定直接决定了支付成功率。看日志时主要关注三类关键行:QUERY_STARTQUERY_SUCCESSNOTIFY_SEND。如果QUERY_SUCCESS数量占总查询量低于 95%,说明账号被风控或网络环境不稳定。如果NOTIFY_SEND之后没有对应NOTIFY_ACK,说明 PHP 端回调处理异常,需要查 PHP 的 error_log 和 Nginx 的 access log。

我一般在 EXE 配置里加一个大文件日志转储,单文件超过 50MB 自动切分。因为轮询日志增长非常快,不切分的话,半个月就能占用几个 GB 磁盘。

4. 从“被动回调”到“主动补单”:订单状态机与异常对账

4.1 订单状态机

系统里订单状态必须闭环,否则容易出现“钱收了但订单没发货”的纠纷。完整的状态迁移如下:

状态触发场景目标状态
0 待支付商户下单成功1 已支付 / 2 已关闭
1 已支付EXE 查询到已支付,或支付宝主动通知3 已退款
2 已关闭超时未支付,或被商户取消
3 已退款商户退款接口触发,或人工对账退款

在代码实现上,所有状态更新都放到一个带锁的 service 方法里,防止并发回调把订单重复发货:

public function changeOrderStatus($orderNo, $targetStatus, $lockKey = '') { $lock = Redis::get("lock:order:" . $orderNo); if ($lock) return false; Redis::setex("lock:order:" . $orderNo, 10, '1'); try { $order = DB::table('orders')->where('order_no', $orderNo)->first(); if (!$order) return false; // 根据当前状态判断是否允许迁移 $allowMap = [ 0 => [1, 2], 1 => [3], ]; if (!in_array($targetStatus, $allowMap[$order->status] ?? [])) return false; // 执行更新 return DB::table('orders')->where('id', $order->id)->update(['status' => $targetStatus]); } finally { Redis::del("lock:order:" . $orderNo); } }

4.2 主动补单的三种场景

第一,EXE 查询到支付成功但 PHP 进程挂了,导致订单状态没有更新。这种情况恢复后 EXE 要能从日志里回放“待确认交易”,重新推送一次。

第二,支付宝网银页面跳转或回调丢失。用户在银行侧已经扣款,但 EXE 那一次查询请求超时了。所以 EXE 要有一个“在途订单”列表,只要订单没被显式关闭,就继续按退避策略查询。

第三,订单金额不一致。比如用户实际付了 100.01 元,订单是 100 元,这时候系统不能自动确认,而是进入待人工审核表。我在实际运营中遇到过多次这种“多付一分钱”的情况,基本都是银行的协议或支付页面显示问题。

4.3 定时对账脚本

无论 EXE 多自动化,每周跑一次对账都是必须的。最直接的方式是导出支付宝账单 CSV,然后和本地订单表比对。下面是 PHP 脚本的核心片段:

public function checkAgainstAlipay($csvFile) { $fp = fopen($csvFile, 'r'); $line = 1; $mismatch = []; while (($data = fgetcsv($fp)) !== false) { if ($line <= 4) { $line++; continue; } // 跳过表头 $alipayOrderNo = $data[0]; // 支付宝订单号 $localOrderNo = $data[1]; // 商户订单号 $amount = $data[7]; // 金额 $status = $data[9]; // 交易状态 $local = DB::table('orders')->where('order_no', $localOrderNo)->first(); if (!$local) { $mismatch[] = "本地无单: $localOrderNo"; continue; } if (bccomp($local->amount, $amount, 2) !== 0) { $mismatch[] = "金额不一致: $localOrderNo 本地{$local->amount} 支付宝$amount"; } if ($status == '交易成功' && $local->status != 1) { $mismatch[] = "状态不一致: $localOrderNo 本地{$local->status} 支付宝成功"; } } if (!empty($mismatch)) { file_put_contents('/var/log/pay_recon_' . date('Ymd') . '.txt', implode("\n", $mismatch), FILE_APPEND); } }

4.4 区分“网关超时”和“订单不存在”

EXE 查询时,支付宝网关可能返回三种结果:支付成功、支付中、订单不存在。其中“订单不存在”有两种原因:一是订单号录入错误,二是超过了支付宝网银的查询时间窗口。我建议 EXE 对“订单不存在”不做自动关闭,只记录日志,然后把这个订单转移到人工复核列表。因为有些银行的网银跳转延迟,支付宝侧可能暂时还没建立订单,等几秒再查就有了。

5. 部署细节与运行排错:从 Nginx 配置到 Windows 服务化

5.1 PHP 环境要求与 Nginx 配置要点

这套系统对 PHP 版本要求不高,7.4 到 8.2 都能跑,但必须开启curlpdo_mysqlbcmath扩展。Nginx 配置里要注意两个点:一是client_max_body_size不能设太小,EXE 回调时可能携带 cookie 数据;二是 PHP-FPM 的request_terminate_timeout要调大,因为回调商户接口时如果商户响应慢,会导致 PHP 进程卡住。

server { listen 80; server_name pay.example.com; client_max_body_size 2m; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php7.4-fpm.sock; fastcgi_param PHP_VALUE "request_terminate_timeout=60s"; fastcgi_read_timeout 60s; } }

try_filesindex.php是为了支持框架式路由。如果你拿到的源码是原生 PHP 文件,则不需要这个配置,直接指向文件即可。

5.2 数据库并发连接控制

EXE 轮询的频率越高,数据库连接消耗越快。PHP-FPM 默认pm.max_children只有 10 的话,回调一打过来就全堵住了。我在部署时按 8GB 内存的机器做如下调整:

pm = dynamic pm.max_children = 40 pm.start_servers = 10 pm.min_spare_servers = 5 pm.max_spare_servers = 20

另外所有 SQL 必须走预处理,防止回调用法注入。EXE 发送的order_no是数据库索引,但也要用prepare绑定参数,不要直接拼接字符串。

5.3 将 EXE 注册为 Windows 服务

监控 EXE 是 24 小时运行的程序,不能每次开机手动双击。常见的做法是用 NSSM 把它注册成服务:

nssm install PayMonitor "C:\prog\PayMonitor\monitor.exe" nssm set PayMonitor AppParameters "--config=C:\prog\PayMonitor\config.ini" nssm set PayMonitor AppRotateFiles 1 nssm set PayMonitor RotationBytes 52428800 nssm start PayMonitor

RotationBytes设置为 52428800(50MB),日志轮转不用自己写代码。服务失败时,NSSM 可以自动重启:

nssm set PayMonitor AppExit Default Restart nssm set PayMonitor RestartDelay 5000

5.4 常见问题排查

最常出现的问题是时区不一致。MySQL 的time_zone如果是默认的 UTC,而 PHP 用的date_default_timezone_set('Asia/Shanghai'),那么订单时间就会差 8 小时,对账脚本永远对不上。强制统一在 PHP 初始化时设置:

date_default_timezone_set('Asia/Shanghai'); $pdo->exec("SET time_zone = '+08:00'");

另一个坑是 Windows 防火墙默认拦截 EXE 对外访问,导致查询超时。在服务端看到大量超时日志时,先在 EXE 机器上执行:

ping pay.example.com curl http://pay.example.com/api/health

如果 curl 通但 EXE 不通,八成是防火墙规则只允许了浏览器进程,手动添加一项允许 TCP 出站即可。

6. 压测验证与运营中的几个坑

6.1 回调接口的并发压测

回调接口虽然是内网调用,但也要防住商户直接刷接口。用 Apache Bench 模拟 EXE 的并发回调,验证 PHP 接口是否扛得住:

ab -n 2000 -c 100 -p notify.json -T application/json \ -H "Authorization: Bearer test_key" \ http://pay.example.com/index.php/api/notify

notify.json里放一个测试订单号,压测前先在数据库插入 100 条待支付订单。压测后看 DB 里status=1的记录是否都正确,以及是否存在重复更新。如果出现死锁,优先检查订单表的索引策略。

6.2 运营中的三个常见坑

第一个坑是频繁修改支付宝账号的登录凭证会导致次日轮询全部失败。很多网银接口会校验会话环境,同一个账号在多个 IP 间切换后,EXE 查询会直接被拒绝。解决办法是给每个账号固定一个登录出口,EXE 在启动时校验凭证有效性,失效时发告警通知,而不是静默重试。

第二个坑是订单金额校验不严被刷单。我之前接手过一个被薅的系统,原因是回调接口只验订单号不验金额。攻击者先下单 1 元,再通过伪造回调构造任意金额的付款成功通知。压测验证时特意把金额比对放到状态更新之前,并且用bccomp而不是==

第三个坑是服务器时间偏差导致验签失败。支付宝 APP 支付用 RSA 验签时,如果服务器时间快慢超过 5 分钟,会直接验签失败。部署一套 NTP 时间同步,并在监控里加上时间偏移检查:

ntpdate -u ntp.aliyun.com

6.3 通过日志验证系统稳定性

上线后每天只需看两个指标:一是notify_flag从 0 变 1 的订单占比,正常情况应该接近 100%;二是 EXE 日志中QUERY_SUCCESS的总耗时。如果平均耗时超过 800ms,说明轮询间隔太短,账号可能已经被限流。此时将轮询间隔翻倍,观察 24 小时,直到平均耗时回落到 200ms 以内。对于日订单量在 5000 笔以下的小团队,这个方案在成本和可控性上远比接第三方支付要好。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询