☰
H5游戏联运系统源码:渠道归因、支付回调与分成结算
2026/10/26 13:24:42 网站建设 项目流程

简介:溪谷H5游戏平台联运系统V3.0完整版源码面向H5游戏开发者、联运运营商及需要搭建游戏分发后台的技术团队,围绕多合作渠道共同推广、收益分成这一典型场景,提供游戏上下架、渠道效果追踪、用户注册与行为分析、充值分成报表、营销活动配置及DAU、ARPU等运营统计模块,可用于快速搭建或二次开发一套联运管理后台。压缩包为rar格式,共2181个文件、约9.82MB,其中668个php文件承载后端业务逻辑,221个html与177个js、117个css构成管理页面和交互样式,360个dat及18个config、16个functions文件多用于数据与配置,另有gif、png、jpg等界面图片资源。已有3198人学习下载,说明其在同类源码中具备一定参考热度。读者可据此梳理联运平台的模块划分、渠道分成与财务对账思路,并借助现成页面和接口结构进行功能扩展与部署实践。

1. 从手工对账到自动化分账:这套 H5 联运系统到底管什么

联运平台这个词在游戏圈不新鲜,但真落到代码上,很多团队还在用 Excel 记渠道分成:玩家从哪个渠道进来、充了多少钱、该给渠道分多少,全靠运营每周手动拉表。这套 H5 游戏联运系统 V3.0 完整版源码,解决的就是这条链路的自动化——游戏接入、渠道推广链接、玩家账号绑定、支付下单、回调验签、分成流水、后台报表,一整套跑通。它适合两类人:一类是手上有几款 H5 小游戏、想快速搭个联运后台接渠道的发行或运营团队;另一类是接私活、需要一套能改能上线的联运底座的开发者。系统自带平台后台、渠道后台和游戏端接口三层入口,代码结构不算重,拿来做二次开发的起点比从零造轮子省事得多。下面我按目录、核心模块、部署、避坑、改造的顺序拆一遍。

2. 架构与目录拆解:PHP 后台 + 多端入口的请求链路怎么走

这套源码是典型的 PHP 服务端渲染后台加多入口 API 的结构。常见做法是后端用 ThinkPHP 5.x/6.x 或 Laravel,前端后台用 Vue + Element UI 或 layui 打包成静态资源塞进public/static,数据库 MySQL 5.7 以上,缓存用 Redis 存登录态和推广链接的短期上下文。这套组合在联运系统里很常见,原因是渠道后台和平台后台的表格、筛选、导出需求多,Vue 后台模板改起来快;游戏端接口要求轻,PHP 直接出 JSON 也够用。

2.1 目录结构与模块职责

解压后先看根目录,不要急着改代码:

project/ ├── application/ # 业务代码 │ ├── admin/ # 平台后台:游戏、渠道、订单、分成、报表 │ ├── channel/ # 渠道商后台:推广链接、玩家数据、分成账单 │ ├── api/ # 游戏端/客户端接口:登录、下单、回调 │ ├── common/ # 公共模型、服务、验证器 │ └── command.php ├── config/ # 数据库、缓存、支付、路由配置 ├── public/ │ ├── index.php # 唯一入口 │ ├── static/ # 后台前端资源 │ └── uploads/ # 游戏图标、渠道素材 ├── route/ # 路由定义 ├── runtime/ # 日志、缓存、编译文件 ├── sql/ │ └── install.sql # 基础表结构 └── vendor/ # Composer 依赖

admin和channel两个模块共用common里的模型,但权限体系是分开的:平台管理员能看全量数据,渠道商只能看自己channel_id下的玩家和订单。这种分法比单后台加角色权限更清晰,渠道商登录后不会误看到别家的分成比例。

2.2 请求从入口到落库的链路

入口文件通常只做三件事:定义应用目录、加载 Composer、启动框架。真正决定请求去向的是路由。下面是一段常见的 API 路由定义:

