☰
话费充值系统源码拆解:订单状态机、渠道路由与幂等回调
2026/10/27 6:29:35 网站建设 项目流程

简介:这套话费充值系统源码面向希望搭建或研究话费直充、快充、慢充业务的后端开发者与小型项目团队,覆盖从订单创建、支付回调到运营商接口下发的完整链路,可作为二次开发基础,也适合作为 Java Web 综合练手项目。压缩包共约 2000 个文件、175.89MB,以 java 源码与 class 编译文件为主体,配合 jsp 页面、js 脚本、xml 与 properties 配置、css 样式以及 sql 脚本、jar 依赖,构成典型的前后端一体工程结构。内容中可见订单控制、支付服务、支付宝客户端封装、电信运营商 HTTP 请求工具及数据抓取等模块,可据此梳理充值下单、渠道对接、状态回查与对账处理的实现思路。已有 1243 人学习下载,适合具备一定 Java Web 与接口对接基础、希望快速理解话费充值业务分层与第三方通道接入方式的读者参考。

1. 从一条凌晨告警说起

凌晨两点半,监控群里弹出一条告警:某个上游渠道的成功率从 98% 掉到 41%,订单表里还卡着一批「提交中」的记录,用户端已经开始有人打电话问钱扣了为什么没到账。这类场面在话费充值系统里几乎每周都会碰到。它表面上只是「下单 + 调接口」,真跑起来却是一整套订单状态机、渠道路由、幂等回调加对账补偿的组合拳。这套话费充值系统源码把直充、快充、慢充三条链路分开实现:直充对接官方接口,实时到账;快充走高频小额通道换取秒级体验;慢充走低价批量通道换取折扣空间。适合手里有上游渠道资源、想做一套可控充值中台的开发,也适合想研究订单幂等与对账设计的后端同学。

2. 系统架构:一笔充值订单从下单到到账要经过几层

先别急着看代码,把链路在纸上画一遍比读十遍源码管用。一笔订单从用户点「立即充值」到真正到账,中间要穿过接入层、业务层、渠道层、支撑层四道门,每一道门都可能出问题,也都必须有兜底。这套源码的分层不是摆设,是被上游渠道各自的签名规则、超时时间、字段命名逼出来的。

2.1 分层结构与目录树

源码目录大致是这样切的,和市面上常见的 ThinkPHP / Laravel 项目骨架接近,换成 Spring Boot 也只是把同样的分层搬过去:

phone-recharge/ ├── app/ │ ├── api/ # 对外接口层:H5、小程序、开放 API │ │ ├── controller/ │ │ └── middleware/ # 限流、签名校验、防重放 │ ├── channel/ # 渠道层:一家上游一个适配器 │ │ ├── ChannelInterface.php │ │ ├── ChannelManager.php │ │ ├── fast/ # 快充通道 │ │ ├── slow/ # 慢充通道 │ │ └── direct/ # 直充通道 │ ├── service/ # 业务层:订单、商品、费率、退款 │ │ ├── OrderService.php │ │ ├── RouteService.php │ │ └── NotifyService.php │ ├── job/ # 异步任务:查单、补单、对账 │ └── model/ # 数据层 ├── config/ │ ├── channel.php # 各渠道密钥、地址、限额 │ └── database.php ├── runtime/ # 日志、缓存 └── public/index.php

接入层只干三件事:验签、限流、把参数塞进统一 DTO。业务层负责订单生命周期和费率计算,不碰任何 HTTP 细节。渠道层是整套系统里最脏最累的一层,每家上游的签名算法、金额单位、成功码都不一样,全部在这里被抹平成同一个ChannelResult。支撑层是异步任务和日志,出了事全靠它查。

这样切的好处很直接:新增一家上游,只动app/channel/下的一个文件加一段配置,业务层一行不改。我见过不少项目把渠道调用直接写进OrderService,结果接到第五家渠道时那个文件已经两千行,改一次心惊胆战。

2.2 三条链路的订单状态机

订单状态必须收敛成有限个,否则后面所有排查都是玄学。这套源码用的状态表如下:

