推广系统源码实战:短链归因、佣金结算与防刷对账

发布时间:2026/10/30 17:12:21
推广系统源码实战:短链归因、佣金结算与防刷对账

简介:面向有PHP基础的开发者与中小型推广平台搭建者,这套推广系统源码提供可查看、可修改的完整实现,用于搭建广告投放、推广者管理、转化跟踪与结算分析等业务流程。压缩包共14个文件、约907KB,以6个PHP脚本为核心业务入口,辅以3个url快捷链接、易语言模块与动态库文件,以及备份和说明文本,规模轻量但模块指向明确。已有1824人学习下载,说明其在推广系统学习与二次开发场景中具有一定参考价值。读者可据此梳理用户管理、广告管理、跟踪代码、报表统计、支付结算和API对接等关键模块的实现思路,并按自身业务调整推广算法、增加广告渠道或集成第三方服务;同时也能借备份与说明文件了解目录组织和部署前检查要点,适合作为PHP推广平台原型与功能拆解的学习素材。

1. 一条推广链接背后的四个环节,缺一个就是糊涂账

上个月有个做工具类小程序的同行来问我,投放后台里那条“注册来源”到底能不能信。他们花钱买量,月底对账时渠道方报的量和自己库里的量差了两成,谁也说不清差在哪。这类问题的根源往往不在投放,而在推广系统本身——链接怎么发、落地页参数怎么带、注册时怎么落库、佣金按什么口径算,四个环节任何一个环节搞不清楚,后面就全是糊涂账。这份推广系统源码解决的就是这条链路:渠道建档、推广链接与二维码生成、点击与注册归因、佣金冻结与结算,后端服务、管理后台、数据库脚本是配套的,不是只给几个页面截图的空壳。适合自己要做投放或分销的中小团队,也适合手里接了个推广系统源码二开需求、想先有个能跑的底子对照的开发者。

2. 把源码跑起来:三端启动顺序与数据库初始化

拿到一套没跑过的推广系统源码,很多人第一反应是先看代码。我一般反过来,先把目录结构、依赖版本、启动顺序三件事理清楚,代码可以慢慢读,环境跑不起来全是空谈。这类系统跟单体业务系统不一样的地方在于,它天然是“对外接口 + 异步任务 + 管理后台”三段式,三端跑起来的先后顺序错了,你会看到一堆莫名其妙的空指针和连不上的 Redis。

2.1 目录结构决定了你能改哪一层

一套完整的推广系统源码通常拆成五块:promotion-api是对外接口层,落地页取参、短链跳转、注册回传、订单回调都走它;promotion-job是定时任务层,佣金解冻、每日对账、渠道日报都在这;promotion-admin是后台前端,渠道管理、链接生成、结算审核的界面;promotion-common放公共实体和工具;sql/目录放建表脚本和初始化数据。这个拆法不是随便定的,api 层要扛外部流量,可能被刷;job 层跑批不能和 api 抢连接池;admin 是内部人用的,权限模型和 api 完全两套。你要二开的时候先想清楚改的是哪一层,把结算逻辑塞进 api 层是新手最容易犯的错,跑量的时候一次全表扫描能把接口拖死。

依赖上,常见配置是 JDK 8、Maven 3.6 以上、MySQL 5.7 或 8.0(字符集必须是 utf8mb4,渠道名和推广文案里带表情符号很常见,utf8 存不进去)、Redis 5 以上、Node 14 以上给前端。别小看字符集这一条,我见过不止一次上线后渠道名称显示成问号,回头改字符集要重建整张表。

2.2 建表顺序和配置项,先看这三处

数据库脚本不要一次性全执行完就不管了,先确认表之间的依赖顺序:渠道表p_channel→ 推广链接表p_short_link→ 点击日志表p_click_log→ 用户绑定表p_user_bind→ 订单表p_order→ 佣金流水表p_commission_flow→ 提现表p_withdraw。外键我一般不加,全靠索引和代码约束,因为这类系统后面大概率要分库或者做冷热分离,外键是纯累赘。

初始化完成后先跑三条检查,别急着启动服务:

-- 1. 渠道字典有没有初始数据,没有的话后台登录进去一片空白 SELECT channel_code, channel_name, status FROM p_channel LIMIT 10; -- 2. 短码唯一索引,这张表并发写最多,索引缺了必然串号 SHOW INDEX FROM p_short_link WHERE Key_name = 'uk_short_code'; -- 3. 佣金流水的幂等唯一键,是防重复结算的最后一道闸 SHOW INDEX FROM p_commission_flow WHERE Key_name = 'uk_order_user_type';

三条里任何一条查不到结果,先别往下走。第二条缺索引,后面高并发生成短链会出现两条记录同一个码,用户点进去归因到别人头上;第三条缺索引,支付回调重试一次就多算一笔佣金,这类问题上线后才发现,追账能追到你怀疑人生。

配置文件里改四个地方就能跑起来,其余保持默认:

server: port: 8081 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/promotion?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=UTC username: promo password: change_me redis: host: 127.0.0.1 port: 6379 database: 3 # 单独一个库,别和业务缓存混用,清库时容易误伤 promotion: short-domain: s.example.com # 短链域名,换成自己的 sign-secret: 换成32位随机串 # 签名密钥,源码里的默认值上线必须换 attr-window-days: 7 # 归因窗口,点击后多少天内注册还算这个渠道 freeze-days: 7 # 佣金冻结天数,对应退款风险期

serverTimezone=UTC这一项别随手改成 Asia/Shanghai,后面第 5 章会讲到跨天统计对不上的坑就是从这来的。sign-secret是链接防篡改的根,源码里通常给了个默认字符串,很多人本地跑通了就直接上生产,等于把生成链接的权限公开了,谁都能给你的渠道刷量。

2.3 三端启动顺序与最小验证

启动顺序固定为:MySQL / Redis → 执行 sql 脚本 →promotion-api→promotion-job→promotion-admin。api 先起是因为 job 里的任务启动时会调 api 的健康检查,反过来会报一堆连接拒绝,看着吓人其实没坏。

# 1. 接口服务,落地页、短链、回调都走它 java -jar promotion-api.jar --spring.profiles.active=dev # 2. 任务服务,结算/解冻/对账在这里,生产环境建议单独一台机器 java -jar promotion-job.jar --spring.profiles.active=dev # 3. 后台前端,本地调试用 dev server,生产环境打静态包丢 nginx cd promotion-admin && npm install && npm run dev

三端起来后别急着点界面,先用命令行把主链路走一遍,这是判断“源码真实可用”最快的方式:

# 生成一条推广链接,sign 按 channelCode + uid + ts + secret 拼接后取 md5 curl -X POST 'http://127.0.0.1:8081/api/link/create' \ -H 'Content-Type: application/json' \ -d '{"channelCode":"C1001","uid":10086,"ts":1710000000,"sign":"<按规则算出的签名>"}' # 拿到短码后走一次跳转,重点看 Location 里原始参数有没有丢 curl -I 'http://127.0.0.1:8081/s/Ab3xK9'

第一条返回里应该有shortCode和fullUrl两个字段;第二条必须是 302,并且Location里带着ch和clk两个参数。如果 302 了但参数没了,说明短链服务只存了目标地址没存原始 query,这是这套源码最常见的改造成本所在,第 5 章第一条会细说。

3. 推广链接与归因落库:短链发号、签名与绑定幂等

推广系统的价值密度最高的部分就在这一章。前面跑起来了只是能看,链接怎么发、参数怎么透传、注册时怎么把人和渠道对上,决定了你后面所有数据的可信度。归因错了,佣金算得再准也是错的。

3.1 短码别用自增 ID,签名别省

短码生成有三个方案:数据库自增 ID 转 62 进制、Redis 号段转 62 进制、纯随机 6 位加重试。自增 ID 最省事但最危险,别人拿到s/1001就知道你一共发了多少条链接,顺手遍历一遍就能把你的渠道数据摸干净。我一般用 Redis 号段加固定盐做异或混淆,既保证唯一又不可顺序推测:

// 短码生成:Redis 号段取序 + 62 进制 + 固定盐混淆 public String genShortCode(long seq) { long mixed = seq ^ 0x5F3A9CL; // 混淆因子,一旦上线就不能改,改了历史短码全部失效 String alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; StringBuilder sb = new StringBuilder(); while (mixed > 0) { sb.append(alphabet.charAt((int) (mixed % 62))); mixed /= 62; } return sb.reverse().toString(); // 6 位约 568 亿组合,中小团队用十年都够 }

逻辑很简单,但参数有两处必须注意。混淆因子0x5F3A9C是硬编码在代码里的盐,上线后不能动,动了之后所有已投放的短链全部指向错误目标,这个损失比你想的大。号段步长建议设 1000 并落库持久化当前最大值,服务重启时从库里恢复,纯内存计数一重启就从头开始,短码会覆盖。号段用完后异步预取下一段,别在请求线程里同步去抢。

签名校验放在/api/link/create上,规则是md5(channelCode + uid + ts + secret),ts和服务端时间差超过 5 分钟直接拒。这不是防黑客,是防自己的后台被人随手改个uid就生成一堆挂在别人名下的链接。

3.2 参数透传:前端只做兜底,服务端才是准绳

归因链路的标准做法是:短链跳转时服务端下发一个clk(点击 ID),落地页把它和渠道码一起存进 localStorage,注册请求带上;后端以p_click_log里这条clk记录为准落绑定表,前端传上来的渠道码只在前端异常时兜底。为什么不让前端说了算?因为 localStorage 可以被清、可以被改、跨端还会丢,而点击日志是你自己写的,改不了。

// 落地页脚本:只负责留参,不做任何判定 (function () { const qs = new URLSearchParams(location.search); const ch = qs.get('ch'); // 渠道码,用于兜底 const clk = qs.get('clk'); // 点击 ID,服务端归因的唯一凭据 if (!ch && !clk) return; // 覆盖写:同一次会话以最后一次落地为准,跨天由后端归因窗口兜底 localStorage.setItem('promo_ch', ch || ''); localStorage.setItem('promo_clk', clk || ''); localStorage.setItem('promo_ts', Date.now()); })();

promo_ts这个时间戳别省,注册时一起传上去,后端用它判断有没有超出attr-window-days窗口。窗口内算这个渠道,窗口外算自然量,规则写死在代码里,别让运营口头约定,口头约定在结算日一定会变成争议。

3.3 绑定表必须幂等,一个用户只能属于一个渠道

用户绑定表p_user_bind上一定要有uk_user_id唯一索引。这不是性能优化,是业务约束:一个注册用户只能归属一个渠道,否则同一个订单会被两个渠道同时算佣金,结算时你连该扣谁的钱都判断不了。常见做法是插入用INSERT IGNORE,失败就直接返回已绑定,不要抛异常给前端。

首次归因还是末次归因,这个选择要在系统上线前定死。投放买量场景一般是首次归因,用户第一次从哪个渠道来就归谁,避免渠道之间互相抢量;分销裂变场景常用末次归因,最后一个邀请人拿佣金,激励更直接。源码里通常做成配置项,改之前先把历史数据想清楚,切换归因模型等于把过去的数据口径全改了,运营那边会炸。

4. 佣金结算链路:规则表、状态机与对账脚本

到这一章才是真正容易出事的环节。推广系统出 bug 不外乎两种:归因错了,或者钱算错了。归因错了运营骂你,钱算错了财务和渠道一起找你。结算链路的代码量不大,但每行都得想清楚重复执行会怎样。

4.1 分润规则落表:比例用万分比,金额用分

规则表设计的关键是支持多档阶梯,还要支持同一渠道不同时间段的不同比例。我一般这么建:

-- 分润规则表:同渠道多档,按生效时间 + 金额区间命中唯一一条 CREATE TABLE p_commission_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, channel_code VARCHAR(32) NOT NULL, level TINYINT NOT NULL DEFAULT 1 COMMENT '1 一级 2 二级', min_amount INT NOT NULL DEFAULT 0 COMMENT '订单金额下限,单位分', max_amount INT NOT NULL DEFAULT 99999999 COMMENT '上限,单位分', rate_bp INT NOT NULL COMMENT '比例,万分比,300 即 3%', effect_from DATETIME NOT NULL, effect_to DATETIME DEFAULT NULL, KEY idx_channel_time (channel_code, effect_from) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个参数选择值得说明。比例用万分比整数300而不是0.03,是为了避开浮点误差,0.03 * 199在 Java 里算出来是5.969999999999999,四舍五入之后就差一分钱,一天几千单就是几块钱的账对不上。金额统一用分存INT或BIGINT,前端展示时再除 100,这条规则在整套系统里必须贯彻到底,中间任何一处用了double都要改回来。