<?php // route/api.php Route::group('api', function () { // 玩家登录/注册,落地页会带 channel_id 和 game_id Route::post('player/login', 'api/Player/login'); Route::post('player/register', 'api/Player/register'); // 创建支付订单 Route::post('pay/create', 'api/Pay/create'); // 支付回调,必须放在免登录白名单里 Route::post('pay/notify', 'api/Pay/notify'); // 游戏启动时拉取玩家信息 Route::get('game/entry', 'api/Game/entry'); });

这段路由里,pay/notify是最容易出问题的一条:它必须跳过登录鉴权,因为支付渠道服务器不会带你的用户 token;但它又不能完全裸奔,得靠签名验签。常见翻车点是把回调路由放进需要登录的中间件组,结果渠道那边一直重试,后台订单永远停在待支付。链路顺序是:玩家点推广链接进入落地页,落地页把channel_id、game_id写进 cookie 或 URL 参数;注册时写入玩家档案;下单时按玩家档案归因到渠道;支付回调更新订单并生成分成流水。每一步都依赖上一步写对参数,哪一步丢了,后面报表就对不上。

2.3 多端入口的配置差异

config目录下一般有database.php、cache.php、pay.php、app.php。平台后台和渠道后台共用同一个数据库,但app.php里的default_module可能不同,部署时如果前后台域名分开,Nginx 要按域名指向不同入口,或者用同一入口加模块前缀。我一般会把后台域名和 API 域名分开配,API 走独立域名便于加证书和限流,后台走内网或加 IP 白名单。这一步不做,后面回调联调时容易把后台登录态和 API 请求混在一起,日志看起来一团乱。

3. 渠道推广与玩家账号:一条推广链接背后的绑定逻辑

联运系统最核心的资产不是游戏,是渠道归因。玩家从哪个链接进来、算谁的量、后续充值怎么分,全靠推广链接和账号绑定这两步。这部分代码不复杂,但参数设计一旦含糊,后面分成结算就是一笔糊涂账。

3.1 推广链接的生成与验签

渠道商在后台点“生成链接”,系统会拼出带签名参数的落地页地址。下面是一个常见的生成方法:

<?php // 生成渠道推广链接:channel_id + game_id + 时间戳 + 签名 function buildPromoteUrl(array $channel, int $gameId, string $baseUrl, string $secret): string { $ts = time(); $params = [ 'channel_id' => $channel['id'], 'game_id' => $gameId, 'ts' => $ts, ]; // 按参数名升序拼接,避免签名顺序不一致导致校验失败 ksort($params); $signStr = http_build_query($params) . '&key=' . $secret; $params['sign'] = md5($signStr); return $baseUrl . '/promote/landing?' . http_build_query($params); }

参数说明:channel_id是渠道商主键,game_id是游戏主键,ts用来防重放,secret是平台分配给渠道的签名密钥,存在channel表里。落地页收到请求后先校验ts是否在有效期内,一般给 10 到 30 分钟,再看签名是否匹配。校验通过后把channel_id、game_id写进 cookie 或 session,有效期设成 7 天到 30 天,取决于业务对归因窗口的要求。这里有个细节:http_build_query会把参数做 URL 编码,验签时要用同样的编码规则还原,否则签名对不上。血泪经验是前后端各写一套拼接逻辑,最后差一个空格都过不了。

3.2 玩家注册时的渠道绑定

落地页只负责把渠道标识带进来,真正落库是在注册接口。下面这段代码在注册时做两件事:写玩家档案、写绑定关系:

<?php // 玩家注册:从落地页上下文取渠道标识,写入玩家档案和绑定表 public function register($mobile, $password, array $context) { $channelId = $context['channel_id'] ?? 0; $gameId = $context['game_id'] ?? 0; // 渠道不存在或已禁用则按自然量处理,不阻断注册 $channel = Db::name('channel') ->where('id', $channelId) ->where('status', 1) ->find(); if (!$channel) { $channelId = 0; } $playerId = Db::name('player')->insertGetId([ 'mobile' => $mobile, 'password' => password_hash($password, PASSWORD_DEFAULT), 'channel_id' => $channelId, 'game_id' => $gameId, 'create_time' => time(), ]); // 单独写绑定表,后续订单归因和换渠道场景都以这张表为准 Db::name('player_bind')->insert([ 'player_id' => $playerId, 'channel_id' => $channelId, 'game_id' => $gameId, 'bind_time' => time(), ]); return $playerId; }

