简介:一套在线虚拟商品自动交易发卡平台源码,面向个人站长与虚拟商品卖家,基于ThinkPHP5与Layui2.2开发,支持支付宝、微信免签接口及第三方个人支付,可自动完成虚拟商品交易与发货,适合搭建发卡网、自动发货系统等场景。包内共2000个文件,以1137个PHP业务代码、136个PHPT模板、58个JS脚本、58个HTML页面和57个Markdown文档为主,同时包含CSS样式、图片素材、数据库SQL与配置文件,压缩包整体约13.39MB。已有653人学习下载。源码全开源,无域名与安装次数限制,附带安装教程;后台默认账号admin、密码123456,导入数据库并修改database.php即可快速部署。目录按ThinkPHP模块化组织,前端基于Layui,支付与自动发货逻辑完整,适合PHP开发者二次开发或直接上线运营。对于需要个人免签支付的虚拟商品商家,这份源码提供了完整的支付与发货闭环,值得收藏参考。
1. 免签接口源码与第三方个人支付:发卡平台自动交易的低门槛路径
做在线虚拟商品自动交易发卡平台,最绕不开的一环不是商品管理,不是卡密存储,而是支付。个人开发者去申请支付宝或微信的官方支付接口,往往卡在营业执照、企业资质和签约审核上。于是“免签接口”成了这个领域最常见的替代方案:平台在用户下单后生成收款二维码,用户扫码付款,平台通过某种方式收到支付成功的通知,再自动把卡密发货给用户。整个链路完全无人值守。这篇博文要讲的就是“第三方个人支付”这类免签接口的完整落地路径,从回调验签、订单状态机到自动发货和防掉单都在覆盖范围内,还会给出可直接改写的源码实现。适合正在自建发卡系统、想了解免签接口工作原理,或者被回调通知、掉单问题折磨过的开发者阅读。
2. 免签接口的两条实现路线与选型依据
2.1 路线一:使用第三方聚合支付平台
最常见的做法是接入市面上已有的“第三方个人支付”聚合平台。这类平台本身已经解决了个人收款和异步通知的技术难题,对外提供统一下单、订单查询、回调通知等接口。发卡平台只需要按照其文档接入,就能获得支付宝、微信的扫码支付能力。
// 第三方聚合支付统一下单示例(伪代码,参数名以实际平台文档为准) $params = [ 'merchant_id' => '你的商户号', 'order_id' => $orderNo, // 平台内部订单号 'amount' => $productPrice, // 金额,单位元 'notify_url' => 'https://your-domain.com/api/pay/notify', 'return_url' => 'https://your-domain.com/order/result', 'sign' => md5($merchantId . $orderNo . $productPrice . $apiKey) ]; $response = httpPost('https://pay.example.com/api/create', $params); // 返回中通常包含 payment_url 或 qr_code,前端展示二维码即可这里sign的生成规则各平台大同小异,常见做法是把除签名外的参数按 ASCII 码排序后拼接,加上商户密钥做 MD5。下单成功后,平台返回一个二维码地址或跳转链接,用户在手机上完成扫码付款。
2.2 路线二:自建监听方案
如果不想依赖第三方平台,可以选择自建免签方案:自己开发一个接收支付结果的后端服务,监听异步通知。这条路的工作量集中在两个地方:一是接收支付宝/微信官方服务器发来的回调通知,二是把通知与订单关联并触发发卡流程。
# Flask 接收异步通知示例(仅演示通知接收与验签框架) from flask import Flask, request import hashlib app = Flask(__name__) APP_KEY = "你的密钥" @app.route('/api/pay/notify', methods=['POST']) def pay_notify(): data = request.form.to_dict() sign = data.pop('sign', '') raw = '&'.join(f'{k}={v}' for k, v in sorted(data.items())) if hashlib.md5((raw + APP_KEY).encode()).hexdigest() != sign: return 'fail' # 验签失败,通知方会重试 order_no = data.get('out_trade_no') # 更新订单状态 + 触发发货,见第 3 章 return 'success' # 必须返回 success,否则支付方会反复通知自建方案的优势是没有中间平台抽成,缺点是所有支付渠道都要自己对接。对于发卡平台来说,绝大多数开发者会选择路线一,因为第三方个人支付平台已经把“个人收款码收款”与“程序化通知”之间的桥搭好了。
2.3 怎么选:看单量和回调稳定性
选择的关键指标只有一个:回调成功率。第三方个人支付平台的回调链路本身就是它最大的产品壁垒,稳定的平台能做到 99% 以上的回调到达率。自建方案的典型问题是:个人收款码无法程序化读取付款方信息,只能靠用户输入单号或金额尾数来匹配订单,这个体验会让自动交易大打折扣。
初创阶段的发卡平台,我一般建议直接接第三方个人支付,把精力放在卡密管理和自动发货上,支付这块交出去。单量上来后再评估自建方案,或者直接在第三方平台上走“商家代付”类产品,本质上是把免签接口升级成合规签约接口。
3. 发卡平台自动交易:从下单到自动发货的完整源码实现
3.1 订单表与商品表设计
自动交易发卡平台的核心数据模型比普通电商简单:商品需要预先导入卡密库存,订单需要记录支付状态和发货状态。下面是常用的两张表结构:
-- 商品与卡密表 CREATE TABLE `product_card` ( `id` int(11) NOT NULL AUTO_INCREMENT, `product_id` int(11) NOT NULL COMMENT '商品ID', `card_content` text NOT NULL COMMENT '卡密内容,可多行', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未售 1锁定 2已售', `sold_at` datetime DEFAULT NULL COMMENT '售出时间', PRIMARY KEY (`id`), KEY `idx_product_status` (`product_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '平台订单号', `product_id` int(11) NOT NULL, `amount` decimal(10,2) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已发货 3已关闭', `card_id` int(11) DEFAULT NULL COMMENT '发货的卡密ID', `pay_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT NOW(), PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单状态流转是自动交易的关键:用户在页面上下单时,系统先锁定一张卡密(status=1),同时生成一个待支付订单。回调到达且验签通过后,把订单置为已支付,卡密置为已售,返回卡密内容给用户。这样设计的核心目的是防止超卖——下单瞬间锁定库存,而不是等到支付成功再扣库存。
3.2 回调验签与订单状态更新
无论接哪家第三方个人支付,回调处理代码的结构都差不多。下面是 PHP 版本的完整处理逻辑:
public function handleNotify(Request $request) { $data = $request->all(); $sign = $data['sign'] ?? ''; unset($data['sign']); // 1. 验签:按 key 排序拼接 + 密钥 MD5 ksort($data); $str = urldecode(http_build_query($data)) . '&key=' . $this->apiKey; if (md5($str) !== strtolower($sign)) { return 'fail'; // 验签失败,让支付平台重试 } // 2. 校验订单存在 $orderNo = $data['order_id']; $order = Db::table('orders')->where('order_no', $orderNo)->first(); if (!$order) { return 'success'; // 订单不存在,不再重试,人工排查 } // 3. 幂等判断:已经是已支付/已发货,直接返回成功 if ($order['status'] >= 1) { return 'success'; } // 4. 金额校验:防止支付金额与订单金额不一致 if (abs(floatval($data['amount']) - floatval($order['amount'])) > 0.01) { return 'fail'; } // 5. 更新订单 + 发货(事务) Db::transaction(function () use ($orderNo) { Db::table('orders')->where('order_no', $orderNo) ->update(['status' => 2, 'pay_time' => date('Y-m-d H:i:s')]); $cardId = Db::table('orders')->where('order_no', $orderNo)->value('card_id'); Db::table('product_card')->where('id', $cardId) ->update(['status' => 2, 'sold_at' => date('Y-m-d H:i:s')]); }); // 6. 触发用户通知:短信、邮件或站内信,异步执行 Queue::push(new SendCardJob($orderNo)); return 'success'; }这段代码有四个关键点。验签失败必须返回非success内容,让支付平台继续重试,这是回调机制的基本约定。幂等判断必须放在金额校验之前,因为重复通知到达时订单已经发货,此时返回success可以终止通知循环。金额校验不能省,否则用户支付了错误金额也拿到了卡密。发货操作和订单更新必须在同一个事务里,避免出现订单已支付但卡密没发出去的情况。
这个方案的选型理由很直接:把回调处理做成同步事务,确保数据一致性优先;用户通知放在队列里异步执行,快速响应回调。对于发卡平台这个体量,Redis 队列或 MySQL 表驱动队列都足够了。
3.3 自动发货中的并发与性能边界
自动交易平台的另一个隐患是并发回调导致卡密多发或重复发。上面的代码用事务解决了订单状态与卡密状态的原子性,但无法解决“同一笔订单回调两次”与“同一张卡密被多笔订单锁定”的并发问题。
解决卡密锁定主要是下单环节的问题。常见的做法是使用条件更新代替先查后改:
// 下单时锁定卡密:只更新第一条未售的卡密 $locked = Db::table('product_card') ->where('product_id', $productId) ->where('status', 0) ->limit(1) ->update(['status' => 1]); if (!$locked) { return '库存不足'; }MySQL 的UPDATE语句会锁定匹配的行,两个并发请求同时执行时,第二个会等待第一个提交或回滚后才继续执行。这样可以天然避免两张订单锁定同一张卡密。需要明确的是,limit(1)在UPDATE中不是标准 SQL 语法,但 MySQL 和大多数国内云数据库都支持,这也是发卡平台最常见的实现方式。
回调的高并发场景可以用 Go 写一个轻量级的消费服务,用 channel 做并发控制:
// Go 并发消费回调通知示例 var sem = make(chan struct{}, 10) // 最多 10 个并发处理回调 func handleNotify(w http.ResponseWriter, r *http.Request) { sem <- struct{}{} // 获取信号量 defer func() { <-sem }() // 释放信号量 // 验签、幂等判断、更新订单发货 processOrder(r) w.Write([]byte("success")) }sem这个带缓冲的 channel 限制了同时处理的回调数量,避免瞬时回调洪峰打满数据库连接。发卡平台的订单峰值通常远低于这个量级,但加上这个控制成本极低,能防止支付平台批量补发回调时把接口拖垮。
3.4 掉单补偿机制
回调通知虽然稳定,但微信和支付宝都有通知超时重发的机制,重发间隔一般是 15 秒、30 秒、60 秒,最多 24 小时。如果在这期间发卡平台服务刚好重启或接口报错,回调就会丢失,用户的订单会一直停留在待支付状态。
解决办法是主动查询对账。第三方个人支付平台一般提供订单查询接口,发卡平台需要定时扫描超时未支付的订单,主动向支付平台确认支付状态:
// 每分钟执行一次的补单脚本 $pendingOrders = Db::table('orders') ->where('status', 0) ->where('create_time', '<', date('Y-m-d H:i:s', time() - 120)) ->limit(50) ->get(); foreach ($pendingOrders as $order) { $payStatus = $this->queryPayStatus($order['order_no']); if ($payStatus === 'paid') { $this->processPaidOrder($order); // 走和回调一样的发货逻辑 } }补单脚本的核心是复用回调那边的处理逻辑,不要单独写一套发货代码,否则很容易出现状态判断不一致。上面processPaidOrder内部直接调用与回调相同的发货方法。
4. 免签接口源码的部署链路与订单状态机优化
4.1 从提交订单到发货的完整链路图
发卡平台的自动交易链路可以拆成六个环节:用户在商城页面点击购买、请求后端生成订单并锁定卡密、弹出二维码或跳转收银台、第三方支付平台回调通知、后端验签并更新状态、系统推送卡密信息给用户。
这里有一个容易被忽视的细节:很多入行不久的开发者会把“自动发货”和“展示卡密”混为一谈。用户支付成功后,系统把卡密展示在订单详情页,这意味着订单接口必须同时校验用户身份和订单归属,确保只有购买者本人能看到卡密内容。最常见的实现是生成一个带签名的查询地址:
// 生成卡密查询链接,带有一次性 token $token = md5($orderNo . $cardId . $secretKey . $order['create_time']); $queryUrl = "https://your-domain.com/order/card?order_no={$orderNo}&token={$token}";token与create_time绑定,即使 URL 泄露也无法长期有效。这不是必要的复杂度,但虚拟商品交易里卡密被爬虫抓取、被分享到公开群组的案例实在太多了,加一层签名保护成本极低。
4.2 订单状态机:避免“已支付但未发货”的边界状态
订单状态从 0 到 3,四个状态的流转里藏着两处边界。第一处是下单锁定卡密后用户始终未支付,卡密一直被锁定直到订单关闭;第二处是支付回调到达但发货事务失败,订单状态停在 1,卡密状态仍在 1。
第二处边界最致命,因为用户付了钱却拿不到东西。解决思路是把“标记支付”和“实际发货”拆成两个步骤,发货失败时允许重试:
// 重试机制:订单已支付但尚未发货的,由定时任务统一补偿 $paidUnshipped = Db::table('orders') ->where('status', 1) // 已支付未发货 ->where('updated_at', '<', date('Y-m-d H:i:s', time() - 60)) ->limit(100) ->get(); foreach ($paidUnshipped as $order) { try { Db::transaction(function () use ($order) { Db::table('orders')->where('id', $order['id']) ->update(['status' => 2]); Db::table('product_card')->where('id', $order['card_id']) ->update(['status' => 2, 'sold_at' => date('Y-m-d H:i:s')]); }); } catch (\Exception $e) { // 记录日志,等待下轮重试 Log::error('发货失败: ' . $e->getMessage(), ['order' => $order['order_no']]); } }这个定时任务和 3.4 的补单脚本不同:补单解决的是“支付了但平台不知道”的问题,这里解决的是“平台知道了但发货动作失败”的问题。两个任务并存,才能把状态机拆干净。
4.3 服务部署:PHP-FPM 与队列进程的配合
发卡平台通常部署在一台低配云服务器上,Nginx + PHP-FPM + MySQL 是主流组合。回调接口作为 HTTP 接口运行在 PHP-FPM 里,异步消耗时间较长的任务(如发送邮件、推送卡密到用户微信)要放到队列进程里,避免回调接口耗时过长导致支付平台超时重试。
队列组件我一般用 Redis + PHP Resque 或者直接用 Laravel 的 Queue。下面是一个极简的 MySQL 队列实现:
CREATE TABLE `jobs` ( `id` int(11) NOT NULL AUTO_INCREMENT, `queue` varchar(50) NOT NULL DEFAULT 'default', `payload` text NOT NULL, `attempts` tinyint(4) NOT NULL DEFAULT '0', `reserved_at` datetime DEFAULT NULL, `available_at` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_queue_available` (`queue`, `available_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;// 消费脚本:命令行运行,supervisor 守护 while (true) { $job = Db::table('jobs') ->where('queue', 'default') ->where('available_at', '<=', date('Y-m-d H:i:s')) ->whereNull('reserved_at') ->orderBy('id') ->first(); if (!$job) { sleep(2); continue; } Db::table('jobs')->where('id', $job['id']) ->update(['reserved_at' => date('Y-m-d H:i:s')]); try { $handler = unserialize($job['payload']); $handler->handle(); Db::table('jobs')->where('id', $job['id'])->delete(); } catch (\Exception $e) { Db::table('jobs')->where('id', $job['id']) ->increment('attempts'); Log::error('队列任务执行失败', ['job_id' => $job['id'], 'error' => $e->getMessage()]); } }available_at字段用于延迟任务,比如发货后 10 分钟自动确认、48 小时未支付自动关闭订单,都可以通过它做轻量级定时调度,不需要额外引入 cron 脚本。发卡平台的队列积压不会很严重,MySQL 表队列的吞吐完全够用,需要提高并发时再考虑 Redis。
5. 免签接口落地后的 3 个验证方法与避坑技巧
5.1 验证回调验签逻辑:模拟支付平台发送通知
免签接口最难调试的就是回调验签。支付平台不会因为你在调试就给你反复发送通知,本地把通知模型写好、签名算法写对,再用脚本模拟通知请求是最好的验证方式:
# 模拟回调通知,验证验签与发货逻辑 curl -X POST https://your-domain.com/api/pay/notify \ -d "order_id=TEST20250101001&amount=19.90&pay_type=alipay&sign=$(php -r " \$data = ['order_id'=>'TEST20250101001','amount'=>'19.90','pay_type'=>'alipay']; ksort(\$data); echo md5(urldecode(http_build_query(\$data)).'&key=your_api_key'); ")"这个命令把签名生成和请求发送放在一起完成,能快速验证三个问题:签名算法是否和文档一致、回调接口返回的字符串是否为success、订单状态是否被正确更新为已支付并完成发货。注意 URL 里的&需要用引号包起来,否则 shell 会把后面的参数截断。
5.2 验证并发下不超卖:用 wrk 打并发下单
发卡平台的超卖是最难发现的隐性缺陷,因为日常量小根本不会暴露。用 wrk 做一次简单的并发测试,就能验证卡密锁定逻辑是否正确:
wrk -t 4 -c 50 -d 10s -s post.lua http://your-domain.com/api/order/create-t 4表示 4 个线程,-c 50表示保持 50 个并发连接,post.lua里写下单的 POST 参数和 Header。测试结束后,检查订单表里相同商品的订单数是否等于锁定的卡密数,再对比库存余量和已售数量。如果有超卖,订单数会大于卡密数,说明条件更新写错了或者没有在事务里执行。
5.3 避坑:回调地址必须是公网可访问
这是最基础也是最容易踩的坑。免签接口的回调地址必须能被支付平台公网访问,本机localhost或内网 IP 一律无效。开发调试时可以用内网穿透工具把本地端口暴露到公网,但生产环境一定要部署到云服务器上。回调地址的域名不要频繁更换,部分支付平台对新域名会有风控审核期,更换域名可能导致回调被拦截。同一笔订单被回调多次是常态,幂等判断不是可选项而是必选项。另外还要注意第三方个人支付平台的结算方式——有的平台支持 D+1 自动结算到银行卡,有的需要手动发起提现,结算周期直接影响发卡平台的资金周转,接入前要跟平台确认清楚。
免签接口的调试关键在日志。回调接口里把$_GET、$_POST、验签结果、返回内容全部写入日志文件,支付平台说“已发送回调”时,你只需要看日志就能定位是哪一步断了。日志格式建议写成 JSON,每条记录包含时间、订单号、事件类型和上下文,排查问题时按订单号过滤即可。
本文还有配套的精品资源,点击获取