简介:PHP代理分销系统是一套基于PHP语言构建的电子商务解决方案,面向希望搭建代理分销业务的企业或个人开发者。系统覆盖完整前后台:前端支持用户注册登录、商品展示、购物车、提交订单与支付、订单状态跟踪;后端提供商品管理、代理与分销商注册审核、等级设置、佣金计算与结算、订单处理、用户管理、数据统计等功能,并采用MVC架构整合MySQL数据库及支付宝/微信支付等API接口,既有基础电商能力又突出代理分销特色。压缩包为rar格式,大小约11.5MB,目前已有1349人浏览学习。通过研究这份源码,可深入理解商城系统的角色权限、订单流转、佣金算法和数据库表设计,也能学习到PHP项目分层开发、支付对接及SQL防注入等安全处理技巧。对于想从事PHP电商开发、研究分销模式或进行二次定制的技术人员,这是一份内容完整、实用价值较高的参考案例。
1. PHP代理分销系统:先搞清楚它在解决什么问题
很多做电商、课程、SaaS的团队,业务跑起来之后的第一件事,就是搞一套代理分销。PHP代理分销系统听起来像是一个后台管理面板,实际上是一整套围绕“人拉人、人卖货、分钱”的规则引擎。它要回答四个问题:谁是谁的上级,订单算谁的业绩,佣金按什么比例分,以及钱怎么安全地发出去。
这套系统最适合正在从单商户往多级代理模式转型的团队,也适合接外包需要快速交付的开发者。如果业务刚起步,先别想着上微服务、上队列,用PHP把分销闭环跑通、把账算对,比堆架构重要得多。这篇文章不聊概念,直接讲我把这套系统从零搭到能稳定处理订单、不出乱子的具体实现,也会把并发、退款、提现这几个最容易翻车的点单独拿出来说。
2. 代理分销系统的核心模型:从订单到分佣的链路
分销系统的本质不是多一张表,而是一条链路:用户点击代理的推广链接注册,注册时记录推荐关系;用户下单时锁定订单归属;支付成功后触发分佣计算;最终生成佣金流水并进入可提现余额。这条链路里任何一个环节没锁死,后面都会出乱子。这一章先把链路拆开讲清楚,再给出可以直接用的表结构和规则设计。
2.1 分销层级与代理关系的数据建模
代理关系是一棵多叉树。一个用户只能有一个上级代理,但可以带出无数个下级。很多人第一版设计喜欢把整棵层级树存在一张表里,用 parent_id 加 level 字段。这样做查询直观,但层级一深,更新路径就变成了递归操作,性能和复杂度都很难看。
我一般会拆成两张表:一张用户表只记录用户自身属性,一张代理关系表专门记录链路上的归属。比较稳的做法是记录直接上级 referrer_id,同时维护一个 path 字段,用冗余路径保存从根到当前节点的完整上级链。这样查某个代理的所有下级时,一条 LIKE 查询就能解决,不用递归。
建表的时候,核心字段如下。注意 path 字段的格式,我习惯用两端带斜杠的ID串,比如 /1/3/7/,这样查询时用 LIKE '/7/%' 或 LIKE '%/7/%' 不会误匹配到 17、27 这种ID。
CREATE TABLE agent_relation ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL COMMENT '用户ID', referrer_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '直接上级代理ID,0表示无', path VARCHAR(255) NOT NULL DEFAULT '/0/' COMMENT '完整上级链,如 /1/3/7/', level SMALLINT NOT NULL DEFAULT 0 COMMENT '当前用户在链路上的层级', created_at DATETIME NOT NULL, UNIQUE KEY uk_user (user_id), KEY idx_referrer (referrer_id), KEY idx_path (path(64)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;唯一约束加在 user_id 上,是因为一个用户在整个系统里只能属于一条推荐链,重复绑定推荐关系会让后续订单归属判定产生歧义。referrer_id 索引用于查询某个代理名下的直接下级,path 的截断索引用于前缀匹配整条上级链。注册时写入推荐关系有一个前提:必须先确认推荐人本身是合法且未被禁用的代理,不能直接信任前端传过来的推广参数。
2.2 订单归属判定:推荐链与结算时点
订单归属是最容易扯皮的地方。A 用户先点了 B 的链接注册,但后来在 C 的直播间下单,这单到底算谁的?绝大多数分销平台的规则是:按注册时的推荐关系算,而不是按下单来源算。也就是说,用户一旦和代理绑定了关系,之后无论从哪里进来下单,业绩都归上级。
这个规则在代码里的体现就是:订单表冗余一个 belong_agent_id 字段,在下单时从代理关系表实时查出上级ID写入订单,而不是在支付回调时才去查。原因很简单:支付回调时可能用户已经换了上级,或者推荐关系表正在被并发写入,这时候再查就容易串单。在下单接口里锁定归属的代码:
public function createOrder($userId, $orderData) { $agent = $this->db->query( 'SELECT referrer_id, level FROM agent_relation WHERE user_id = ?', [$userId] )->fetch(); // 归属代理取直接上级;如果上级已失效,则向上找第一个有效代理 $belongAgentId = $this->findFirstValidAgent($agent['referrer_id'] ?? 0); $this->db->beginTransaction(); try { $orderId = $this->db->insert('orders', [ 'user_id' => $userId, 'belong_agent_id' => $belongAgentId, 'order_sn' => $this->generateOrderSn(), 'order_amount' => $orderData['amount'], 'settle_status' => 0, // 0待结算 1已结算 2结算失败 ]); $this->db->commit(); return $orderId; } catch (\Throwable $e) { $this->db->rollBack(); throw $e; } }findFirstValidAgent 是辅助函数,逻辑是向上递归查找第一个状态正常的代理。这里的 settle_status 字段非常关键,它标记订单是否已经执行过结算,后续所有对账、补单、防重都靠它,所以订单创建时一定要初始化为0。归属ID在订单创建时写入后就不要随便改了,除非运营手动干预。
2.3 分佣规则引擎:固定比例、梯度与级差
分佣规则很容易写成一座泥潭。常见的有三种:固定比例、梯度比例、级差比例。固定比例最简单,所有等级的代理拿同一个百分比;梯度比例按订单金额分档,比如满1000元返10%、满5000元返15%;级差比例是上级拿下级剩余的部分,比如一级代理拿10%,二级代理拿5%,一级实际拿的是10%减去5%等于5%。
第一版建议先做固定比例加简单的等级比例,规则表里留扩展字段,不要一上来就全上。规则表可以这样设计:
CREATE TABLE commission_rule ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, agent_level TINYINT NOT NULL COMMENT '代理等级,0为默认', min_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '订单金额下限', max_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '订单金额上限,0为不限', rate DECIMAL(5,2) NOT NULL COMMENT '佣金比例,百分比', rule_type TINYINT NOT NULL DEFAULT 1 COMMENT '1固定 2梯度 3级差', status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;匹配规则时要注意顺序:先过滤代理等级,再按订单金额区间从小到大匹配,取第一个命中的区间。不要用等值匹配去查比例,因为订单金额是连续的,必须用区间判断。级差模式要额外记录父级规则,建议单独建一张级差配置表,不要在比例表里塞逻辑。这一章讲的模型是整个系统的地基;表结构定完之后,下一步就是把规则串成可运行的下单、结算、发放流程。
3. 用PHP搭建最小可运行的分销闭环:表结构、接口与脚本
模型建好之后,要把它变成能跑的东西。一个最小可运行的闭环至少包含四段:代理注册绑定关系、用户下单锁定归属、支付回调触发结算、定时脚本汇总入账。下面按顺序把每一段的关键代码和参数写清楚。
3.1 核心表结构:订单表、佣金流水表与提现表
除了 agent_relation 和 commission_rule,分销系统最少还需要三张表:订单表、佣金流水表、提现表。订单表记录业务事实,佣金流水表记录每一笔佣金和它的来源订单,提现表把可提现余额变成真实的打款记录。注意所有金额字段都用 DECIMAL 而不是 FLOAT,PHP 做浮点运算会出现 0.1 加 0.2 不等于 0.3 的问题,金额用浮点存是血泪教训。
CREATE TABLE orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, belong_agent_id INT UNSIGNED NOT NULL DEFAULT 0, order_sn VARCHAR(32) NOT NULL, order_amount DECIMAL(10,2) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0, settle_status TINYINT NOT NULL DEFAULT 0, paid_at DATETIME NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_order_sn (order_sn), KEY idx_agent_pay (belong_agent_id, pay_status), KEY idx_settle (settle_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE commission_flow ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, agent_id INT UNSIGNED NOT NULL COMMENT '获得佣金的代理', from_user_id INT UNSIGNED NOT NULL COMMENT '下单用户', amount DECIMAL(10,2) NOT NULL COMMENT '佣金金额', rule_snapshot VARCHAR(255) NOT NULL COMMENT '结算时的规则快照', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待入账 1已入账 2已退回', created_at DATETIME NOT NULL, KEY idx_order (order_id), KEY idx_agent (agent_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE withdrawal ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, agent_id INT UNSIGNED NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1打款中 2完成 3拒绝', audit_note VARCHAR(255) NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_agent_status (agent_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;rule_snapshot 字段是很多人会漏掉的。它把结算那一刻使用的规则序列化存进去,之后规则改了,历史佣金流水仍然能查出来当时是怎么算的,对账时不用猜。我建议存 JSON 字符串,带上规则ID、比例、订单金额,不要只存一个比例数字。
3.2 支付回调触发结算:先写流水,再动余额
支付回调是整个系统里对一致性要求最高的入口。常见错误是在回调里直接给代理余额加钱,然后更新订单结算状态。这样回调一旦被重复触发,代理余额就会翻倍。正确的做法是:回调里只写佣金流水,且通过唯一索引保证同一订单同一代理只能产生一条流水,真正的余额变动交给结算脚本去做。
public function onPayCallback($orderSn) { $order = $this->db->query( 'SELECT id, user_id, belong_agent_id, order_amount FROM orders WHERE order_sn = ?', [$orderSn] )->fetch(); if (!$order || $order['pay_status'] === 1) { return; // 订单不存在或已处理过,直接返回 } $this->db->beginTransaction(); try { // 条件更新:只有 pay_status = 0 时才能更新,防止重复回调 $updated = $this->db->execute( 'UPDATE orders SET pay_status = 1, paid_at = NOW() WHERE id = ? AND pay_status = 0', [$order['id']] ); if ($updated === 0) { $this->db->rollBack(); return; } $this->db->insert('commission_flow', [ 'order_id' => $order['id'], 'agent_id' => $order['belong_agent_id'], 'from_user_id' => $order['user_id'], 'amount' => $this->calcCommission($order), 'rule_snapshot' => json_encode($this->getMatchedRule($order), JSON_UNESCAPED_UNICODE), 'status' => 0, ]); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollBack(); throw $e; } }这段代码里 pay_status 的条件更新是关键。数据库层面用 pay_status = 0 作为条件,就算支付网关把回调推两遍,第二次进来 updated 等于0,直接回滚退出,不会产生副作用。这里没有直接更新代理余额,因为余额更新要等订单过退款保护期,很多系统把这一步做成 T+1 或 T+3 结算。
3.3 定时结算脚本:把流水变成可提现余额
结算脚本放在命令行跑,不放在 Web 请求里。它要做的事是:把所有 status = 0 且对应订单已支付超过 N 天的流水,统一更新为 status = 1,同时给代理账户余额加钱。分两段做,第一段查流水,第二段逐条处理,避免一条大 UPDATE 锁表时间过长。
public function settleCommission() { $cutoff = date('Y-m-d H:i:s', strtotime('-3 days')); // 订单支付满3天可结算 $flows = $this->db->query( "SELECT f.id, f.agent_id, f.amount, f.order_id FROM commission_flow f INNER JOIN orders o ON f.order_id = o.id WHERE f.status = 0 AND o.paid_at <= ? AND o.settle_status = 0", [$cutoff] ); foreach ($flows as $flow) { $this->db->beginTransaction(); try { $this->db->execute( 'UPDATE commission_flow SET status = 1 WHERE id = ? AND status = 0', [$flow['id']] ); $this->db->execute( 'UPDATE agent_account SET available_balance = available_balance + ? WHERE agent_id = ?', [$flow['amount'], $flow['agent_id']] ); $this->db->execute( 'UPDATE orders SET settle_status = 1 WHERE id = ?', [$flow['order_id']] ); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollBack(); throw $e; } } }每一笔流水单独开事务,是为了避免一把大事务把所有流水锁到结束,导致业务写操作长时间等待。参数说明:-3 days 是延迟结算天数,也就是退款保护期。对退款风险敏感的业务可以调到 7 或 15;追求体验的业务可以压缩到 T+1。这个参数建议写进后台配置,而不是硬编码在脚本里。
到这里,一个能用的分销闭环已经跑通。但这套代码在并发稍微大一点的时候就会露馅,下一章专门说并发和防重的问题。
4. 并发与一致性:分布式锁、事务和分佣防重
分销系统对一致性的要求比一般业务系统高,因为每一笔钱都不能算错。早期我把所有逻辑都塞进支付回调里,结果活动大促时回调重试导致佣金多发,后来才逐步改成“回调只写流水、脚本再入账”的模型。这章讲并发场景下会踩的坑和对应的解决手段。
4.1 为什么不能直接扣库存再算佣金
很多外包团队喜欢在下单接口里同时完成扣库存、绑定代理、算佣金、更新余额,一气呵成。这个方案在小流量下没问题,但一旦同一件商品同时被多个代理的下级购买,数据库的行锁竞争会让下单接口的响应时间飙升。更危险的是多实例部署时,每个实例各自算出的佣金可能基于同一份过期的规则数据,算出来的金额都不一样,最后入库互相覆盖。
正确拆分方式是把写操作分成三类:下单只写业务数据,支付回调只更新支付状态并写佣金流水,结算脚本只做金额入账。三步之间用订单状态字段串起来,任何一步失败都不影响前一步已经落库的事实。这样做还有一个额外好处:排查问题时可以直接看流水表,不需要翻日志重构当时的计算过程。
4.2 用Redis锁加数据库事务保证结算不重复
佣金流水写入的防重,单靠数据库唯一索引已经能挡住大部分重复。但如果是同一个订单、不同来源的重复推送同时发生,比如支付网关回调加人工补单,唯一索引还没落库的时候,就需要在应用层加锁。我常用的做法是拿订单号作 Redis 分布式锁的 key,加锁成功后才继续处理。
public function settleOrder($orderSn) { $lockKey = 'settle:lock:' . $orderSn; $locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 30]); if (!$locked) { return; // 另一个请求已拿锁,这里直接放弃 } try { $this->db->beginTransaction(); $order = $this->db->query( 'SELECT * FROM orders WHERE order_sn = ? FOR UPDATE', [$orderSn] )->fetch(); if ($order['pay_status'] === 1 && $order['settle_status'] === 0) { // 写流水、更新订单结算状态 } $this->db->commit(); } finally { $this->redis->del($lockKey); } }Redis 锁只是减少无效计算,最终的一致性还是靠数据库事务和唯一索引兜底。即使锁因为网络原因失效,数据库层仍然会拒绝重复流水。分布式锁解决的是性能问题,数据库约束解决的是正确性问题,两者不能互相替代。EX 30 表示锁的自动过期时间是30秒,这个值要大于一次结算操作的最长耗时,否则锁提前过期、第二个请求会趁虚而入。如果日志里频繁出现拿不到锁,先看是不是锁粒度过大,比如把整个用户的所有订单都锁住了。
4.3 佣金明细对账与延迟结算
对账不是一个功能,而是一个习惯。佣金系统上线后,我坚持每天跑一次对账脚本,把三张表的数据做交叉验证:订单金额乘以规则比例,是否等于流水表的佣金总额;流水表里 status = 1 的金额之和,是否等于代理账户余额的增量。对不上的时候当天就要处理,不然累积几天就是一笔烂账。
延迟结算本质上是把退款风险转嫁给时间。订单支付后马上结算,用户一退款,代理余额已经提现走了,追回成本会变得很高。常见做法是 T+3 结算,也就是支付成功后第三天再把佣金入账,给退款留一个缓冲窗口。这个参数建议做成后台可配置,不要写死在代码里,因为不同商品的退货率完全不同。
5. 代理分销系统避坑指南:5个真实翻车现场
分销系统的坑不在功能复杂度,而在边界情况。有些问题表面看着像玄学,实际原因往往是状态更新少了条件,或者少做了一张快照表。下面这5个问题,几乎每个新上线的分销项目都会碰到,我把现象、原因和解决方式一次写清楚。
5.1 现象:同一订单被结算两次,代理余额翻倍
现象:活动大促时,支付渠道回调超时自动重试,同一笔订单的回调在十几分钟内到达了两次。第一次回调正常写入流水并加余额,第二次回调因为没有校验,把同样的佣金又加了一遍,代理后台的余额直接翻倍。
原因:支付状态更新没有使用条件更新,代码只判断了“订单是否存在”,没有判断“订单是否已经从待支付变成已支付”。两个回调请求几乎同时到达时,第一个请求还没提交事务,第二个已经通过了状态查询。
解决:两条缺一不可。第一,把支付状态更新改成条件更新,WHERE 里强制带 pay_status = 0,受影响行数为0就直接放弃。第二,给 commission_flow 表加唯一索引,从数据库层面拒绝重复流水。
ALTER TABLE commission_flow ADD UNIQUE KEY uk_order_agent (order_id, agent_id);这条 SQL 是后悔药,也是最强的兜底。它加上之后,即使应用层出现漏洞,数据库也会直接抛错并把事务回滚。唯一索引加上后,记得在代码里捕获插入重复的异常,不要让错误直接打到回调日志。
5.2 现象:上级代理的佣金算错了,明细查不出来
现象:运营把一级代理的佣金比例从10%调到8%,结果发现调整之前产生的历史佣金流水,在后台详情页显示的比例变成了8%,金额和当时的付款记录对不上,代理投诉过来,开发排查了半天没找到原因。
原因:佣金明细展示时实时去查当前的规则表,而不是读取流水表里保存的结算时规则。规则一改,历史数据的展示口径就跟着变,对账自然对不上。
解决:commission_flow 里保存 rule_snapshot,结算那一刻把匹配到的规则序列化写进去。展示和导出佣金明细时只认快照字段。我见过一个项目因为没做快照,对账时把三个月的历史流水重新算了一遍,最后花了两周才把账调平,这种事尽量不要再经历第二次。
5.3 现象:退货退款后,代理佣金没有回滚
现象:用户买完东西申请退款,订单状态变成了已退款,但代理后台的佣金明细里那笔收入还在,余额也没有扣减。
原因:退款逻辑只处理了订单状态和库存,没有触发佣金回滚。支付回调和退款回调是两套独立逻辑,退款这边漏掉了佣金处理。
解决:退款回调里生成金额为负的佣金流水,状态记为“已退回”。先写流水再扣余额,和正向结算保持同一个习惯。同时给订单和退款单建立关联,同一个订单退款两次时,第二次直接忽略,避免重复回滚把代理余额扣成负数。
5.4 现象:代理提现后出现负余额,运营不知道怎么处理
现象:一个代理申请提现成功,后来他下线的一笔订单发生退款,回滚佣金后这个代理的账户余额变成了负数,连续提现被拦截,客诉飙升。
原因:提现额度用的是“预计可提现”,包含了还没过退款保护期的佣金。真正可以提现的金额,应该只算已经结算超过保护期的部分。
解决:账户表拆成 available_balance 和 frozen_balance 两个字段。发起提现时冻结余额,审核通过后真正扣减,审核拒绝则解冻。退款回滚时如果 available_balance 不够,就扣 frozen_balance,还不够产生负值时,标记该代理为风险账户,触发人工审核。每一次余额变动都要写余额流水表,这样出现负数时能倒查出是哪一笔退款导致的。
5.5 现象:数据量一大,佣金报表查询卡死
现象:订单量到了几十万之后,后台分销报表页面响应时间从2秒涨到20秒,数据库CPU持续跑满。开发看日志发现是报表实时在做 GROUP BY agent_id 聚合,再关联流水表,扫描行数太大。
原因:报表没有预聚合,每次请求都实时对订单表做分组统计,数据量大了自然扛不住。
解决:每天凌晨跑定时任务,生成一张日汇总表,字段包含代理ID、下级人数、订单数、订单总额、佣金总额、退款数、退款金额。报表和后台列表只查汇总表,查询毫秒级返回。日汇总表还有一个好处:可以按天回溯,哪一天口径出了问题,直接对比当天的汇总数据和流水明细就能定位。
注意:日汇总表的统计口径必须和佣金流水表一致,否则报表数据和账目对不上,又是一个新的坑。
6. 从能用变好用:分佣试算、预生成流水与压测验证
系统跑顺之后,下一步不是急着加功能,而是把账算得更透明、更可验证。这里讲三个我自己常用的进阶手段。
分佣试算接口是第一个要补的。运营在后台改规则、代理在手机端查“这一单我能赚多少”,都需要一个只计算不落库的接口。我一般让试算和真实结算共用同一个规则匹配函数,只是把最后的写库动作换成预览输出。
public function previewCommission($orderAmount, $agentLevel) { $rule = $this->matchRule($orderAmount, $agentLevel); return [ 'rate' => $rule['rate'], 'amount' => round($orderAmount * $rule['rate'] / 100, 2), ]; }输出结果直接给前端展示,不写任何流水表。注意 preview 接口要加缓存或限流,防止被刷接口消耗数据库资源。
第二个手段是预生成流水。佣金流水不一定非要在支付回调时写,也可以在下单时就预生成一条状态为“待生效”的流水。支付成功后把状态改成“待入账”,退款时改成“已作废”。这样做的好处是流水的生命周期和订单完全同步,月底对账时每一笔订单都能找到对应的流水轨迹,哪怕是买完还没支付的订单也有记录。缺点是流水表会比实时写入多出不少无效记录,需要定期清理已作废的过期数据。
第三个手段是压测验证。分销系统最怕的是大促时回调重试风暴,上线前一定要做一次重复回调压测。方法很简单:拿一个测试订单,把支付回调脚本同时跑10遍,看最终流水表是不是只有一条;再把结算脚本挂上定时任务,模拟几十万订单流水入账,观察数据库锁等待和事务耗时。我习惯把每次压测的结果记录下来,规则调整后对比耗时差异,避免系统越改越慢。
分销系统维护久了之后,我把核心逻辑收敛到了一个非常朴素的原则里:该落库的事实一定落库,该加的条件更新一条都不能少,规则快照永远冗余保存。坚持这个习惯,半夜被运营叫起来对账的次数就真的少了。希望帮到你。
本文还有配套的精品资源,点击获取