参数说明:$context来自落地页写入的 cookie 或 session,里面至少要有channel_id和game_id。status = 1表示渠道启用,禁用渠道的链接不应该再产生新绑定,但历史玩家不受影响。player_bind单独建表而不是只存在player表里,是为了后续支持一个玩家在多个游戏下绑定不同渠道,或者渠道关系变更时保留历史记录。很多联运系统只存一个channel_id字段,后期做渠道转移时没有后悔药。

3.3 渠道表与玩家表的关键字段

表结构不用全贴,但几个字段决定了后面能不能算对账:

CREATE TABLE `channel` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '渠道名称', `secret` varchar(64) NOT NULL COMMENT '推广链接签名密钥', `share_rate` decimal(5,4) NOT NULL DEFAULT '0.3000' COMMENT '渠道分成比例', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道商表'; CREATE TABLE `player_bind` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `player_id` int unsigned NOT NULL, `channel_id` int unsigned NOT NULL DEFAULT '0', `game_id` int unsigned NOT NULL DEFAULT '0', `bind_time` int unsigned NOT NULL, PRIMARY KEY (`id`), KEY `idx_player` (`player_id`), KEY `idx_channel` (`channel_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='玩家渠道绑定表';

share_rate用decimal(5,4)存 0.3000 表示 30%,不要用浮点。secret每个渠道独立,不要全平台共用一个密钥,否则一个渠道泄露等于全平台裸奔。player_bind上建channel_id索引,后面按渠道拉玩家列表和算分成都要走这个索引。

4. 支付回调与分成结算:订单状态机怎么保证钱不算错

联运系统里最容易出事故的模块就是支付和分成。订单状态机没设计好,重复回调会把一笔订单加两次分成;金额口径不统一,渠道按充值金额算、平台按实付金额算,月底对账能差出一截。这部分代码要按“先幂等、再更新、后分账”的顺序写。

4.1 订单表与状态机

订单表的核心字段不多,但每个字段都要有明确口径:

CREATE TABLE `order` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '平台订单号', `out_trade_no` varchar(64) DEFAULT NULL COMMENT '支付渠道单号', `player_id` int unsigned NOT NULL, `game_id` int unsigned NOT NULL, `channel_id` int unsigned NOT NULL DEFAULT '0', `amount` int unsigned NOT NULL COMMENT '实付金额,单位分', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已退款 3已关闭', `pay_time` int unsigned DEFAULT NULL, `create_time` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_player` (`player_id`), KEY `idx_channel` (`channel_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游戏订单表';

amount用整数分,不用小数。order_no加唯一索引,防止并发下重复创建。status只允许单向流转:0 到 1,1 到 2,0 到 3。退款不是把 1 改回 0,而是走 2 并生成冲正流水,这样历史记录可追溯。channel_id在下单时从玩家绑定关系里取,不要信任客户端传上来的渠道参数,否则渠道可以伪造归因。

4.2 回调验签与幂等处理

支付回调进来后,先验签,再查订单,再开事务:

<?php // 支付回调:验签 -> 幂等判断 -> 事务更新订单 -> 生成分成流水 public function notify() { $raw = file_get_contents('php://input'); $data = json_decode($raw, true); // 1. 验签:按支付渠道约定拼接待签串,密钥从配置读 if (!$this->verifySign($data)) { return ['code' => 'FAIL', 'msg' => 'sign error']; } // 2. 幂等:同一笔订单只处理一次,锁住行防止并发重复回调 $order = Db::name('order') ->where('order_no', $data['out_trade_no']) ->lock(true) ->find(); if (!$order || $order['status'] == 1) { return ['code' => 'SUCCESS', 'msg' => 'ok']; } Db::startTrans(); try { // 3. 更新订单状态,金额以平台订单为准,不信任回调金额 Db::name('order')->where('id', $order['id'])->update([ 'status' => 1, 'out_trade_no' => $data['trade_no'], 'pay_time' => time(), ]); // 4. 生成分成流水 $this->createShareBill($order); Db::commit(); } catch (Exception $e) { Db::rollback(); Log::error('pay notify fail: ' . $e->getMessage()); return ['code' => 'FAIL', 'msg' => 'retry']; } return ['code' => 'SUCCESS', 'msg' => 'ok']; }