状态码名称触发条件可迁移到
0待支付订单创建1 / 9
1已支付待提交支付回调成功2 / 8
2提交中已发往上游3 / 4 / 8
3上游处理中上游返回 processing4 / 8
4充值成功上游成功回调或查单成功终态
8充值失败上游明确失败9
9已退款退款完成终态

关键在于所有状态变更都要走同一个入口,并且带上「只允许从 2/3 迁到 4/8」这类白名单校验。回调是乱序的,上游偶尔还会把同一笔的失败通知发两次再来一次成功通知,没有白名单校验,你的订单状态就会来回横跳,最后对账时根本说不清哪条是对的。状态机就是给这种乱序准备的一道保险栓。

2.3 快充与慢充的差异到底在哪

很多人以为快充慢充只是「快一点慢一点」,实际是两条完全不同的商业链路,技术选型也不同:

维度直充快充慢充
到账时间秒级10 秒到 5 分钟10 分钟到 72 小时
折扣空间最小中等最大
接口形态实时同步 + 回调实时同步 + 回调批量文件 / 异步回调
超时设置3 到 5 秒5 到 10 秒不设同步超时
失败补偿立刻切通道重试延迟 30 秒查单按批次对账补单
单笔限额通常较高单笔小额单笔中等

慢充之所以慢,是因为上游把订单攒成批再统一提交,中间还有个人工审核环节;它的接口往往不返回明确结果,只给你一个受理号,真实状态要等几小时后对账文件回来才知道。所以慢充链路的设计重点不是「快」,而是「可追溯」——每一笔都得能在对账文件里找到对应行,找不到的必须报警而不是静默丢弃。

快充链路的设计重点反过来,是超时和重试。上游 5 秒没响应,你并不知道这单到底提交成功没有,直接重试就有重复充值的风险。合格的开发做法是先落一条「提交中」记录,再交给查单任务去确认,确认失败才允许重提。

提示:快充和慢充共用一个订单表,但用channel_type字段区分,不要建两张表。对账逻辑、退款逻辑、统计报表都能复用一套代码。

3. 核心模块拆解:渠道适配层怎么把 N 家上游装进一个入口

渠道层是这套源码里最值得抄的部分。它的目标很明确:让业务层调用上游时,感觉不到上游的存在。做这件事的方式是接口抽象加配置驱动,而不是给每家渠道写一个if。

3.1 渠道适配器的统一契约

先看接口定义,四个方法覆盖了充值业务的全部动作:

<?php // app/channel/ChannelInterface.php interface ChannelInterface { // 渠道唯一标识,会写进 order.channel_code,用于对账和路由 public function code(): string; // 下单:入参是内部订单对象,返回统一结果,屏蔽各家差异 public function submit(RechargeOrder $order): ChannelResult; // 主动查单:回调丢失时靠它兜底,是幂等的 public function query(string $channelOrderNo): ChannelResult; // 渠道可用额度:路由前先判断这家还吃不吃得下 public function balance(): float; }

参数说明没什么花哨的:submit收的是订单对象,不是数组,这样上游要什么字段由适配器自己从订单里取,业务层不用关心;query只接收上游订单号,因为回调丢失时你手里最可靠的凭证就是上游返回过的那串号;balance返回一个浮点数,单位统一成元,路由层用它做额度熔断。

真正干活的是具体实现。下面这个快充适配器把「签名 + 组包 + 请求 + 字段映射」四件事塞进了二十行:

