简介:这是一套面向Java后端开发者与支付系统学习者的手机话费充值系统源码,覆盖直充、快充、慢充等典型业务模式,可用于搭建充值平台原型或研究第三方支付对接流程。压缩包共约2000个文件,整体175.89MB,以335个java源文件与377个class编译文件为核心,配合252个jsp页面、336个js脚本、148个css样式及179个xml配置,构成较完整的Web工程结构;另有25个txt说明、32个properties配置、5个jar依赖及少量图片、字体与sql脚本,便于还原运行环境。内容涉及订单控制、支付宝与运营商接口调用、支付服务封装及爬虫辅助类等模块,可帮助读者理解充值下单、渠道对接与状态回调的实现思路。目前已有1241人学习下载,适合具备一定Java Web基础、希望深入支付与充值业务场景的开发者参考。
1. 话费充值系统源码拆包:直充、快充、慢充到底差在哪
前阵子有个做本地生活服务的朋友找我,说想在自己已有的会员商城上加一个话费充值入口,用户下单后能自动到账那种。他手里已经拿到了一份话费充值系统源码,标题写着“话费直充快充慢充系统源码”,但打开一看,目录里一堆渠道适配层、订单状态机、回调验签模块,完全不知道从哪下手。这其实是很多做软件/插件方向从业者的共同处境——资源到手了,但“直充、快充、慢充”这三个词到底对应什么业务逻辑、源码里哪些文件是核心、跑起来需要接什么上游,没人讲清楚。
这份源码本质上是一套话费充值订单调度系统,核心解决的是“用户付钱之后,话费怎么安全、稳定地充到目标号码”这件事。它把充值请求按到账时效拆成三种模式:直充走官方或一级渠道,通常几秒到几分钟到账;快充走聚合通道,到账时间稍长但成功率有兜底;慢充走批量代充或低价通道,适合对时效不敏感、追求低成本的场景。源码里已经封装好了订单创建、渠道路由、状态回调、对账补单这些模块,你要做的是把上游渠道的接口对接到它的适配层里。
适合谁看?如果你手里有商城、小程序、公众号或者任何需要话费充值能力的流量入口,想自己控订单和资金流,而不是纯给第三方导流,这份源码值得拆一遍。但如果你指望开箱即用、不接任何上游就能跑通,那它不适合你——它是一套调度框架,不是渠道本身。下面我按实际部署顺序,把渠道对接、订单状态机、回调验签、并发控制和避坑点逐个拆开讲。
2. 渠道适配层怎么接:从接口签名到路由策略
2.1 先搞清楚源码里的渠道抽象结构
打开源码根目录,通常能看到channel或adapter这样的文件夹,里面按渠道名分了子目录。每个子目录下一般有三个核心文件:config.php(或.json)、Client.php、Notify.php。Client负责发起充值请求,Notify负责接收上游异步回调,config存商户号、密钥、网关地址这些参数。
我一般会先看Client.php里的submit方法签名,它决定了你对接新渠道时要实现哪些参数。常见做法是定义一个抽象基类,比如AbstractChannel,里面声明submitOrder($order)和verifyNotify($data)两个抽象方法,所有具体渠道继承它。这样你新增渠道时只需要实现这两个方法,路由层不用动。
// 抽象渠道基类,定义统一契约 abstract class AbstractChannel { protected $config; public function __construct(array $config) { $this->config = $config; } // 提交充值订单,返回渠道侧订单号或失败原因 abstract public function submitOrder(array $order): array; // 验证异步回调签名,返回布尔值 abstract public function verifyNotify(array $data): bool; // 查询订单状态,用于主动补单 abstract public function queryOrder(string $channelOrderNo): array; }这段代码的关键在于把“提交、验签、查询”三个动作标准化。参数说明:$order数组里至少包含order_no(本地订单号)、phone(充值号码)、amount(面值)、notify_url(回调地址)。submitOrder返回的数组里要有code、channel_order_no、msg三个字段。逻辑上,路由层拿到用户下单请求后,根据面值和当前渠道成功率选一个渠道实例,调submitOrder,拿到渠道单号后把本地订单状态置为“充值中”。
2.2 路由策略:直充、快充、慢充怎么选
源码里一般会有一个Router类或dispatch方法,核心逻辑是根据订单属性选渠道。直充渠道通常成本高但成功率高,快充渠道成本中等、有并发限制,慢充渠道成本低但到账慢。我见过不少翻车案例,是把所有订单都往直充渠道灌,结果上游限流,整个系统卡死。
一个可用的路由策略是加权轮询加降级:给每个渠道配一个权重和最大并发数,订单进来先按权重选,如果该渠道当前并发已满或最近失败率超过阈值,自动降级到下一个。源码里如果有channel_route表,通常会存channel_id、weight、max_qps、status这些字段。
// 简化版路由选择:按权重和健康状态筛选可用渠道 public function selectChannel(array $order): ?AbstractChannel { $channels = $this->channelModel->getEnabledChannels($order['amount']); $candidates = []; foreach ($channels as $ch) { // 跳过并发已满或近期失败率超标的渠道 if ($ch['current_qps'] >= $ch['max_qps']) continue; if ($ch['fail_rate'] > 0.3) continue; $candidates[] = $ch; } if (empty($candidates)) { // 全部不可用时走兜底慢充渠道 return $this->getFallbackChannel(); } // 按权重随机选一个 return $this->weightedRandom($candidates); }参数说明:max_qps是渠道侧允许的每秒最大请求数,fail_rate是最近 100 笔的失败比例。逻辑上先过滤掉不可用渠道,再从可用池里按权重随机。注意fail_rate的统计窗口不要太长,否则渠道恢复后流量回不来。常见做法是用 Redis 的滑动窗口计数,每笔订单结果写入,查询时算最近 N 笔的失败率。
2.3 对接新渠道的实操步骤
假设你要接一个上游,对方给了你商户号和密钥,文档里说提交地址是https://api.example.com/recharge,签名方式是 MD5 拼接。操作顺序如下:
第一步,在channel目录下新建渠道文件夹,比如NewChannel,复制一份现有渠道的Client.php和Notify.php过去改。
第二步,改Client.php的submitOrder,按对方文档拼参数。常见坑是对方要求参数按字母序拼接后再加密钥做 MD5,顺序错了签名就过不了。
public function submitOrder(array $order): array { $params = [ 'merchant_no' => $this->config['merchant_no'], 'out_trade_no' => $order['order_no'], 'phone' => $order['phone'], 'amount' => $order['amount'], 'notify_url' => $order['notify_url'], 'timestamp' => time(), ]; // 按字母序排序后拼接密钥做签名 ksort($params); $signStr = urldecode(http_build_query($params)) . '&key=' . $this->config['secret']; $params['sign'] = strtoupper(md5($signStr)); $resp = $this->httpPost($this->config['gateway'], $params); $data = json_decode($resp, true); return [ 'code' => $data['code'] ?? -1, 'channel_order_no' => $data['order_no'] ?? '', 'msg' => $data['msg'] ?? 'unknown', ]; }第三步,改Notify.php的verifyNotify,按对方回调文档验签。注意回调数据可能是POST也可能是GET,源码里一般会统一用$_REQUEST取。
第四步,在后台渠道管理里新增一条记录,填好商户号、密钥、网关、权重、最大并发,状态设为启用。然后下一笔测试单,看订单状态能不能从“充值中”变成“成功”。
3. 订单状态机与回调验签:别让回调把你系统打穿
3.1 状态流转设计:从待支付到终态
话费充值订单的状态比普通商品订单多一层,因为涉及上游异步回调。源码里通常定义这几个状态:pending(待支付)、paid(已支付待提交)、submitted(已提交上游)、success(充值成功)、failed(充值失败)、refunded(已退款)。状态流转必须是单向的,不能从success回到submitted。
我一般会在数据库里加一个status_log表,每次状态变更都插一条记录,包含order_no、from_status、to_status、remark、created_at。这样出问题时能回溯是哪一步卡住了。常见做法是用乐观锁更新状态,SQL 里带WHERE status = '旧状态',更新影响行数为 0 就说明有并发冲突,直接放弃本次变更。
-- 状态变更时带旧状态条件,防止并发覆盖 UPDATE recharge_order SET status = 'success', channel_order_no = ?, updated_at = NOW() WHERE order_no = ? AND status = 'submitted';如果这条 SQL 返回影响行数为 0,说明订单已经被其他回调或补单任务改过了,本次回调直接返回成功给上游即可,不要重复处理。
3.2 回调验签:三个必须检查的点
上游回调是外部请求,不能信任。源码里Notify.php的verifyNotify方法必须做三件事:验签、验金额、验订单状态。验签是防伪造,验金额是防篡改,验订单状态是防重复。
验签逻辑按渠道文档实现,常见的是 MD5 或 RSA。RSA 验签要注意公钥格式,很多上游给的是 PKCS8 格式,PHP 里需要用openssl_pkey_get_public配合正确的头尾拼接。
public function verifyNotify(array $data): bool { // 1. 验签 $sign = $data['sign'] ?? ''; unset($data['sign']); ksort($data); $signStr = urldecode(http_build_query($data)) . '&key=' . $this->config['secret']; if (strtoupper(md5($signStr)) !== strtoupper($sign)) { return false; } // 2. 验金额:回调金额必须和本地订单金额一致 $order = $this->orderModel->getByOrderNo($data['out_trade_no']); if (!$order || $order['amount'] != $data['amount']) { return false; } // 3. 验状态:只有 submitted 状态的订单才处理回调 if ($order['status'] !== 'submitted') { return true; // 已处理过,直接返回成功避免上游重试 } return true; }参数说明:$data是回调原始数据,out_trade_no是本地订单号,amount是面值。逻辑上先验签,再查本地订单比对金额,最后检查状态。注意第三步,如果订单已经是success,说明之前处理过了,直接返回true让上游停止重试,不要重复加款。
3.3 回调幂等:用 Redis 锁住订单号
即使状态检查做了,高并发下两个回调同时进来还是可能都通过检查。我一般会在处理回调前用 Redis 加一个订单号维度的锁,SETNX成功才继续,处理完再删。锁的过期时间设 10 秒,防止死锁。
$lockKey = 'notify_lock:' . $data['out_trade_no']; $locked = $redis->set($lockKey, 1, ['nx', 'ex' => 10]); if (!$locked) { return 'success'; // 已有回调在处理,直接返回成功 } // ... 处理业务逻辑 $redis->del($lockKey);这套组合下来,回调重复、并发、伪造三个问题基本能挡住。但要注意,Redis 锁不是绝对可靠,如果 Redis 挂了,降级方案是依赖数据库唯一索引——在status_log表上对order_no + to_status建唯一索引,重复插入会报错,捕获异常即可。
4. 并发控制与补单机制:慢充批量提交的坑
4.1 慢充批量提交:别一次性全推给上游
慢充渠道通常支持批量提交,比如一次传 100 个号码。源码里如果有batch目录,里面会有批量提交的脚本。我见过最典型的翻车是:运营导了 5000 个慢充订单,脚本一次性循环提交,结果上游接口超时,一半订单状态未知,用户投诉到爆。
正确做法是分批加队列。每批 50 到 100 个,提交后把渠道返回的批量结果逐条更新到本地订单,失败的进入重试队列。重试队列要有最大重试次数,比如 3 次,超过就转人工。
// 分批提交慢充订单,每批 50 个 $batches = array_chunk($orders, 50); foreach ($batches as $batch) { $result = $channel->batchSubmit($batch); foreach ($result as $item) { if ($item['code'] === 0) { $this->orderModel->updateStatus($item['order_no'], 'submitted', $item['channel_order_no']); } else { $this->retryQueue->push($item['order_no'], $item['msg']); } } sleep(1); // 批次间留间隔,避免触发上游限流 }参数说明:array_chunk的第二个参数是每批数量,根据上游文档的批量上限调整。sleep(1)是给上游喘息时间,如果上游 QPS 限制严,可以调到 2 秒。重试队列建议用 Redis 的 List 或 RabbitMQ,消费端单独起进程。
4.2 补单任务:主动查询兜底
异步回调不是 100% 可靠,上游可能回调失败或延迟。源码里一般会有一个cron目录,放补单脚本。补单逻辑是:查本地submitted状态且超过 5 分钟未更新的订单,主动调上游查询接口,根据查询结果更新本地状态。
// 补单脚本:每 2 分钟跑一次 $staleOrders = $orderModel->getStaleSubmittedOrders(300); // 5 分钟前 foreach ($staleOrders as $order) { $result = $channel->queryOrder($order['channel_order_no']); if ($result['status'] === 'success') { $orderModel->updateStatus($order['order_no'], 'success'); } elseif ($result['status'] === 'failed') { $orderModel->updateStatus($order['order_no'], 'failed'); // 触发退款流程 } // 其他状态保持不动,等下次补单 }补单频率不要太高,否则上游查询接口也会限流。常见做法是 2 到 5 分钟一次,只查超过 5 分钟没更新的订单。注意补单和回调可能同时更新同一笔订单,所以更新时还是要带状态条件。
4.3 对账:每天跑一次,差一笔都不行
对账是最后一道防线。源码里如果有reconcile模块,通常会拉取上游的对账单文件,和本地订单逐笔比对。对账要关注三类差异:本地成功上游失败、本地失败上游成功、两边金额不一致。前两类要自动修正本地状态,第三类要人工介入。
对账文件一般是 CSV 或 TXT,按行解析。注意编码问题,有些上游给的是 GBK,需要转 UTF-8 再解析。对账结果写一张reconcile_diff表,运营每天看一眼。
5. 避坑与排查:那些让我半夜爬起来改代码的瞬间
5.1 回调地址被上游重复请求,订单重复加款
现象:用户充值 100 元,话费到账 100,但系统里订单状态变了两次,余额多扣了一次。原因:上游回调超时重试,本地没有做幂等,两次回调都走了加款逻辑。解决:按第 3 章的方法,Redis 锁加数据库状态条件更新,双保险。我后来还在status_log表上加了唯一索引,重复插入直接报错,捕获后返回成功。
5.2 慢充渠道批量提交后,部分订单状态永远停在 submitted
现象:批量提交 100 笔,回调只回来 80 笔,剩下 20 笔一直“充值中”。原因:上游批量接口只对成功的订单回调,失败的订单不回调,需要主动查询。解决:补单脚本必须覆盖批量提交的订单,查询接口要能按批量单号查。另外,批量提交时本地要记录batch_no,方便按批次补单。
5.3 渠道密钥配错,签名一直失败但日志没打出来
现象:新渠道上线,所有订单都失败,但日志里只有“提交失败”,没有具体原因。原因:Client.php里httpPost返回的原始响应没记日志,签名错误被吞了。解决:在submitOrder里把请求参数和原始响应都写日志,注意脱敏密钥。我一般会在config里加一个debug开关,打开时记录完整请求响应。
5.4 直充渠道限流,订单堆积导致超时
现象:晚高峰直充渠道 QPS 超限,订单提交全部超时,用户看到“充值中”但一直不到账。原因:路由层没有做并发控制,所有订单都往直充灌。解决:按第 2 章的路由策略,给每个渠道配max_qps,用 Redis 计数器实时统计,超了自动降级到快充或慢充。降级时要给用户提示“预计到账时间延长”,避免投诉。
5.5 对账文件解析时编码错误,差异记录全是乱码
现象:对账脚本跑完,reconcile_diff表里订单号是乱码,没法比对。原因:上游对账文件是 GBK 编码,脚本按 UTF-8 读的。解决:读文件时先检测编码,用mb_detect_encoding判断,GBK 的话用iconv转 UTF-8。另外,对账文件的行尾可能是\r\n,用fgetcsv时注意设置auto_detect_line_endings。
6. 进阶技巧:用压测和灰度把上线风险压到最低
源码跑通只是第一步,真正上线前我一般会做两件事:压测和灰度。压测不是随便跑个ab或jmeter,而是要模拟真实订单流——创建订单、提交上游、模拟回调、补单查询,全链路跑一遍。重点看三个指标:订单创建到提交的延迟、回调处理的吞吐、补单任务的积压量。
我常用的压测脚本是 PHP 写的,直接调源码里的OrderService,不经过 HTTP 层,这样能压出业务逻辑的瓶颈。比如下面这段,模拟 1000 笔订单并发提交,看渠道路由和状态更新有没有锁冲突。
// 压测脚本:并发提交 1000 笔订单 $pool = new \Swoole\Coroutine\Channel(100); for ($i = 0; $i < 1000; $i++) { go(function () use ($pool, $i) { $orderNo = 'TEST' . time() . $i; $result = $orderService->createAndSubmit([ 'order_no' => $orderNo, 'phone' => '1380000' . str_pad($i, 4, '0', STR_PAD_LEFT), 'amount' => 100, ]); $pool->push($result); }); } // 统计成功率和平均耗时参数说明:Swoole\Coroutine\Channel用来收集结果,go是协程并发。如果没有 Swoole 环境,用curl_multi也能做类似的事。压测时注意把上游渠道切到沙箱环境,别把真实订单打进去。
灰度则是先放 1% 的流量走新渠道,观察 24 小时的成功率、到账时间、回调延迟,没问题再逐步放大到 10%、50%、100%。灰度期间要盯紧对账差异,一旦差异超过阈值立即回滚。我一般会在路由层加一个gray_ratio配置,按订单号哈希取模决定走新渠道还是老渠道。
从那以后我每次上线新渠道,都强制走一遍“压测 1000 笔 + 灰度 1% 观察 24 小时 + 对账零差异”的流程,再急也不跳过。希望帮到你。
本文还有配套的精品资源,点击获取