逻辑说明:第一步验签不通过直接返回失败,不要泄露具体原因。第二步用lock(true)锁行,防止支付渠道短时间重复推送导致两次分账。第三步只更新状态和时间,金额字段不动,因为下单时已经写死。第四步放在同一个事务里,订单更新失败则分成流水也不落库。返回给支付渠道的SUCCESS要严格按渠道要求的格式,有的渠道要求返回纯文本success,有的要求 JSON,配错了渠道会一直重试。

4.3 分成流水的计算与退款冲正

分成计算单独放在一个方法里,方便后面接规则表:

<?php // 按订单生成分成流水:渠道分成 = 实付金额 × 渠道比例,平台留存 = 差额 private function createShareBill(array $order) { $channel = Db::name('channel')->where('id', $order['channel_id'])->find(); $rate = $channel ? (float)$channel['share_rate'] : 0.0; // 金额统一用分,向下取整,避免分出超过实付的零头 $channelFee = (int)floor($order['amount'] * $rate); $platformFee = $order['amount'] - $channelFee; Db::name('share_bill')->insert([ 'order_no' => $order['order_no'], 'channel_id' => $order['channel_id'], 'amount' => $order['amount'], 'rate' => $rate, 'channel_fee' => $channelFee, 'platform_fee' => $platformFee, 'status' => 1, 'create_time' => time(), ]); }

参数说明:channel_fee是渠道应得,platform_fee是平台留存。如果联运模式里还有游戏研发分成,share_bill要再加一个game_fee字段,公式变成平台留存 = 实付 - 渠道分成 - 研发分成。退款时不要改原流水,插入一条负数流水,status标成冲正,这样月底对账时正负相加等于净分成。我见过直接把原流水删掉的实现,退款一多,历史账单就成黑匣子了,查都没法查。

5. 部署运行与常见问题排查:从导入 SQL 到回调联调

这套源码拿到手,先别急着改业务代码,按“环境检查、导库、改配置、配伪静态、跑后台、联调回调”的顺序走一遍。下面命令以 Linux + Nginx 为例,PHP 版本按源码要求选 7.4 或 8.0,MySQL 5.7 以上,Redis 用来存登录态和队列。

5.1 环境准备与数据库导入

# 环境检查:PHP、MySQL、Redis 都要通 php -v mysql -V redis-cli ping # 进入项目根目录 cd /www/wwwroot/h5_union # 导入基础表结构,库字符集用 utf8mb4 mysql -u root -p h5_union < sql/install.sql # 给运行时目录写权限,别直接 777 chown -R www:www runtime public/uploads chmod -R 775 runtime public/uploads

runtime目录要可写,否则框架启动就报错。public/uploads存游戏图标和渠道素材,也要可写。用www用户跑 PHP-FPM 的话,属主设成www,不要用 root 跑进程。

5.2 数据库配置与伪静态

配置文件一般在config/database.php,改完记得清缓存:

<?php // config/database.php return [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'h5_union', 'username' => 'h5_user', 'password' => '换成你的强密码', 'hostport' => '3306', 'charset' => 'utf8mb4', ];

Nginx 伪静态不配,后台登录页会 404:

location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } }

配完后访问后台域名,默认管理员账号在install.sql里,第一次登录后立刻改密码。渠道后台一般用/channel前缀或独立域名,按源码路由配置来。

5.3 支付回调联调的注意点