<?php class FastChannelA implements ChannelInterface { public function __construct(private HttpClient $http, private array $cfg) {} public function code(): string { return 'fast_a'; } public function submit(RechargeOrder $order): ChannelResult { $params = [ 'merchant_no' => $this->cfg['merchant_no'], 'out_trade_no' => $order->sn, // 我方单号,必须原样带回 'phone' => $order->encryptMobile(), // 按渠道要求做加密或脱敏 'face' => $order->face, // 面值,单位元 'notify_url' => $this->cfg['notify_url'], 'timestamp' => time(), ]; $params['sign'] = $this->sign($params); // 各家签名规则不同,放子类 // 5 秒硬超时,超时不代表失败,只代表未知 $resp = $this->http->postJson($this->cfg['submit_url'], $params, 5); return ChannelResult::fromRaw($resp, $this->cfg['field_map']); } }

field_map这一段是关键设计。上游返回{"code":"0000","orderid":"A123","msg":"ok"},另一家返回{"status":1,"data":{"orderNo":"B456"}},如果每家都在业务层写一遍解析,代码会迅速失控。源码把「哪个字段是状态、哪个是单号、哪个值代表成功」放进配置,ChannelResult::fromRaw统一吃配置吐对象,业务层拿到的永远是isSuccess()、isProcessing()、getChannelOrderNo()三个方法。加一家渠道,只写一个配置数组,不动一行解析代码。

3.2 路由、限流与熔断

路由层负责决定一笔订单走哪家渠道。这套源码的路由顺序是:先按运营商和面值筛出候选渠道,再按余额和成功率排序,最后按费率取最优。排序时成功率用 Redis 里维护的滑动窗口统计,窗口取最近 5 分钟,避免用全量历史导致故障渠道被「惯性信任」。

<?php // app/service/RouteService.php public function pick(RechargeOrder $order): string { $candidates = $this->repo->findByFace($order->operator, $order->face, $order->channel_type); $scored = []; foreach ($candidates as $c) { if ($c['balance'] < $order->face) continue; // 额度不足直接淘汰 $rate = $this->stat->successRate($c['code'], 300); // 5 分钟窗口 if ($rate < 0.85) continue; // 成功率过低触发熔断 $scored[] = ['code' => $c['code'], 'score' => $rate * 100 - $c['fee'] * 1000]; } if (!$scored) { throw new NoChannelException('no available channel'); // 宁可拒单也不乱发 } usort($scored, fn($a, $b) => $b['score'] <=> $a['score']); return $scored[0]['code']; }

这里的0.85和300是最值得调的两个参数。成功率阈值定太高,一次小抖动就把所有渠道熔断,用户直接下不了单;定太低,故障渠道会持续吃单。我一般先用 0.8 起步,跑一周看真实分布再收紧。窗口 300 秒是折中,太短容易被几次网络抖动带偏,太长则故障恢复后迟迟不回来。

注意:熔断不能只靠「剔除」,还要有恢复机制。源码里每个渠道有个冷却时间戳,被剔除后 60 秒重新参与竞争,避免一次误判永久下线。

3.3 回调验签与幂等落库

回调是整个系统最容易出事故的地方,没有之一。上游可能重复推送、乱序推送、甚至在半夜推错单号。这段代码的处理顺序是「验签 → 去重 → 锁行 → 状态机校验 → 落库」:

<?php public function notify(Request $req) { $raw = $req->getContent(); $data = json_decode($raw, true); // 1. 验签失败直接拒,连日志都不用细记 if (!$this->channel->verify($data)) { return ['code' => 'FAIL', 'msg' => 'sign error']; } // 2. 幂等:渠道流水号建唯一索引,插入成功才继续 $inserted = Db::table('channel_callback_log')->insertIgnore([ 'channel_code' => $this->channel->code(), 'channel_trade_no' => $data['trade_no'], 'raw' => $raw, 'created_at' => date('Y-m-d H:i:s'), ]); if (!$inserted) { return ['code' => 'SUCCESS']; // 重复回调,直接回成功让上游闭嘴 } // 3. 行锁 + 状态机白名单 Db::transaction(function () use ($data) { $order = Db::table('recharge_order') ->where('sn', $data['out_trade_no']) ->lockForUpdate()->first(); if (in_array($order->status, [4, 9])) return; // 终态跳过,幂等第二道 if (!in_array($order->status, [2, 3])) { throw new StateException("illegal {$order->status}->4"); } Db::table('recharge_order')->where('id', $order->id)->update([ 'status' => $data['status'] == '1' ? 4 : 8, 'channel_order_no' => $data['trade_no'], 'finish_time' => date('Y-m-d H:i:s'), ]); }); return ['code' => 'SUCCESS']; }

insertIgnore依赖唯一索引,这一招比先 select 再 insert 靠谱得多,并发下不会有两个线程同时通过检查。行锁那一步是为了防止「回调」和「查单任务」同时改同一笔单,两者都可能把状态推到终态,加了锁就变成串行。最后的状态机白名单是第三道防线,三道都过了才算真幂等。

4. 本地跑通:环境、配置与沙箱联调

源码拿到手,第一步不是改业务,是让它在你机器上完整跑通一笔订单。这个过程大概半天,别省。

4.1 依赖清单与启动顺序

环境按这个清单准备:PHP 8.1 以上(带 bcmath、redis、pdo_mysql 扩展)、MySQL 8.0、Redis 6、Composer 2。启动顺序也有讲究,先起数据库和 Redis,再导入 SQL,最后起应用,反了会出现连不上库直接白屏。

# 1. 装依赖 composer install --no-dev # 2. 导入表结构和演示数据 mysql -uroot -p recharge < sql/install.sql mysql -uroot -p recharge < sql/demo_channel.sql # 3. 复制并修改配置 cp .env.example .env php think key:generate # Laravel 用 php artisan key:generate # 4. 起服务 php think run --host 0.0.0.0 --port 8000

install.sql里建了六七张表,最重要的是recharge_order、channel_callback_log、recharge_flow(流水)和channel_balance。demo_channel.sql会灌入一个 mock 渠道,地址指向本地/mock/submit路由,这套源码很贴心地带了模拟上游,不用真实密钥就能跑通全链路。如果没有 mock 路由,自己写一个固定返回成功和失败的接口,十分钟的事。

4.2 必改的六个配置项

config/channel.php里字段不少,但真正决定能不能跑起来的是下面这六个:

配置项示例值作用改错会怎样
submit_urlhttp://127.0.0.1:8000/mock/submit上游下单地址全渠道超时,日志刷满
notify_urlhttp://你的域名/api/notify/fast_a回调地址订单永远停在提交中
merchant_no100001商户号上游直接返回商户不存在
secret随机 32 位签名密钥验签必失败
face_list[10,20,50,100]支持面值前端下拉为空
timeout5请求超时秒数太大拖垮队列,太小误判失败

notify_url是本地调试第一大坑。上游推不到127.0.0.1,要么用内网穿透工具,要么在 mock 模式里让「上游」直接调本地方法模拟回调。源码里MOCK_NOTIFY=true时走后者,调试期建议开着。

4.3 用 Mock 渠道把全链路走一遍

服务起来后,用三条命令验证三个关键节点:

# 1. 下单,应返回订单号和支付参数 curl -X POST http://127.0.0.1:8000/api/order/create \ -H "Content-Type: application/json" \ -d '{"mobile":"13800000000","face":10,"channel_type":"fast"}' # 2. 模拟支付回调,把订单推到已支付 curl -X POST http://127.0.0.1:8000/api/pay/notify \ -d '{"sn":"R20240101001","status":"success"}' # 3. 模拟上游充值回调,订单应变成 4 curl -X POST http://127.0.0.1:8000/api/notify/fast_a \ -d '{"out_trade_no":"R20240101001","trade_no":"A123","status":"1","sign":"..."}'

跑完第三条,去数据库看recharge_order的status是不是 4,channel_order_no有没有写进去,channel_callback_log里是不是只有一条。再手动重放第三条命令,日志表应该还是只有一条,订单状态不变——这就证明幂等生效了。这个验证动作我建议每接一个新渠道都做一遍,比看代码可靠得多。

5. 避坑清单:五个真实踩过的坑和排查路径

下面这几条都是能让人半夜爬起来的那种,按「现象 → 原因 → 解决」写,遇到时直接对照。

5.1 重复回调导致用户被充两次

现象:用户收到两条到账短信,流水表里同一订单有两条成功记录,财务对账对不上。

原因:幂等用的是「先查有没有,没有就插」,两个线程同时查都是没有,同时插就进了两条。上游推送本身就是 at-least-once 语义,重复是常态不是异常。

解决:把去重从应用层挪到数据库层,channel_code + channel_trade_no建唯一索引,用insertIgnore或捕获唯一键冲突。应用层判断只能当优化,不能当保证。

5.2 订单卡在「提交中」几小时不动

现象:订单状态一直是 2,用户催单,后台看不到任何上游返回。

原因:调用上游超时了。超时的含义是「不知道成功没成功」,但很多实现把它当失败处理,直接置 8 又重提,结果重复充值;或者干脆不管,永远卡着。

解决:超时单独走一个状态,交给查单任务按退避策略轮询(30 秒、2 分钟、10 分钟、30 分钟),查到明确结果再迁移。超过最大重试次数仍无结果的,标记为人工介入并报警,别自动退款——万一上游其实成功了,退款就是双倍损失。

5.3 一百块的订单只充了十块

现象:面值字段传错,用户下单 100 元,实际到账 10 元。

原因:金额单位不统一。我方用元,上游用分,适配器里少乘了个 100;或者反过来多乘了一次。也有面值字段和实际支付金额混用的情况。

解决:内部统一用「分」存整数,数据库字段名直接叫face_fen、amount_fen,从命名上杜绝歧义。适配器转换单位的地方单独写单元测试,输入 10000 必须输出 100。这条血泪经验值一个测试。

5.4 慢充对账文件总是差几笔

现象:每天跑对账任务,总有 2 到 5 笔「我方成功、上游无记录」和几笔反向差异。

原因:日切时间不一致。我方按自然日 00:00 切,上游按 23:30 切,跨零点那半小时的订单两边都算不清;另外文件下载有延迟,昨天的文件今天才齐。

解决:对账窗口前后各留 1 小时重叠,即处理[T-1 23:00, T 00:59]区间的订单,重叠部分靠唯一键去重。文件没到齐就重试下载,连续三次失败报警,不要用「昨天的数据」直接覆盖。

5.5 渠道成功率突然暴跌但接口是通的

现象:监控显示某渠道成功率 40%,但手动调接口返回正常,日志里全是超时。

原因:并发打满了。上游有 QPS 限制,超出后不是拒绝而是排队,排队就表现为超时。你看到的是超时,实际是限流。

解决:在适配器外面加一层令牌桶,按渠道配置 QPS 上限,取不到令牌的请求直接路由到其他渠道而不是等待。同时给每个渠道单独统计 P99 耗时,耗时突增往往比错误率更早暴露问题。

6. 二次开发:给系统接一个新上游渠道

接新渠道是这套源码最高频的改造动作,流程走顺了基本两小时一家。第一步照着FastChannelA复制一个类,改code()、签名方法和字段映射配置;第二步在config/channel.php里加一段配置,重点是field_map要把成功码、处理中码、失败码都列全;第三步在ChannelManager的注册表里挂上,源码用的是配置驱动,多数情况连这步都省了;第四步用channel_type和面值把它挂进路由候选池。

灰度是必须的。别一上来就给全量流量,先把费率调到略低于现有渠道,让路由自然分 5% 过来,观察 24 小时的成功率、P99 耗时和查单兜底次数,三个指标都稳了再放开。我一般会在灰度期给新渠道单独加一个日志前缀,出问题时 grep 一下就能把它的所有请求捞出来,比在混合日志里翻快得多。

验证环节建议写三个断言:一笔正常单能到 4、一笔上游明确失败的单能到 8 并触发退款、一笔超时单能通过查单任务收敛到终态。这三个用例覆盖了 90% 的真实故障场景,接渠道前跑一遍,能省掉后面很多来回。

改造时有个容易忽略的点:退款。新渠道的退款接口形态往往和充值完全不同,有的支持原路退,有的只能退到余额。所以在适配器里把refund()也一起实现,哪怕先抛「不支持」,也好过上线后才发现退款链路是空的。

注意:不要在业务层写if ($channel == 'xxx')。只要出现第一个这样的分支,第二个第三个就会跟着来,半年后这文件没人敢动。所有差异都往适配器和配置里塞。

从那以后我每次接新渠道,都强制先把「超时单 + 重复回调 + 退款」三个用例跑一遍再放流量,翻车的次数确实肉眼可见地少了。这套源码的分层和幂等设计值得对着抄一遍,剩下的就是按自己手里的渠道资源去填适配器。希望帮到你。

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

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

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

立即咨询