规则命中逻辑:按channel_code查生效时间覆盖当前时间的记录,再按订单金额落进[min_amount, max_amount)区间,命中多条取effect_from最新的那条。别用BETWEEN,区间边界会重叠,max_amount那一条必然被算两次。

4.2 冻结、解冻、提现:状态机不能省

佣金流水的状态我固定设五档:0 冻结、1 可提现、2 已提现、3 已冲正、4 已取消。订单支付成功时写一条状态 0 的流水,同时落unfreeze_at = 当前时间 + freeze_days;job 每天扫一遍到期且状态为 0 的记录改成 1;用户发起提现时锁住对应流水改成 2;订单退款时把还没提现的流水改成 3。

@Transactional(rollbackFor = Exception.class) public void settle(String orderNo, long userId, String channelCode, int payAmountFen) { // 依赖 uk_order_user_type 唯一键兜底,重复回调会抛 DuplicateKeyException CommissionFlow flow = new CommissionFlow(); flow.setOrderNo(orderNo); flow.setUserId(userId); flow.setChannelCode(channelCode); flow.setBaseFen(payAmountFen); // 计佣基数,注意是实付不是应付 flow.setAmountFen(calcRule(channelCode, payAmountFen)); flow.setStatus(0); // 0 冻结 flow.setUnfreezeAt(LocalDateTime.now().plusDays(freezeDays)); flowMapper.insert(flow); }

payAmountFen一定要用实付金额,不是订单原价。用了优惠券的订单按原价算佣金,等于平台自己把补贴也分给渠道了,这个错误在带优惠券的电商场景里极其常见,而且前几个月没人发现,因为差异金额小、分散在几千单里。唯一键冲突的处理方式是在调用方 catch 掉DuplicateKeyException直接返回成功,别往外抛,支付回调那边看到失败会一直重试。

4.3 每日对账:差异要落到表里,不要只看日志

对账脚本每天凌晨跑一次,把当天已支付订单和佣金流水做左连接比对,差异写进临时表,运营第二天上班第一件事就是看这张表有没有数据。光打日志没人看,日志滚一圈就没了。

-- 订单侧实付金额 vs 佣金侧计佣基数,不等于就是异常 SELECT o.order_no, o.pay_amount_fen, IFNULL(c.base_fen, 0) AS base_fen FROM p_order o LEFT JOIN p_commission_flow c ON c.order_no = o.order_no AND c.type = 1 WHERE o.status = 1 AND o.pay_time >= CURDATE() - INTERVAL 1 DAY AND o.pay_time < CURDATE() AND IFNULL(c.base_fen, 0) <> o.pay_amount_fen;

这条 SQL 会捞出两类问题:一是订单支付了但没生成佣金流水,通常是回调丢了或者渠道码为空;二是有流水但基数对不上,通常是部分退款后没同步冲正。两类都要人工介入,脚本不做自动修复,自动补钱的脚本一旦逻辑有误就是批量事故。

5. 避坑与排查:五条血泪记录,从现象倒推原因

这套源码我在不同环境里跑过几轮,踩过的坑基本集中在这五条上。每条都按“现象 → 原因 → 解决”写,你对照自己的现象直接找。

5.1 归因链路断在哪儿

现象一:短链跳转 302 正常,但落地页拿不到ch参数。

原因:短链服务建表时只存了target_url,跳转时直接sendRedirect,原始 query 在生成短码那一步就被丢掉了。很多人以为是 URL encode 问题,改半天URLEncoder没用。

解决:p_short_link加一列params存原始参数串(Base64 编码,避免特殊字符),跳转时解码回拼到Location后面。改完之后用curl -I看Location里是不是原样带着参数,这是唯一可靠的验证方式。

现象二:一个用户被绑定到两个渠道,两个渠道都来要佣金。

原因:p_user_bind只建了普通索引没建唯一索引,加上注册接口有并发,两个请求同时查到“未绑定”就都插入了。

解决:先洗数据保留最早一条,再补uk_user_id唯一索引,插入语句换成INSERT IGNORE。别想着靠代码里的if (exists)判断,那个判断和插入之间永远有窗口。

5.2 钱对不上的三类差异

现象三:支付回调重试一次,佣金流水多一条。

原因:p_commission_flow的幂等键建在了order_no单列上,但二级分销场景同一订单要给两个人分佣,唯一键冲突被误判成重复请求。于是有人把唯一键删了,问题反而更大。

解决:唯一键改成(order_no, user_id, type)三列组合,既挡住重复回调又不影响多级分佣。冲突时返回成功不抛错。

现象四:财务报表差几分钱,逐单核对又都对。

原因:某处代码用了double算比例,0.03 * 199得5.969999...,四舍五入后是 6 分还是 5 分取决于舍入方式。分散在上万单里就是几十块的差异。

解决:全链路改整数分,比例用万分比,最后一步Math.round(amountFen * rateBp / 10000.0)只在唯一一个工具方法里做,其他任何地方不许直接算钱。

现象五:日报和前一天的日报对不上,凌晨那一小时的单子两边都算或都不算。

原因:数据库连接串写了serverTimezone=UTC,但统计 SQL 用的是CURDATE(),跑在东八区服务器上,跨天边界偏了 8 小时。

解决:统计口径统一用 UTC 存、按东八区展示,CURDATE()全部换成显式的时间区间参数,由应用层算好边界再传进去。这条改起来琐碎,但不改的话每月 1 号的对账永远对不平。

6. 上线前的防刷阈值与回归验证:一条 click_id 串全链路

源码能跑起来和能上线是两件事。推广系统天生暴露在公网,链接一旦发出去就会有人刷,你不可能靠事后审计发现问题,必须在写点击那一步就把闸门放好。

// 同一设备 + 渠道 60 秒内只记一次点击,既防刷量也防日志表被打爆 public boolean tryClick(String deviceId, String channelCode) { String key = "clk:" + channelCode + ":" + deviceId; Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(60)); return Boolean.TRUE.equals(ok); }

限频维度不能只用 IP,移动网络下同一个出口 IP 可能对应上千个真实用户,用 IP 会误杀;设备指纹加渠道的组合更贴近真实场景。60 秒这个窗口是我试过比较平衡的值,再短挡不住脚本,再长会吃掉正常的重复点击。

上线前我固定会把这张表过一遍,阈值按自己的业务量调,但每一项都必须配处置动作,只告警不处置等于没做:

指标触发阈值处置动作
单设备日点击超过 30 次丢弃点击并打标,不写绑定
单 IP 小时注册超过 20 个注册进人工审核队列,佣金延后解冻
单渠道日佣金环比超过 300%冻结该渠道当日结算,人工核对
提现单笔金额超过 5000 元强制人工复核,不自动打款
同一收款账户多账号提现关联 3 个以上全部挂起,走风控审核

验证方法比阈值更重要。我一般造三个渠道、每个渠道 100 次点击、20 笔订单,其中混入 5 笔退款、3 笔重复回调,跑完看四件事:绑定表用户数是不是 60、佣金流水是不是 60 条、退款后冲正的是不是 5 条、对账脚本输出是不是空。四个数对上,这套链路才算真的通了。

最后一个技巧,也是我认为最值钱的一条:把clk这个点击 ID 贯穿到全链路。落地页带它、绑定表存它、订单备注里写它、佣金流水的扩展字段里留着它,日志里每一行都打它。出问题的时候你只要拿到用户投诉的那一条记录,就能把点击、注册、下单、结算整条链路串起来看,不用在四张表里靠时间戳猜。没有这个 ID,排查一次归因问题平均要两小时;有了它,十分钟。

从那以后我每次接这类推广系统源码,不管对方说得多完整,我都会先用一个真实的clk从落地页走到佣金流水,走不通就不谈后面的事。链路能串起来,代码才值得读。希望帮到你。

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