ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP话费充值系统:支付回调与订单状态机实战

PHP话费充值系统:支付回调与订单状态机实战 简介这套PHP话费充值通道网站完整运营源码面向需要自建话费充值平台、对接免签约支付接口的站长或开发者基于ThinkPHP框架解决了从服务器环境、支付通道到短信通知的一站式搭建难题尤其适合已有PHP基础、希望快速上线运营的团队。压缩包共1849个文件大小约145.53MB核心为PHP业务逻辑代码辅以PNG/GIF/JPG等前端图片、JS/CSS模板样式、HTML页面、DAT缓存数据以及SQL数据库文件应用目录、配置目录和静态资源划分清晰方便定位修改。资源附带视频搭建教程和完整测试账号涵盖宝塔面板环境配置、伪静态规则、数据库导入、后台入口默认admin账号等关键环节同时针对Z支付免签约支付接口的商户参数填写、短信宝短信接口对接、充值限制条件修改均给出说明可帮助读者快速打通在线支付与短信通知链路。已有627人学习浏览适合用作话费、流量等虚拟商品交易场景的初始版本参考在二次开发中也能减少重复搭建成本。1. PHP话费充值通道网站关键不在充值页面在回调闭环当聊到 PHP 话费充值通道网站很多第一次接触完整运营源码的人会盯着前端页面和支付按钮真正落到生产环境时第一件要理清的却是“支付成功后由谁来告诉服务器”。话费充值通道的链路是用户下单 → 在线支付 → 支付平台回调服务端 → 订单由待支付变成已支付待提交 → 异步交给上游话费通道 → 上游回填结果 → 展示到用户端。这里的每一环都用 PHP 代码串联任何一环断掉用户就会看到“已扣款但未到账”。这篇文章从表结构设计、免签约支付接口验签、Nginx 与 PHP-FPM 部署到最后的幂等压测拆开一套可运营的话费充值系统。适合懂基础 PHP、读过一点源码想把源码从“能跑”改成“能运营”的开发者和站长。源码可以拿到手但直接跑 demo 和正式放量完全是两件不同的事。2. 从表结构开始PHP 话费充值系统的订单、商品与通知流水拿到源码第一件事不是急着开后台而是把数据表打开看清楚订单状态和支付回调在数据库里怎么流动。很多“完整运营源码”把业务字段塞进一张大表里订单、回调、上游申请记录混在一起后面出了问题极难排查。我一般会用四张核心表来拆分goods放商品面值orders放每一次充值notify_log放所有支付和上游回调channels放可用的上游通道。先看建表。2.1 四张核心表goods、orders、notify_log、channelsCREATE TABLE goods ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 商品名如联通50元, face_value DECIMAL(10,2) NOT NULL COMMENT 充值面值, sale_price DECIMAL(10,2) NOT NULL COMMENT 销售价, cost_price DECIMAL(10,2) NOT NULL COMMENT 上游成本用于算毛利, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_sn VARCHAR(32) NOT NULL COMMENT 平台唯一订单号, user_id INT UNSIGNED NOT NULL, goods_id INT UNSIGNED NOT NULL, goods_name VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL COMMENT 充值号码, face_value DECIMAL(10,2) NOT NULL, pay_amount DECIMAL(10,2) NOT NULL COMMENT 用户实付, channel_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 最终使用的上游通道ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付待提交 2已提交上游 3成功 4失败 5已退款, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, finish_time DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_sn (order_sn), KEY idx_user_id_status (user_id, status), KEY idx_status_create_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;orders是整个系统的唯一真相其他表都要围绕它。order_sn必须唯一索引所有外部回调、上游流水都以这个字段关联goods_name冗余存储是为了在商品改价或下架后订单记录仍然可读channel_id初始为 0等到真正选择通道后回填这样便于对账。CREATE TABLE notify_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_sn VARCHAR(32) NOT NULL, notify_type VARCHAR(16) NOT NULL COMMENT pay/supplier, raw_data MEDIUMTEXT NOT NULL COMMENT 原始回调请求原文, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order_sn (order_sn), KEY idx_type (notify_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE channels ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT 通道名称, api_url VARCHAR(255) NOT NULL, app_id VARCHAR(64) NOT NULL, app_secret VARCHAR(128) NOT NULL, priority TINYINT NOT NULL DEFAULT 100 COMMENT 数值越小越优先, balance DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 上游预存款余额, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;notify_log故意只做流水不做唯一键。支付平台通常会重试多次每次回包都可能不同全量留痕才能在出问题时回放现场重复判断交给订单状态字段去控制而不是靠删日志。channels表把密钥和 API 地址集中管理比写在 config 文件里方便做多通道调度时还能直接走 SQL 排序。下面这张表是这四张表在业务上的分工表名作用写入时机关联维度goods商品面值、成本、售价后台上下架时goods_idorders每笔充值的最终状态下单、支付、回调、查单order_snnotify_log回调原始报文留存收到支付/上游回调时order_snchannels上游通道配置与余额开通、停用、调价时channel_id2.2 下单时先锁余额再写订单用 PDO 事务避免超卖话费充值网站最大的资金风险不是页面刷量而是用户在下单的一瞬间余额被两个人同时扣成负数。常见做法是用一条UPDATE ... WHERE balance fee加事务在同一行记录上做原子判断避免“先 SELECT 再 UPDATE”的竞态。public function createOrder(int $userId, int $goodsId, string $phone): string { $pdo $this-db; $pdo-beginTransaction(); try { $goods $this-loadGoods($goodsId); if (!$goods || $goods[status] ! 1) { throw new \RuntimeException(商品不可用); } $stmt $pdo-prepare( UPDATE user_balances SET balance balance - :fee WHERE user_id :uid AND balance :fee ); $stmt-execute([ fee $goods[sale_price], uid $userId, ]); if ($stmt-rowCount() ! 1) { throw new \RuntimeException(余额不足或账户不存在); } $orderSn date(YmdHis) . str_pad((string) mt_rand(0, 999999), 6, 0, STR_PAD_LEFT); $stmt $pdo-prepare( INSERT INTO orders (order_sn, user_id, goods_id, goods_name, phone, face_value, pay_amount, status) VALUES (?, ?, ?, ?, ?, ?, ?, 1) ); $stmt-execute([ $orderSn, $userId, $goodsId, $goods[name], $phone, $goods[face_value], $goods[sale_price], ]); $pdo-commit(); return $orderSn; } catch (\Throwable $e) { $pdo-rollBack(); throw $e; } }这个下单示例默认走账户余额用户点击“充值”时生成一张status1的订单同时扣掉余额。关键在WHERE balance fee如果余额不够MySQL 直接不更新行rowCount()返回 0业务抛异常。order_sn用时间加 6 位随机数虽然并发下撞号概率极低仍有唯一索引兜底撞号时事务整体回滚不会出现扣了钱但订单查不到的情况。订单状态在下单这一段的默认值0待支付留给在线支付回调1表示余额已经扣掉的待提交充值单。很多源码把这两种状态混在一起导致钱包余额支付和支付平台回调共用一套逻辑对账很痛苦。建议从开始就分开理解支付完成不等于充值开始中间隔着队列和通道调度。2.3 上游通道调度把接口差异收敛到一张接口表话费充值的“通道”概念很好理解上游供应商提供的 HTTP 接口提交手机号和面值就能发起一笔直充。不同通道接口的字段名、签名方式、查询频率都不一样但业务侧只需要三个动作——提交订单、查询订单、查询余额。把它抽象成 PHP 接口后面加新通道时只写一个实现类。interface SupplierInterface { public function submit(string $orderSn, string $phone, float $faceValue): array; public function query(string $orderSn): array; public function balance(): float; }调度器加载channels表优先取status1且balance大于当前面值的通道再按priority升序选择。更精一点的做法是把近 24 小时成功率缓存到 Redis调度时把成功率作为一个排序因子但第一版不用做复杂权重算法先把通道选择打成独立方法后面替换策略时不影响订单主流程。3. 对接免签约支付接口签名校验、幂等发货与状态机免签约支付接口在话费充值场景里很常见支付平台提供商户号、密钥、收款链接用户在收银台完成扫码支付后平台用服务器异步 POST 通知你的回调地址。免签约相比微信/支付宝官方接口省掉了商户签约流程但也把令牌、证书都简化成一对密钥所有安全责任几乎都落在“验证签名”和“回调幂等”上。3.1 回调请求里到底有什么常见免签约支付接口回调字段类似pid、trade_no、out_trade_no、type、name、money、trade_status、sign。out_trade_no对应本系统的order_snmoney是用户实际支付金额sign是按约定拼接的签名。支付平台发出回调后如果接口没有返回固定成功文本会按 1 分钟、5 分钟、30 分钟这样的间隔不断重发所以回调处理时长要尽量控制在秒级更不要在里面做网络请求。验签过程不复杂常见做法是剔除sign和sign_type把剩余参数按键名做ksort用 URL query 风格拼接加上商户密钥后做一次散列计算再与传来的sign用hash_equals对比。要注意拼接函数用的 URL 编码风格http_build_query默认会把空格编码成而很多接口要求%20如果签名一直不对先核对这条。public function payNotify(): void { $params $_POST; if (empty($params[sign]) || empty($params[out_trade_no])) { exit(fail); } $sign $params[sign]; unset($params[sign], $params[sign_type]); ksort($params); $signStr urldecode(http_build_query($params, , , PHP_QUERY_RFC3986)); $expect strtoupper(md5($signStr . $this-merchantSecret)); if (!hash_equals($expect, strtoupper($sign))) { exit(fail); } $this-handlePaidOrder((string) $params[out_trade_no], $params); echo success; }PHP_QUERY_RFC3986表示 urlencode 时使用%20而不是ksort是几乎所有支付接口签名规则都要求的字典序md5之后用strtoupper再比较是为了兼容大小写差异hash_equals是常量时间比较函数能避免用时序攻击推断出密钥。整个方法最后只输出一行success不要输出 JSON、HTML否则渠道会认为通知失败并重复回调。3.2 回调处理必须用行锁和状态判断双保险签名校验通过后接下来要处理“同一笔支付通知到达多次”和“回调与队列并发”两个问题。这里不推荐直接用INSERT INTO notify_log做唯一键去重因为前面说过日志要保留全量现场。正确做法是在orders行上加SELECT ... FOR UPDATE再通过UPDATE ... WHERE status 0保证只有第一次能修改成功。public function handlePaidOrder(string $orderSn, array $raw): void { $this-pdo-prepare( INSERT INTO notify_log (order_sn, notify_type, raw_data) VALUES (?, ?, ?) )-execute([$orderSn, pay, json_encode($raw, JSON_UNESCAPED_UNICODE)]); $this-pdo-beginTransaction(); try { $stmt $this-pdo-prepare(SELECT id, status FROM orders WHERE order_sn ? FOR UPDATE); $stmt-execute([$orderSn]); $order $stmt-fetch(); if (!$order) { $this-pdo-commit(); exit(fail); // 订单不存在让支付平台重试并人工排查 } if ($order[status] ! 0) { $this-pdo-commit(); echo success; return; } $up $this-pdo-prepare( UPDATE orders SET status 1, pay_time NOW() WHERE id ? AND status 0 ); $up-execute([$order[id]]); if ($up-rowCount() ! 1) { $this-pdo-commit(); echo success; return; } $this-pdo-commit(); $this-redis-lpush(queue:charge, $orderSn); } catch (\Throwable $e) { $this-pdo-rollBack(); $this-logger-error(notify error: {$orderSn} . $e-getMessage()); exit(fail); } }行锁把同一订单的并发回调串成队列第二个回调会等第一个 commit 之后才读到status一旦发现已经不为0就直接success不再重复入队。rowCount()的二次判断是给极端情况兜底虽然锁范围内理论不会发生但在数据库隔离级别异常时仍能挡住。最后lpush放在事务 commit 之后worker 拉取到的都是已提交数据如果进程在 commit 后、入队前崩溃就靠稍后的定时任务把status1超过 2 分钟的单捞回队列。3.3 状态机支付回调、上游提交、成功失败必须分清楚推荐用一张状态表约束整个网站逻辑。订单状态字段如果只是随便存个字符串后面报表统计和异常恢复都会失控。常见做法是把状态定义成常量或者数组不允许业务代码里直接写数字魔法值。状态值含义触发事件下一步业务动作0待支付支付回调验签通过改为1入队提交1已支付待提交队列 worker 取出提交上游改为22已提交上游上游返回成功改为32已提交上游上游明确失败改为4并退款/人工2已提交上游超时未返回主动查单按查询结果更新3充值成功完成通知用户记录完成时间4充值失败处理异常退款或转入人工5已退款人工/自动退款完成关闭订单这里最容易被忽略的是2状态下的主动查单。很多通道不会回推结果或者回推晚于用户可接受的时间系统应有一个status2且超过 5 分钟未完成的订单列表周期性调用上游query()方法把最终的success/fail写回来。没有主动查单订单就会卡死在“已提交”用户投诉后只能靠人工翻数据库改状态。另外免签约支付接口这层要单独看待部分渠道回调地址不需要域名备案、不需要白名单还能直接转发到内网但这不代表安全。生产环境尽量选择有支付牌照的服务商至少要对回调来源做 IP 白名单或渠道标识校验开发测试时用 1 元以内的小额单避免把正式密钥暴露在公网演示环境里。免签约只免了签约流程没免掉服务端对订单状态的校验责任。4. 搭建教程里最容易漏的三个环境操作伪静态、队列、定时任务很多视频搭建教学只拍到“后台能登录、商品能上架”就停了真正决定能不能运营的是 Web 服务器配置、PHP 异步消费队列和兜底定时任务。下面按 Nginx PHP-FPM Redis 的常见组合把三个步骤串成可复制的一套配置。4.1 Nginx 伪静态不要用 pathinfo 模式跑生产站如果充值系统用的是 ThinkPHP、Laravel 这类框架Nginx 默认配置并不能直接支持控制器路由。最常见的问题是把controller/action当成真实目录去访问返回 404。视频里出现“伪静态未开启”时先检查try_files这一行。server { listen 80; server_name charge.example.com; root /var/www/charge/public; index index.php; location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_read_timeout 120s; } }try_files $uri $uri/ /index.php$is_args$args的含义是先找真实存在的静态文件找不到就交给public/index.php去解析路由这是 Laravel/ThinkPHP 最通用的写法。fastcgi_read_timeout设成 120 秒是为了防止支付平台回调或上游接口响应慢时Nginx 提前返回 504导致渠道重试风暴。生产环境建议再补一层强制 HTTPS 跳转回调地址全部用 HTTPS。4.2 PHP-FPM 参数按内存计算并发不要照抄视频视频教程常出现“用宝塔默认配置”这类说法默认动态进程池在低配服务器上很容易被支付回调和高并发订单压崩。核心参数是进程数、请求监听和进程回收。pm dynamic pm.max_children 20 pm.start_servers 4 pm.min_spare_servers 2 pm.max_spare_servers 8 pm.max_requests 500计算方法先看 PHP 单进程常驻内存执行ps aux | grep php-fpm用 RSS 平均值估算。一般 2GB 内存机器给 20 个 php-fpm 进程每个按 80MB 估算占用 1.6GB再留出 MySQL 和 Redis 的空间。max_children太小扛不住下单太大直接内存不足触发 OOM。max_requests500让进程处理 500 次请求后自动退出重建能洗掉 PHP 脚本里的隐性内存泄漏。要调整的参数不多重点看这三个参数作用100并发的建议值pm.max_children最大PHP进程数20pm.max_requests进程回收周期500request_terminate_timeout单请求最大执行时间60s修改完执行sudo systemctl reload php8.1-fpm注意reload与restart的差别reload 会保留正在处理的连接避免在线充值请求断掉。4.3 用 Supervisor 常驻充值 worker别把上游请求写在回调里免签约支付回调里如果直接 curl 上游用户会看到充值页面一直转圈渠道还容易因为响应慢而重试同一笔单。正确做法是把订单号推入 Redis 列表由常驻 worker 消费提交上游成功后回写状态。[program:charge_worker] commandphp /var/www/charge/bin/charge_worker.php process_name%(program_name)s_%(process_num)02d numprocs2 autostarttrue autorestarttrue userwww-data redirect_stderrtrue stdout_logfile/var/log/charge-worker.log保存为/etc/supervisor/conf.d/charge_worker.conf然后执行sudo supervisorctl reread sudo supervisorctl update sudo supervisorctl status charge_worker:*numprocs2表示启动两个 worker 进程同时消费同一个 Redis List。因为同一个订单号可能被两个进程同时取出worker 里必须先执行SELECT ... FOR UPDATE或UPDATE orders SET channel_id?, status2 WHERE id? AND status1只有影响行数为 1 的进程才允许向上游提交。autorestarttrue保证 PHP 脚本异常退出后立刻拉起redirect_stderr把 PHP 错误日志写入同一文件便于定位。4.4 定时任务兜底让卡单自动回到队列队列再完整也挡不住外部网络抖动必须有一组定时任务做补偿。下面三行 crontab 是充值系统的底线* * * * * php /var/www/charge/bin/sweep_pending.php */5 * * * * php /var/www/charge/bin/query_supplier_status.php 0 3 * * * php /var/www/charge/bin/daily_reconcile.php第一行每分钟扫描status1且create_time小于两分钟前的订单重新lpush到queue:charge。第二行每 5 分钟把status2超过 5 分钟的订单调上游查询写回成功或失败。第三行每天凌晨 3 点拉取支付平台前一天的账单对orders、notify_log、余额流水做总额比对找出“有支付无订单”或“有订单无支付”的差异项。定时任务所在的 PHP 脚本要和 Web 进程隔离运行不要在网页控制器里调用。如果服务器上同时存在多个源码包务必给脚本加上declare(strict_types1)和入口文件锁用flock防止定时任务重叠执行一个卡单扫描脚本如果跑超过一分钟下一个周期要有LOCK_EX | LOCK_NB跳过的机制否则堆积的全表查询会拖垮数据库。4.5 拿到完整运营源码后先做三件安全动作视频搭建教程通常到“访问安装目录完成配置”就结束了。上线前我建议立刻做三件事第一删除/install或任意安装目录避免重装覆盖数据库第二修改后台默认地址把admin改成不可预测字符串第三关闭 PHP 错误显示display_errors Off日志走/var/log/php_errors.log。这三条不写进配置过两周大概率会被扫描器盯上原因不再是代码逻辑问题而是常识遗漏。5. 压测回调幂等连续 100 次相同支付通知手机号只能收到一次充值最后一章用一个可执行技巧收尾模拟支付平台并发回调验证“一个订单只提交一次上游”。这个实验在本地或预发环境跑能提前发现回调代码里最常见的重复发货问题。5.1 用循环 curl 模拟重试风暴准备一个从未被消费的out_trade_no调用 100 次支付通知接口观察最终只有第一次将订单从等待支付改为已支付待提交。签名字符串需要先按渠道规则生成这里用占位符代替。for i in $(seq 1 100); do curl -s -X POST http://127.0.0.1/pay/notify \ -d out_trade_no202506170001money50.00sign签名字符串 \ -o /dev/null -w %{http_code}\n done由于验签通过后接口返回success100 次循环的 HTTP 状态都会是 200只有数据库状态和上游提交记录能看出差别。注意这里要在同一台机器上对本地 Nginx 跑不要拿线上真实支付平台的回调地址做实验否则渠道接收到 100 次实际上等同重放的通知只能加重对方服务压力。5.2 三张表验证结果请求结束后分别查orders、notify_log、上游提交记录三张表SELECT order_sn, status, pay_time FROM orders WHERE order_sn 202506170001; SELECT COUNT(*) FROM notify_log WHERE order_sn 202506170001 AND notify_type pay; SELECT COUNT(*) FROM supplier_submit_log WHERE order_sn 202506170001;结果应该满足notify_log的COUNT(*)接近 100 或等于 100证明 100 次回调每次都真实到达orders.status是1表明只有第一次把状态从 0 改成 1后续全部被状态判断挡下上游提交记录的COUNT(*)必须等于 1说明上游只被提交一次。如果源码里没有单独的上游提交表可以用notify_log表notify_typesupplier的计数代替。如果提交计数大于 1排查顺序是先看 worker 里是否在UPDATE前执行了SELECT判空再看UPDATE是否带上了status1条件最后看 Redis 消费端有没有重复投递到同一订单的通道。这个压测还顺带验证了notify_log的写入性能。支付平台重试风暴下每笔回调都会产生一条日志写库如果日志表没有建order_sn索引在高频重试时会拖慢主库。日志写入可以放进独立连接或直接异步化但主表的 CAS 更新必须和回调在同一个事务里这是充值系统本质上绕不开的原子性边界。把重复通知屏蔽在上游之前比依赖支付渠道“只通知一次”更可靠。做一遍上述实验再上生产单边账数量会明显少很多。本文还有配套的精品资源点击获取
返回列表