回调地址要能在公网访问,本地开发用内网穿透工具临时映射一个域名,别把 localhost 填给支付渠道。回调路由必须在免登录白名单里,同时要放行 CSRF 校验。联调时先看runtime/log里有没有收到请求,再看验签日志,最后看订单状态有没有从 0 变 1。如果渠道一直重试,先检查返回格式是不是渠道要求的那一种。

注意:回调日志里不要打印完整签名密钥和用户手机号,脱敏后再落盘。

5.4 常见问题排查

现象一:后台登录页 404,接口返回 500。原因多半是伪静态没配或runtime目录不可写。解决:先按上面的 Nginx 配置加try_files或 rewrite,再检查runtime权限和 PHP 错误日志。

现象二:支付成功但订单一直是待支付。原因通常是回调路由被登录中间件拦截,或者验签密钥配错。解决:把pay/notify加进白名单,对照支付渠道文档检查待签串拼接顺序,日志里打出原始报文和本地拼串结果比对。

现象三:玩家从渠道链接注册,后台渠道显示为空。原因可能是落地页参数没写进 cookie,或者注册接口没读上下文。解决:从落地页开始逐段打日志,确认channel_id在每一步都存在,重点看 cookie 域和过期时间。

现象四:分成金额和渠道对不上。原因常见于按充值金额而不是实付金额算,或者退款没有冲正。解决:统一口径为实付金额,退款插负流水,月底用订单表和分成流水表做一次合计比对。

现象五:渠道商登录后能看到别家数据。原因是没有在查询里强制加channel_id条件。解决:渠道后台的模型层统一加全局作用域,所有列表和导出都带当前渠道 ID,别指望前端传参过滤。

6. 二次开发改造:新增渠道类型与分成规则的落地方法

这套系统跑通之后,二次开发最常见的两个需求是加渠道层级和把分成比例做成可配置。渠道层级用于代理模式:总代理下面挂子渠道,推广链接带子渠道标识,统计时按树聚合。分成规则配置化用于不同游戏、不同渠道、不同时间段给不同比例,避免改一次比例就动代码。

先给channel表加parent_id和level字段,推广链接生成时多带一个sub_channel_id,落地页校验子渠道是否属于当前总渠道。统计查询用递归或闭包表,数据量不大时递归 CTE 就够。分成规则单独建表:

CREATE TABLE `share_rule` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `game_id` int unsigned NOT NULL DEFAULT '0' COMMENT '0表示全游戏通用', `channel_id` int unsigned NOT NULL DEFAULT '0' COMMENT '0表示全渠道', `rate` decimal(5,4) NOT NULL COMMENT '渠道分成比例', `start_time` int unsigned DEFAULT NULL, `end_time` int unsigned DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分成规则表';

查询规则时按“越具体优先级越高”排序:

<?php // 按游戏、渠道、时间匹配分成规则,找不到则回落到渠道默认比例 public function getShareRate(int $gameId, int $channelId, int $now): float { $rule = Db::name('share_rule') ->where(function ($q) use ($gameId) { $q->where('game_id', $gameId)->whereOr('game_id', 0); }) ->where(function ($q) use ($channelId) { $q->where('channel_id', $channelId)->whereOr('channel_id', 0); }) ->where('start_time', '<=', $now) ->where('end_time', '>=', $now) ->order('game_id desc, channel_id desc') ->find(); return $rule ? (float)$rule['rate'] : 0.3; }

这段逻辑替换掉原来直接读channel.share_rate的地方,订单回调生成分成流水时调用。验证方法不复杂:造一笔小额测试订单,走沙箱回调,查share_bill里的rate和channel_fee是否符合规则表预期;再把规则表的start_time调到未来,确认回落到默认比例;最后跑一遍退款,确认冲正流水金额是负数且绝对值等于原分成。

我一般还会加一个对账脚本,每天凌晨比对order表已支付金额和share_bill的amount合计,差值不为零就告警。这个脚本不复杂,但能提前发现回调漏单和重复分账。从那以后我每次接联运后台,第一步都是先把订单状态机和分成口径写成一张表贴在显示器边上,再动代码,免得写到一半又回头改字段。希望帮到你。

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

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

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

立即咨询