简介:这是一套面向PHP开发者与中小商户的支付宝、微信免签约收款回调系统源码,基于ThinkPHP内核框架构建,配套安卓监控端与视频搭建教程,帮助使用者在无正式签约的前提下实现收款结果的实时回调与监控。压缩包共297个文件,约34.03MB,包含73个class类文件、63个jar依赖包、25个js脚本、16个html页面、13个css样式及11个png图片等,另有apk安装包、bat批处理与properties配置,覆盖后端服务、安卓端与部署脚本。系统核心涵盖支付回调处理、收款信息监控、数据统计与分析,教程从PHP环境搭建、数据库配置、框架安装到支付平台接入、安卓接口对接逐步展开,并讲解HTTPS加密通信、数据验证过滤与常见网络攻击防范等安全措施。目前已有187人学习,适合希望将免签约支付能力整合进自有应用、需要完整源码与视频指引的开发者参考。
1. 从「免签约」说起:V免签支付系统到底解决了谁的痛点
做过个人开发者或小团队收款的人都知道一个现实:想接支付宝或微信的官方支付接口,营业执照、对公账户、行业资质、审核周期,哪一样都不轻松。而 V免签支付系统这类方案,核心思路是把「收款」这件事从官方接口申请,转移到「个人收款码 + 到账监听」上——用户扫码付款,系统通过安卓监控端在手机上监听收款通知,识别到账金额和备注后,回调通知你的业务服务器,业务侧再自动发货或开通权限。标题里的 ThinkPHP 内核框架负责服务端逻辑,安卓监控端负责监听支付宝和微信的到账推送,两者通过 HTTP 回调串联。
这套东西适合谁?个人站长、小体量虚拟商品卖家、需要快速验证收款闭环的产品原型阶段。它不适合谁?日流水大、需要分账、需要退款、需要发票的场景——那些还是老老实实走官方接口。热搜里常出现的「支付宝回调」「微信支付接口」「支付宝沙箱支付」这些词,本质上都是同一类需求的不同实现路径,而 V免签走的是最轻的那条路。下面我把这套系统的服务端结构、监控端原理、回调链路和实际搭建中会遇到的坑,按能复现的顺序讲清楚。
2. ThinkPHP 服务端:订单、回调与签名的三件套
2.1 为什么这类系统偏爱 ThinkPHP 而不是 Spring Boot
热搜里有人问「我使用 gin-vue-admin 做了支付宝支付,但是怎么配置」,也有人搜「thinkphp 项目运行」。这其实反映了两种技术栈的取舍。V免签支付系统选 ThinkPHP 作为内核,原因很实际:部署门槛低。一台最便宜的虚拟主机,PHP 7.x 加 MySQL 就能跑起来,不需要 JDK、不需要额外容器。对于「个人收款」这种轻量场景,Spring Boot 的工程化优势用不上,反而增加了运维成本。
ThinkPHP 的另一个优势是路由和数据库操作的写法足够直白,改回调地址、加一个订单状态字段,对新手来说改起来不费劲。常见做法是把订单表、回调日志表、监控端心跳表放在同一个库,用 ThinkPHP 的模型关联去查。我一般会把回调日志单独存一份原始报文,因为后面排查「钱到了但订单没变状态」这种问题时,原始报文是唯一的后悔药。
2.2 订单创建与回调接收的最小实现
下面这段代码是服务端最核心的两个动作:创建订单、接收监控端回调。用 ThinkPHP 6 的写法,放在app/controller/Pay.php里。
<?php namespace app\controller; use app\BaseController; use think\facade\Db; class Pay extends BaseController { // 创建订单:业务侧调用,返回订单号和金额 public function create() { $amount = input('post.amount/f', 0); // 金额,浮点 $mark = input('post.mark/s', ''); // 备注,用于监控端匹配 if ($amount <= 0 || $mark === '') { return json(['code' => 0, 'msg' => '参数缺失']); } $orderNo = date('YmdHis') . mt_rand(1000, 9999); Db::name('order')->insert([ 'order_no' => $orderNo, 'amount' => $amount, 'mark' => $mark, 'status' => 0, // 0待支付 1已支付 'create_time'=> time(), ]); return json(['code' => 1, 'order_no' => $orderNo, 'amount' => $amount]); } // 监控端回调:安卓端监听到到账后 POST 过来 public function notify() { $raw = file_get_contents('php://input'); Db::name('notify_log')->insert([ 'raw' => $raw, 'create_time'=> time(), ]); $data = json_decode($raw, true); $amount = $data['amount'] ?? 0; $mark = $data['mark'] ?? ''; // 按备注+金额匹配待支付订单 $order = Db::name('order') ->where('mark', $mark) ->where('amount', $amount) ->where('status', 0) ->find(); if (!$order) { return json(['code' => 0, 'msg' => '无匹配订单']); } Db::name('order')->where('id', $order['id'])->update([ 'status' => 1, 'pay_time' => time(), ]); // 这里触发业务侧发货逻辑,比如开通会员 return json(['code' => 1, 'msg' => 'ok']); } }逻辑说明:create方法生成唯一订单号,把金额和备注写进订单表,备注是监控端匹配的关键字段。notify方法先把原始报文落库,再做匹配。参数方面,amount用浮点接收,但实际比对时建议转成整数分来避免浮点误差;mark是监控端从收款通知里提取的备注文本,长度和字符集要提前约定好,否则匹配不上。
提示:回调接口一定要做幂等。监控端可能因为网络重试发多次,
status字段的判断就是最简单的幂等锁。
2.3 监控端心跳与在线状态怎么维护
安卓监控端不是一直可靠的,手机会锁屏、会断网、会被系统杀进程。服务端需要一张心跳表来记录监控端最后一次上报时间,业务侧在创建订单前先检查监控端是否在线,不在线就提示用户稍后再试。
// 监控端每 30 秒上报一次 public function heartbeat() { $deviceId = input('post.device_id/s', ''); Db::name('device')->where('device_id', $deviceId)->update([ 'last_time' => time(), ]); return json(['code' => 1]); } // 业务侧检查在线:超过 90 秒没心跳视为离线 public function checkOnline() { $device = Db::name('device')->order('last_time', 'desc')->find(); $online = $device && (time() - $device['last_time'] < 90); return json(['online' => $online]); }参数说明:心跳间隔 30 秒、离线阈值 90 秒,这两个值可以根据手机省电策略调整。阈值设太短会频繁误报离线,设太长则用户付了钱但监控端其实已经挂了,订单一直不回调。我一般会把阈值设成心跳间隔的 3 倍,留出两次丢包的余量。
3. 安卓监控端:通知监听、金额提取与回调上报
3.1 通知监听服务的注册与权限申请
安卓监控端的原理不复杂:用NotificationListenerService监听支付宝和微信的收款通知,从通知文本里正则提取金额和备注,然后 POST 给服务端。难点全在权限和保活上。热搜里「解决非官方弹窗和拦」「微信提示版本过低怎么强制登录」这类词,侧面说明安卓端的环境适配有多碎。
第一步是在AndroidManifest.xml里声明服务:
<service android:name=".NotifyListener" android:label="收款监听" android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" android:exported="true"> <intent-filter> <action android:name="android.service.notification.NotificationListenerService" /> </intent-filter> </service>声明之后,用户必须手动在「设置 → 通知使用权」里勾选你的应用,这个权限没法用代码静默申请。常见做法是应用启动时检测权限,没开就跳转到系统设置页,并给一个图文引导。
3.2 从通知文本里提取金额和备注的正则写法
支付宝和微信的收款通知格式会随版本变化,所以正则不能写死,要留多个模式。下面是一个可用的提取逻辑:
@Override public void onNotificationPosted(StatusBarNotification sbn) { Bundle extras = sbn.getNotification().extras; String title = extras.getString(Notification.EXTRA_TITLE, ""); String text = extras.getString(Notification.EXTRA_TEXT, ""); String pkg = sbn.getPackageName(); // 只处理支付宝和微信 if (!pkg.equals("com.eg.android.AlipayGphone") && !pkg.equals("com.tencent.mm")) { return; } // 匹配金额:支持 0.01 和 1.00 两种格式 Pattern amountPat = Pattern.compile("([0-9]+\\.[0-9]{2})"); Matcher m = amountPat.matcher(text); if (!m.find()) return; String amount = m.group(1); // 备注通常在「备注」「附言」后面 String mark = ""; Pattern markPat = Pattern.compile("(?:备注|附言)[::]?\\s*(\\S+)"); Matcher mm = markPat.matcher(text); if (mm.find()) mark = mm.group(1); // 上报服务端 reportToServer(amount, mark, pkg); }逻辑说明:onNotificationPosted是系统回调,每来一条通知触发一次。先按包名过滤,只认支付宝和微信。金额正则匹配「数字.两位小数」,这是收款通知里最稳定的特征。备注提取用非贪婪匹配,因为备注后面可能跟其他文字。参数方面,amount和mark要和服务端约定好编码,中文备注建议 URL 编码后再传,避免乱码。
注意:微信和支付宝的通知文本在不同版本里差异很大,有的版本金额在
EXTRA_TEXT,有的在EXTRA_TITLE。稳妥做法是两个字段拼起来再匹配。
3.3 上报失败的重试与本地队列
手机网络不稳定是常态,上报失败不能直接丢掉。常见做法是在本地用 SQLite 存一个待上报队列,上报成功就删除,失败就保留,下次心跳时重试。
private void reportToServer(String amount, String mark, String pkg) { // 先入本地队列 db.insert("pending", amount, mark, pkg, System.currentTimeMillis()); // 立即尝试上报 new Thread(() -> { boolean ok = httpPost(serverUrl + "/pay/notify", buildJson(amount, mark, pkg)); if (ok) db.deleteByAmountMark(amount, mark); }).start(); }参数说明:本地队列要设一个上限,比如 500 条,超过就丢最旧的,防止数据库无限膨胀。重试间隔建议指数退避,第一次 5 秒,第二次 15 秒,第三次 60 秒,避免网络刚恢复时瞬间打爆服务端。
4. 回调链路联调:从扫码到订单状态变更的完整验证
4.1 用固定金额和固定备注做端到端测试
联调阶段最怕的是「不知道哪一环断了」。我的做法是先固定金额和备注,比如金额 0.01、备注test001,然后手动用另一个账号转账 0.01 并填备注test001,观察三个点:监控端有没有抓到通知、服务端有没有收到回调、订单状态有没有变。
验证监控端是否抓到通知,可以在监控端加一个本地日志页面,把每次onNotificationPosted的原始 title 和 text 显示出来。这一步能快速区分是「通知没来」还是「正则没匹配上」。热搜里「支付宝小程序抓包」「微信开发者工具」这些调试手段,思路是一样的——先看到原始数据,再谈解析。
4.2 回调日志表怎么设计才方便排查
回调日志表不要只存一个成功标记,要存原始报文、来源 IP、时间戳、匹配结果。下面这张表结构是我用过比较顺手的:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 自增主键 |
| raw | text | 监控端 POST 的原始 JSON |
| amount | decimal(10,2) | 解析出的金额 |
| mark | varchar(64) | 解析出的备注 |
| match_order | varchar(32) | 匹配到的订单号,空表示未匹配 |
| ip | varchar(46) | 来源 IP,兼容 IPv6 |
| create_time | int | 时间戳 |
有了这张表,排查「钱到了订单没变」时,先看raw里金额和备注对不对,再看match_order是否为空。为空通常是备注没填、金额对不上、或者订单已经被匹配过了。
4.3 金额精度与备注匹配的两个隐蔽问题
金额精度问题很隐蔽。PHP 里0.01这种浮点数直接比较可能出问题,比如0.1 + 0.2 != 0.3。稳妥做法是数据库存整数分,比较时也转成整数分。备注匹配的问题在于用户可能不填备注,或者备注里带空格、表情。常见做法是要求业务侧生成一个短备注,比如 6 位数字,并在下单页面明确提示用户「转账时务必填写备注」。
// 金额转整数分再比较 $amountCent = (int)round($amount * 100); $orderCent = (int)round($order['amount'] * 100); if ($amountCent !== $orderCent) { return json(['code' => 0, 'msg' => '金额不匹配']); }参数说明:round而不是intval,因为intval(0.29 * 100)可能得到 28。这个坑我在早期项目里踩过,一笔 0.29 的订单死活匹配不上,查了半天才发现是浮点截断。
5. 避坑与排查:监控端掉线、回调丢失、金额对不上
5.1 监控端频繁掉线,心跳时有时无
现象:服务端显示监控端离线,但手机明明亮着屏,支付宝通知也正常。
原因:安卓的省电策略会在锁屏后限制后台网络,尤其是国产 ROM。NotificationListenerService本身不容易被杀,但上报用的网络线程会被挂起。
解决:引导用户把应用加入电池优化白名单,并在应用内申请「自启动」权限。另外把心跳和上报合并到同一个前台服务里,前台服务有常驻通知,被杀的优先级低很多。
5.2 收到回调但订单状态没变
现象:回调日志表里有记录,raw里金额备注都对,但订单还是待支付。
原因:匹配条件太严,比如同时用mark和amount查,而用户转账时金额被银行扣了手续费,实际到账少了一分。
解决:匹配时以备注为主,金额允许一个容差,比如正负 0.01。或者干脆只用备注匹配,金额只做记录不做过滤。前提是备注足够唯一。
5.3 同一笔到账被重复回调
现象:用户付了一次钱,业务侧发了两次货。
原因:监控端上报成功但没收到服务端响应,触发重试;或者服务端处理超时,监控端认为失败又发了一次。
解决:服务端notify接口用订单号做唯一索引,插入回调记录时如果冲突就直接返回成功。同时订单状态更新用where status = 0做条件更新,保证只有第一次能改成功。
5.4 备注里带中文导致匹配失败
现象:用户备注写了中文,监控端抓到了,但服务端匹配不上。
原因:编码不一致。安卓端默认 UTF-8,但 HTTP 传输时如果没设Content-Type: application/json; charset=utf-8,PHP 可能按 ISO-8859-1 解析。
解决:监控端 POST 时显式设置 charset,服务端接收后先mb_convert_encoding统一转 UTF-8。备注生成时尽量用纯数字或字母,从源头避开这个问题。
5.5 支付宝通知里金额和实际到账不一致
现象:通知显示收款 10 元,但实际到账 9.99 元。
原因:某些收款场景有手续费或优惠抵扣,通知文本里的金额是订单金额不是到账金额。
解决:以通知文本里的金额为准做匹配,因为监控端只能看到通知。如果业务对金额敏感,建议在下单时就把金额和备注绑定,用备注做唯一匹配,金额只做辅助校验。
6. 进阶:把监控端做成可远程配置的多设备方案
单设备方案跑通之后,下一步通常是多设备冗余。一台手机挂了,另一台能顶上。这里的关键是把监控端的配置从本地硬编码改成服务端下发,包括回调地址、心跳间隔、匹配规则。下面是一个配置下发的接口设计:
public function getConfig() { $deviceId = input('get.device_id/s', ''); $config = Db::name('device_config')->where('device_id', $deviceId)->find(); if (!$config) { $config = [ 'notify_url' => 'https://your-domain.com/pay/notify', 'heartbeat' => 30, 'retry_max' => 5, 'match_mode' => 'mark', // mark / amount / both ]; } return json($config); }监控端启动时拉一次配置,之后每次心跳带上配置版本号,服务端发现版本变了就返回新配置。这样改回调地址不用重新打包 APK,对多设备管理很实用。
多设备还有一个问题:同一笔到账可能被两台手机同时监听到,导致重复回调。解决办法是在服务端做去重,用「金额 + 备注 + 时间窗口」做唯一键,比如 60 秒内相同金额和备注只处理一次。这个时间窗口不能太长,否则用户真的连续付两笔相同金额会被误杀。
验证多设备是否生效,可以手动把主设备断网,观察备用设备是否在心跳阈值内接管。我一般会写一个简单的压测脚本,模拟监控端每秒发一次回调,看服务端的去重和幂等逻辑扛不扛得住。
# 模拟 100 次重复回调,验证幂等 for i in $(seq 1 100); do curl -s -X POST https://your-domain.com/pay/notify \ -H "Content-Type: application/json" \ -d '{"amount":"0.01","mark":"test001"}' & done wait跑完之后查订单表,状态应该只变一次,回调日志表应该有 100 条记录但只有一条匹配成功。这个测试能暴露大部分幂等漏洞。
最后说个血泪经验:这套方案的稳定性上限不在代码,在手机。我见过太多人代码写得没问题,但手机一锁屏就掉线,最后业务侧投诉不断。如果要做生产环境,建议至少两台设备热备,并且把「监控端离线」做成业务侧可见的告警,别等用户付了钱才发现没人监听。希望帮到你。
本文还有配套的精品资源,点击获取