三网合一话费余额查询API系统源码拆解与部署实践 简介面向通信服务商、PHP 中高级开发者和企业系统集成人员这套基于 ThinkPHP6.0 框架的三网合一话费余额查询 API 系统源码提供用户中心在线查询、HTTP API 对接和 USDT 稳定币充值三条主线服务端要求 PHP 8.2 及以上适合快速搭建成品话费查询平台也可作为接口开发与钱包支付集成的实战参考。压缩包共 2000 个文件整体约 72.48MB其中含 586 个 SVG 图标、461 个 JavaScript 脚本、320 张 PNG 图片和 185 张 JPG 素材另有 107 个 CSS 样式、93 个 Markdown 说明文档、36 个 PHP 文件以及 SQL 数据库脚本、环境配置、JSON/HTML 模板与字体文件等目录层次分明便于定位业务代码。已有 370 人学习下载。从源码中可以学习 ThinkPHP 6.0 的路由配置、控制器分层、用户登录鉴权、API 响应规范及 USDT 支付回调逻辑还能参考其前后端资源组织方式和开放接口设计将话费查询能力移植、扩展至其他通信业务或组合出充值、对账、订单管理等增值模块。1. 三网合一话费余额查询API系统先搞清它与原厂接口的本质区别说起三网合一话费余额查询 API第一反应往往是“不就是封装三个接口吗”。真正动手后才发现三家运营商对外暴露的余额查询能力在鉴权方式、返回字段、超时表现上差异很大业务方如果分别对接光字段映射和异常分支就能凑出一堆重复代码。这套 2024 年的网站源码解决的核心问题是把三网差异收口在中间层业务端统一传手机号和签名拿回结构化余额数据运营商侧接口更新时只改适配器。适合正在写话费充值后台、渠道商结算系统或者想给内部管理工具加一个话费查询功能的人。下面我会拆目录结构、讲部署配置、给调用示例再把跑通时遇到的边界坑一个个说清楚。2. 系统架构与核心模块适配器收口、缓存策略与请求链路2.1 源码目录结构与一次查询请求的完整链路拿到源码先别急着配数据库先把目录结构看明白。这套包采用的是典型的 MVC 分层关键逻辑集中在 services 目录下htf-api/ ├── app/ │ ├── controllers/ │ │ └── BalanceController.php │ ├── services/ │ │ ├── BalanceQueryService.php │ │ └── adapters/ │ │ ├── CarrierAdapterInterface.php │ │ ├── NetworkAAdapter.php │ │ ├── NetworkBAdapter.php │ │ └── NetworkCAdapter.php │ ├── models/ │ │ └── ChannelModel.php │ └── helpers/ │ ├── SignHelper.php │ └── MobileHelper.php ├── config/ │ └── config.php ├── database/ │ └── schema.sql └── public/ ├── index.php └── .htaccess一次余额查询的完整链路是这样的客户端 POST 手机号和签名到统一入口控制器做基础校验后把请求交给 BalanceQueryService服务层根据手机号段路由到对应的适配器适配器负责拼装运营商侧报文、发起 HTTP 请求、解析响应最后把统一结构返回给调用方。整个过程里控制器不感知运营商差异适配器不感知业务逻辑。这里有两处设计值得留意。一是路由规则没有写死在控制器里而是在服务层按号段实例化适配器新增渠道时不用改动现有调用方。二是缓存逻辑放在服务层而不是适配器层因为同一手机号短时间多次查询时没必要每次都穿透到运营商侧接口后面配置里的 cache_ttl 参数就是为这一层预留的。2.2 运营商渠道适配器如何用一个接口屏蔽三网差异三家运营商对“余额查询”的定义并不一致有的接口叫“话费余额”有的叫“账户余额”返回字段也不一样比如一家返回 balance另一家返回 availableBalance还有一家返回的是可用余额和账户状态两个字段的组合。适配器存在的意义就是把这些差异在内部消化掉。先看统一接口定义?php interface CarrierAdapterInterface { public function queryBalance(string $mobile, string $appKey, string $appSecret): array; }再看一个简化后的实现版本?php class NetworkAAdapter implements CarrierAdapterInterface { private string $baseUrl; public function __construct(string $baseUrl) { $this-baseUrl rtrim($baseUrl, /); } public function queryBalance(string $mobile, string $appKey, string $appSecret): array { $timestamp date(YmdHis); // A 网签名规则手机号 密钥 时间戳 拼接后取 MD5 $sign md5($mobile . $appSecret . $timestamp); $resp $this-httpPost(/query/balance, [ mobile $mobile, appKey $appKey, timestamp $timestamp, sign $sign, ]); return [ mobile $mobile, balance $resp[data][balance] ?? null, availableBalance $resp[data][availableBalance] ?? $resp[data][balance] ?? null, raw $resp, ]; } }代码逻辑说明每个适配器都实现同一个 CarrierAdapterInterface对外统一返回 mobile、balance、availableBalance、raw 四个键。balance 在这里已经被归一化不管运营商返回的是 balance 还是 availableBalance适配器内部都会转成统一含义。raw 保留运营商原始报文排查问题时可以对照原文。参数说明mobile 是待查询手机号appKey 和 appSecret 是渠道审核通过后分配的凭证timestamp 使用 YmdHis 格式。需要强调的是运营商对签名时间窗口有要求一般只接受五分钟内的请求所以 timestamp 生成后要立即发送。选型理由如果直接用 if else 判断运营商也能跑但每接入一个渠道就要改控制器代码会越来越难维护。适配器模式下新增渠道只需要新写一个 Adapter 类并在渠道表里注册对应编码即可这也是这套源码里比较值的部分。2.3 缓存与穿透控制余额数据多久查一次合适余额这个数据有一个特性时效性没有那么强。用户昨天欠费今天充值后余额变化往往要等运营商异步入账几分钟内反复查询结果是相同数值的概率很大。所以源码在 BalanceQueryService 里默认给余额查询结果加了 15 分钟缓存?php public function queryWithCache(string $mobile, array $channelConfig): array { $cacheKey balance: . $mobile; $cached $this-cache-get($cacheKey); if ($cached ! false) { return json_decode($cached, true) [cached 1]; } $result $this-queryBalanceOrThrow($mobile, $channelConfig); // 查询成功后写入缓存TTL 由渠道表 cache_ttl 控制 $this-cache-setex($cacheKey, (int) $channelConfig[cache_ttl], json_encode($result)); return $result [cached 0]; }逻辑说明缓存命中就直接返回不命中才穿透到运营商适配器。返回结构里带了 cached 字段0 表示本次为实时查询1 表示缓存值。业务侧可以根据这个标记决定展示文案比如余额下方提示“数据可能存在 15 分钟延迟”。参数说明cache_ttl 单位是秒源码默认 900。如果业务是充值成功后立刻展示最新余额建议把对应渠道的 cache_ttl 调到 60 秒以内代价是运营商接口调用量成倍上涨如果是后台批量查询保留 900 甚至调到 1800 更合适。这里有一个容易忽视的缓存穿透问题某个手机号如果一直被运营商返回异常连续请求会反复打到渠道接口。我一般会在缓存层额外处理一条空结果短缓存TTL 设 60 秒异常场景下直接命中空缓存而不触发真实查询。源码 config.php 里的 empty_ttl 就是干这个用的部署时不要把它填成 0。3. 从源码到跑通部署步骤与关键配置参数3.1 环境准备Nginx PHP 8.1 MySQL Redis这套源码的常见跑法是 Nginx 反向代理 PHP-FPM。PHP 版本建议 8.1 或以上MySQL 5.7 / 8.0 都可以Redis 用于缓存和异步队列。一次性装齐依赖的命令如下sudo apt update sudo apt install -y nginx mysql-server redis-server php8.1-fpm php8.1-mysql php8.1-redis php8.1-curl # 确认版本 php -v mysql --version redis-cli ping逻辑说明php8.1-mysql 是 PDO 链接 MySQL 的扩展php8.1-redis 是 PHP 操作 Redis 的扩展php8.1-curl 用于适配器发起运营商 HTTP 请求。装完后 redis-cli ping 返回 PONG 才能继续。参数说明如果系统源里默认 PHP 版本不是 8.1先执行apt-cache policy php8.1-fpm确认包是否可用再做下一步。我在部署时遇到过源码用到了 PHP 8 的 readonly 属性而环境只有 7.4 导致直接语法报错的情况版本不满足时优先换源升级而不是改代码绕过去。3.2 数据库初始化与渠道配置表初始化数据库结构mysql -uroot -p database/schema.sql核心表 carrier_channel 记录了每个运营商的渠道信息CREATE TABLE carrier_channel ( id int unsigned NOT NULL AUTO_INCREMENT, carrier_code varchar(16) NOT NULL COMMENT 运营商编码A/B/C, api_base_url varchar(255) NOT NULL COMMENT 运营商接口地址, app_key varchar(64) NOT NULL COMMENT 渠道应用标识, app_secret varchar(128) NOT NULL COMMENT 渠道签名密钥, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用 0停用, cache_ttl int unsigned NOT NULL DEFAULT 900 COMMENT 余额缓存秒数, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_carrier (carrier_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表结构说明carrier_code 是渠道唯一编码源码里按 A/B/C 三网区分。status 字段控制渠道启停BalanceQueryService 转发时会自动跳过 status0 的渠道。cache_ttl 是余额缓存的秒数可以在不改代码的情况下按渠道单独调整。初始化三条渠道记录INSERT INTO carrier_channel (carrier_code, api_base_url, app_key, app_secret, cache_ttl) VALUES (A, http://127.0.0.1:9001/mock/a, 申请到的key, 对应的密钥, 900), (B, http://127.0.0.1:9001/mock/b, 申请到的key, 对应的密钥, 900), (C, http://127.0.0.1:9001/mock/c, 申请到的key, 对应的密钥, 900);参数说明真实环境里 api_base_url 是运营商开放平台分配的正式网关地址源码默认给的是本地模拟网关占位地址联调阶段先拿它跑通链路申请到正式渠道后再替换。app_key 和 app_secret 属于敏感信息不要提交进代码仓库建议由配置文件从环境变量读取。3.3 配置文件逐项解读config/config.php 是整个系统的核心开关逐项看下面的示例?php return [ app [ debug false, timezone Asia/Shanghai, ], sign [ enable true, expire 300, secret 你的业务签名密钥, ], cache [ driver redis, balance_ttl 900, empty_ttl 60, ], query [ timeout 8, retry_num 2, ], ];逐项说明sign.expire 300调用方请求本系统时的签名有效期单位秒超过 300 秒判定过期防止请求被重放。sign.secret业务侧签名密钥和调用方约定好后写入不要使用默认值。cache.balance_ttl 900余额缓存默认 TTL对应渠道表里的 cache_ttl渠道表配置优先级更高。cache.empty_ttl 60运营商返回空结果或异常时的短缓存 TTL用来防缓存穿透。query.timeout 8适配器发起运营商 HTTP 请求的超时时间单位秒。query.retry_num 2超时后的重试次数重试间隔写在代码里默认 500ms。需要特别说明 timeout 和 retry_num把 timeout 调大到 30 秒并不会让系统更稳运营商接口如果处理慢业务方也等不起。常见做法是 8 秒超时、重试 2 次仍失败就立即返回“查询中”状态再通过异步回调补齐结果。这也是很多生产环境搭配异步队列跑的原因。3.4 启动服务并验证接口是否打通Nginx 站点配置指向 public 目录server { listen 80; server_name api.example.local; root /data/htf-api/public; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.1-fpm.sock; } }重载 Nginx 并发起一次测试请求sudo nginx -t sudo systemctl reload nginx curl -X POST http://127.0.0.1/index.php/api/balance/query \ -H Content-Type: application/x-www-form-urlencoded \ -d mobile13800138000timestamp20240101120000signxxxxxx逻辑说明try_files 把所有非真实文件请求交给 index.php由路由分发到 BalanceController。curl 里 timestamp 必须取当前时间sign 需要按第 4 章的签名算法生成用固定值测试只会得到签名错误的返回。如果返回 JSON 里 code0说明整条链路已经打通。常见的 500 基本是 Redis 连不上、渠道表为空、或 app_key 为空的占位符先看 PHP 错误日志再定位比盲目改配置高效得多。4. 接口参数与签名调用方最容易踩的四处边界4.1 统一查询接口参数表对外暴露的余额查询接口参数如下参数必填类型说明mobile是string11 位手机号后端会校验号段合法性timestamp是string格式 YYYYMMDDHHmmss服务端校验 300 秒内有效sign是string32 位小写 MD5 签名channel否string指定渠道编码缺省按号段自动路由参数设计里有两点值得注意timestamp 是防重放的关键没有时间戳的接口很容易被恶意调用方拿到请求后反复重放导致渠道侧被刷channel 可选参数则是为了方便联调测试时可以强制指定走某个渠道不必等号段路由正确。4.2 签名算法与防重放签名算法约定如下调用方持有 secret将 mobile、timestamp、secret 三个字符串按固定顺序拼接后取 MD5?php $mobile 13800138000; $secret 业务约定的签名密钥; $timestamp date(YmdHis); $sign md5($mobile . $timestamp . $secret); $params [ mobile $mobile, timestamp $timestamp, sign $sign, ]; $ch curl_init(http://127.0.0.1/index.php/api/balance/query); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $output curl_exec($ch); curl_close($ch);参数说明拼接顺序必须与源码 SignHelper 保持一致把 secret 放在最前面或最后面都会导致签名不一致。这里用的是简单 MD5适合内部系统如果服务要开放给外部使用建议把算法换成 HMAC-SHA256源码里预留了签名算法钩子。防重放原理服务端收到请求后先比较 timestamp 与当前时间差超过 300 秒直接返回 40003有效时间窗内再把 mobile timestamp 作为唯一标识写入 Redis 去重。同一请求第二次到达时直接拒绝这样即使请求被截获也无法重复使用。4.3 返回结构与状态码正常响应示例{ code: 0, message: success, data: { mobile: 13800138000, balance: 23.50, availableBalance: 20.50, cached: 1, channel: A } }状态码约定code含义处理建议0查询成功正常展示余额40001参数错误手机号格式不对等检查入参40002签名错误核对签名拼接顺序40003签名过期校验服务器时区是否一致50001运营商超时或返回异常展示“查询中”走异步回补50002该渠道已停用检查 carrier_channel.status边界提醒前端不要把运营商原始报文的文案直接透出给用户。运营商返回的提示语面向企业内部格式不稳定业务侧应该根据 code 做一层文案映射保证展示内容可控。4.4 异步查询与回调消息运营商接口在话费月初出账高峰时可能超过 8 秒才返回同步阻塞显然不现实。源码自带异步方案请求先写入 Redis 队列worker 进程消费队列完成后把结果存入结果表同时回调业务方。worker 核心逻辑简化如下?php while (true) { // blpop 阻塞取任务队列为空时每 5 秒醒一次 $job $this-redis-blpop(balance:queue, 5); if (!$job) { continue; } $mobile $job[mobile]; $result $this-queryDirect($mobile); // 写入结果缓存业务方可用 task_id 主动查询 $this-redis-setex(balance:result: . $job[task_id], 600, json_encode($result)); // 回调业务方地址注意接口要做幂等处理 $this-httpPost($job[callback], $result); }逻辑说明blpop 是 Redis 阻塞式弹出worker 空闲时每 5 秒检查一次队列开销很小。结果写入带 TTL 的缓存里600 秒内业务方都可以根据 task_id 主动拉取。边界说明异步查询适合对实时性要求不高的场景。如果业务要求“充值成功立刻显示余额”不建议走异步而是充值回调里主动删除余额缓存键下一次同步查询自然拿到最新值。5. 三网合一话费余额查询的避坑指南5.1 运营商接口超时导致进程被占满现象压测时发了几百个请求PHP-FPM 的进程全部卡住后续请求排队页面响应时间越来越长。原因适配器里 HTTP 请求没有设置明确超时默认值可能长达 30 秒运营商侧偶发一次抖动就把进程池占满整条链路跟着拖垮。解决给适配器基类统一设置 curl 超时8 秒内没响应就断开服务层捕获超时异常后直接返回“查询中”而不是继续占用进程等待。跑通后建议用压测工具跑一轮确认 query.timeout8 这个数值在真实渠道下是否够用。5.2 手机号校验不严格虚拟运营商号段查不出余额现象查询 170、171 开头的手机号时接口一直返回“渠道异常”换了号码又正常。原因虚拟运营商号段的计费归属与传统运营商不同默认按前三位路由到某个渠道但该渠道根本没有覆盖这个号段接口层直接报错。解决在 MobileHelper 里增加号段白名单校验不在渠道覆盖范围内就直接返回“暂不支持该号段”而不是继续往运营商接口发请求。我在某项目里就用一个号段映射文件封住了这个坑把传统号段和虚拟号段分开维护。5.3 加了缓存后用户充值完余额没变化现象用户刚充完值页面显示的余额还是旧值过了十几分钟才更新。原因BalanceQueryService 默认缓存 900 秒充值系统回调时没有通知查询服务删除对应手机号的余额缓存缓存没失效旧值就一直返回。解决充值成功回调里显式删除 balance:{mobile} 这个缓存键如果没有充值回调系统就把渠道表 cache_ttl 调短到 60 秒。此外在展示时透出 cached 字段用户看到缓存值也能理解为什么不是实时数据。5.4 balance 和 availableBalance 混用欠费停机判断错误现象查询接口返回 balance 为正数但用户反馈手机已经停机后台判断逻辑却认为账户正常。原因运营商返回的 balance 是账户总余额而是否欠费停机取决于可用余额和账户状态。适配器归一化时只取了 balance没有把可用余额和状态带出来。解决统一返回结构里增加 availableBalance 和 accountStatus 字段业务层判断停机状态时使用 availableBalance。这是我踩过比较深的一个坑只盯 balance 一个字段迟早会在欠费判断上翻车。5.5 时区不一致导致验签永远失败现象本地测试签名通过部署到服务器后接口一直返回 40003 签名过期。原因服务器系统时区是 UTC源码没有强制设置应用时区date(YmdHis) 生成的时间比北京时间慢 8 小时服务端拿到后自然判断为过期。解决在入口文件最前面强制date_default_timezone_set(Asia/Shanghai)同时把 config.php 里 app.timezone 固定为 Asia/Shanghai。容器化部署时再用 TZ 环境变量兜底挂载 /etc/localtime 到容器内避免镜像默认 UTC 影响其他脚本。6. 验证链路从真实号码、耗时和缓存命中三处入手源码跑通只是第一步真正判断这套系统能不能上生产我会在部署后强制走一遍三件事真实号码实测、耗时监控、缓存命中验证。先看一段验证脚本的思路?php $mobiles [13800138000, 13900139000, 17000000001]; foreach ($mobiles as $mobile) { $timestamp date(YmdHis); $sign md5($mobile . $timestamp . 业务签名密钥); $postData http_build_query([ mobile $mobile, timestamp $timestamp, sign $sign, ]); $t0 microtime(true); $response file_get_contents( http://127.0.0.1/index.php/api/balance/query, false, stream_context_create([ http [ method POST, header Content-Type: application/x-www-form-urlencoded\r\n, content $postData, ], ]) ); $elapsed round((microtime(true) - $t0) * 1000); echo $mobile . . $elapsed . ms . $response . PHP_EOL; }判断标准分三个维度第一个是返回结构能解析出 balance 字段且 code0 才算通第二个是耗时首次实时查询正常应该在 1 秒内返回高于这个值就要查运营商侧接口响应第三个是缓存命中连续两次查询同一号码第二次返回里 cached 应为 1如果还是 0说明 cache_ttl 没有生效。验证脚本跑完我还会在 Redis 里看一遍键列表确认 balance:{mobile} 存在且 TTL 符合预期。这一步能同时验证空结果短缓存有没有生效避免上线后被异常号码反复打穿渠道接口。以前我在某项目里接手过一套类似的查询中间件第一版就是只用固定测试号码验证没测真实号段和时区上线第一天就出现欠费号码返回正余额的问题。从那以后每次拿到话费查询类源码我都会强制让这几个验证动作走一遍再贵的时间也值得花在这上面。希望这些拆解和踩坑记录能帮到你少走一段弯路。本文还有配套的精品资源点击获取