☰
PHP代理分销系统:闭包表+分佣快照+订单归属引擎实现
2026/10/18 13:10:56 网站建设 项目流程

简介:这是一套基于PHP开发的代理分销型商城系统,面向中小型电商企业、创业团队及PHP中高级开发者,解决多级代理管理、佣金自动结算与分销渠道拓展等核心运营难题。系统采用MVC架构,前后端功能完备:前端支持用户注册登录、商品展示、购物车、订单支付与客户服务;后端涵盖商品/订单/用户/代理等级/佣金/数据统计等六大管理模块,并集成支付宝、微信支付及物流API接口。资源包为11.5MB的RAR压缩文件,含完整可运行代码(含MySQL数据库结构)、配置说明与技术文档,主要文件类型包括PHP业务逻辑文件、HTML模板页、SQL建表脚本及JS交互脚本,结构清晰、注释规范,便于二次开发与功能定制。目前已有1349人学习下载,读者可直接部署上线、深入理解分销业务模型、掌握PHP电商系统安全实践(如防SQL注入、XSS防护)及MVC工程化开发流程。

1. 为什么一个“代理分销系统”不能只靠后台权限控制来实现分佣逻辑?

很多同学在做商城类毕设或课设时,第一反应是:“分销不就是多加几个用户角色,再配点权限?”——结果答辩被导师一句“那代理卖出去的商品,佣金怎么实时算?订单取消后佣金要不要回滚?下级代理升级了,历史订单的分润比例要不要追溯调整?”直接问懵。这恰恰说明:代理分销不是权限问题,而是状态驱动的业务流+资金流双轨模型。本套 PHP 代理分销系统方案,面向的是需要交付完整可运行系统的场景:它不依赖第三方 SaaS 接口,所有分佣规则、代理层级、订单归属、结算周期全部由本地代码闭环控制;适用于某高校电商方向课程设计、某实验室模拟项目X 的中期交付,也适合作为某公司内部轻量级渠道管理工具原型。核心能力覆盖三级代理发展(直属/二级/三级)、按商品独立设置分佣比例(支持固定金额与百分比混合)、订单生成即锁定归属关系、结算单自动生成与导出、以及最关键的——佣金变动全程留痕可审计。整套方案基于 PHP 8.1 + MySQL 8.0 构建,无外部 SDK 强依赖,所有逻辑均在 application/ 目录下分层实现,学生可逐模块理解,从业者可直接替换数据库连接后接入自有支付网关。

2. 总体架构设计:MVC 分层 + 四大核心模块的职责边界与数据流向

本系统采用经典 MVC 分层结构,但关键在于各层之间不越界、不跨层调用、不共享状态。Controller 层仅做请求接收、参数校验、跳转调度;Model 层严格封装数据操作与业务规则,禁止出现 echo/print_r;View 层纯渲染,所有变量必须由 Controller 显式传入。整个系统围绕分销业务拆解为四个不可替代的核心模块:代理关系管理、商品分佣配置、订单归属引擎、结算单生成器。它们之间通过明确定义的数据契约交互,而非全局变量或静态方法调用。这种设计让每个模块可独立测试、可单独替换(比如未来想接入 Redis 缓存代理关系,只需重写 Model 中的 getAgentTree() 方法,其余模块完全无感)。下面展开说明各模块定位与协作逻辑。

2.1 代理关系管理模块:用闭包表(Closure Table)解决多级代理的查询与变更难题

传统邻接表(parent_id)在查询某代理的所有下级时需递归,MySQL 深度受限且性能差;而路径枚举(path 字段)又导致更新成本高。本方案采用闭包表设计,在数据库中新增agent_closure表:

CREATE TABLE `agent_closure` ( `ancestor` INT UNSIGNED NOT NULL COMMENT '上级代理ID', `descendant` INT UNSIGNED NOT NULL COMMENT '下级代理ID', `depth` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '层级差,0表示自身', PRIMARY KEY (`ancestor`, `descendant`), KEY `idx_descendant` (`descendant`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

当 A 发展 B,B 发展 C 时,插入记录:

  • (A,A,0), (A,B,1), (A,C,2)
  • (B,B,0), (B,C,1)
  • (C,C,0)

这样,查询 A 的所有下级(含自身)只需一条 SQL:SELECT descendant FROM agent_closure WHERE ancestor = ?;查询 C 的直属上级(B)则:SELECT ancestor FROM agent_closure WHERE descendant = ? AND depth = 1。更重要的是,当 B 升级为区域代理、其下所有子代理自动继承新权限,无需遍历更新,仅需在agent_closure中补全 A→C 的深度为 2 的记录即可。该设计彻底规避了“查所有下级要 N 次查询”的翻车点,也为后续订单归属判断提供了 O(1) 级别的关系判定能力。

2.2 商品分佣配置模块:支持粒度到 SKU 的独立分佣策略与版本快照

分销系统最易被忽略的细节是:不同商品的分佣规则可能完全不同。例如,A 类高毛利商品给一级代理 15%,二级 5%;B 类引流商品则一级仅 3%,但允许三级参与(1%)。若统一配置,后期运营无法灵活调整。本模块采用“商品维度 + 代理等级”二维配置,并引入版本快照机制:

// application/model/CommissionRuleModel.php public function getActiveRuleBySku(string $sku, int $agentLevel): array { // 先查当前生效的最新版本 $rule = $this->db->query( "SELECT * FROM commission_rule WHERE sku = ? AND agent_level = ? AND status = 1 ORDER BY version DESC LIMIT 1", [$sku, $agentLevel] )->fetch(); if (!$rule) { // 降级查通用规则(agent_level = 0) $rule = $this->db->query( "SELECT * FROM commission_rule WHERE sku = ? AND agent_level = 0 AND status = 1 ORDER BY version DESC LIMIT 1", [$sku] )->fetch(); } return $rule ?: ['type' => 'percent', 'value' => 0.0]; }

每次修改分佣比例,系统自动生成新 version(时间戳+随机数),旧规则 status 置为 0。这样,历史订单始终按下单时的规则计算,杜绝“改了规则,老订单佣金突变”的合规风险。同时,前端配置页提供“复制当前规则到新 SKU”按钮,避免重复录入——这是某次帮 A 同学调试时发现的高频需求,他原方案每次换商品都要重填 6 个字段,三天内填错两次。

2.3 订单归属引擎:基于首次触达与邀请码的双重判定机制

订单到底算谁的?这是分销系统的核心争议点。本方案采用“首次触达 + 邀请码强制绑定”双保险:用户首次访问商城时,若 URL 带有?ref=xxx,则将 ref 值存入 session 并写入 cookie(有效期 30 天);后续下单时,优先取 cookie 中的 ref,若为空再查 session;若两者皆空,则归属平台。关键逻辑在 OrderService 中:

// application/service/OrderService.php public function determineAgentId(int $userId): ?int { // 1. 检查用户是否本身就是代理(自购不计佣金) if ($this->agentModel->isAgent($userId)) { return null; } // 2. 从 cookie 获取邀请人ID(ref_id) $refId = $_COOKIE['ref_id'] ?? null; if (!$refId || !is_numeric($refId)) { return null; } // 3. 验证 ref_id 是否真实存在且有效 $agent = $this->agentModel->getById((int)$refId); if (!$agent || $agent['status'] !== 'active') { setcookie('ref_id', '', time() - 3600, '/'); return null; } // 4. 验证该代理是否有权发展此用户(防刷单:同一IP 24小时内最多3单) $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $orderCount = $this->db->query( "SELECT COUNT(*) FROM orders WHERE agent_id = ? AND ip = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)", [$refId, $ip] )->fetchColumn(); if ($orderCount >= 3) { // 触发风控,清空cookie并记录日志 setcookie('ref_id', '', time() - 3600, '/'); $this->logRisk("Ref flood from IP {$ip} for agent {$refId}"); return null; } return (int)$refId; }

这段代码解决了三个实际问题:一是防止代理自己注册小号刷单(自购过滤);二是防止过期邀请码持续生效(cookie 过期自动失效);三是限制恶意刷单(IP 维度频控)。某次线上压测发现,未加 IP 限制时,单个代理 1 秒内能生成 17 笔测试订单,直接导致佣金池虚高——这个风控点是血泪经验换来的。

2.4 结算单生成器:按自然月冻结数据 + 差异化结算状态机

结算不是简单把钱打给代理,而是涉及对账、申诉、打款、开票四阶段。本模块将结算流程抽象为状态机:

状态含义可触发动作
draft草稿(系统自动生成,含所有待结算订单)编辑备注、标记异常
confirmed代理确认无误提交至财务审核
audited财务审核通过生成打款批次
paid已打款导出凭证、归档

每月 1 日凌晨 2 点,系统执行php cli.php settlement:generate --month=2024-05命令,其核心逻辑是:

# cli.php 中的命令入口 case 'settlement:generate': $month = $_GET['month'] ?? date('Y-m'); $generator = new SettlementGenerator($month); $generator->freezeOrders(); // 冻结该月所有已确认订单,禁止再修改 $generator->calculateCommissions(); // 按冻结时的规则计算每笔佣金 $generator->createDraftSettlement(); // 生成草稿结算单 break;

freezeOrders()是关键:它将orders表中status = 'confirmed' AND created_at LIKE '2024-05%'的订单,将其frozen_at字段设为当前时间,并将commission_calculated标记为 true。此后任何对该订单的修改(如退货)都将触发“佣金回滚”流程,而非重新计算——确保结算数据绝对可追溯。这也是为什么我们坚持不用“实时计算佣金”,而必须“冻结快照”的根本原因:财务审计要的是确定性,不是概率。

3. 关键实现细节:从数据库事务到前端防重提交的全链路保障

分销系统最怕什么?不是功能少,而是钱算错、单丢了、重复打款。这些都不是代码炫技能解决的,而是靠一连串看似枯燥的工程细节堆出来的可靠性。下面列出本方案中真正起作用的五个硬核实现点,每一条都来自真实翻车现场。

3.1 佣金计算必须包裹在数据库事务中,且显式指定隔离级别

早期版本曾用“先查余额,再更新”两步法,结果高并发下出现超发。正确做法是:所有涉及资金变动的操作,必须在一个事务内完成,且使用SELECT ... FOR UPDATE锁定行。以代理提现为例:

// application/model/AgentBalanceModel.php public function deductBalance(int $agentId, float $amount, string $remark): bool { try { $this->db->beginTransaction(); // 1. 锁定该代理的余额记录(防止并发扣减) $balance = $this->db->query( "SELECT balance FROM agent_balance WHERE agent_id = ? FOR UPDATE", [$agentId] )->fetch(); if (!$balance || $balance['balance'] < $amount) { throw new Exception("余额不足"); } // 2. 扣减并记录流水 $this->db->query( "UPDATE agent_balance SET balance = balance - ? WHERE agent_id = ?", [$amount, $agentId] ); $this->db->query( "INSERT INTO balance_log (agent_id, amount, type, remark, created_at) VALUES (?, ?, 'withdraw', ?, NOW())", [$agentId, -$amount, $remark] ); $this->db->commit(); return true; } catch (Exception $e) { $this->db->rollback(); $this->logError("提现失败: " . $e->getMessage()); return false; } }

注意:FOR UPDATE必须在事务开启后、任何 UPDATE 之前执行,否则锁无效。某次压力测试中,因漏写这行,100 并发请求导致 3 笔提现成功但只扣了 1 笔余额——这就是没锁住的代价。

3.2 前端提交订单必须带唯一 Token,服务端校验并立即作废

用户手抖连点“提交订单”两次,后端若不做防重,就会生成两笔相同订单。本方案在订单页加载时,由 Controller 生成一次性 Token:

// application/controller/OrderController.php public function showCheckout() { $token = bin2hex(random_bytes(16)); $_SESSION['order_token'] = $token; $_SESSION['token_created_at'] = time(); $this->view->render('order/checkout', ['token' => $token]); }

提交时,前端将 token 放入 hidden input,后端校验:

// application/controller/OrderController.php public function doSubmit() { $token = $_POST['token'] ?? ''; if (!$token || !hash_equals($_SESSION['order_token'] ?? '', $token)) { $this->response->error('非法请求'); return; } // 校验通过,立即作废 token unset($_SESSION['order_token']); unset($_SESSION['token_created_at']); // 后续创建订单逻辑... }

hash_equals()防止时序攻击,unset()确保 token 一次性。这个 Token 不存数据库,只在 session 中存活,简单高效。某次帮某实验室调试,他们原方案用时间戳+用户ID拼接,结果被脚本批量刷单——因为时间戳可预测。

3.3 代理关系变更必须同步更新闭包表,且用存储过程保证原子性

当代理 A 将下级 B 转让给 C 时,需删除 A→B、A→C 子树、B→C 子树等多条记录,并新增 C→B、C→C 子树等。若用 PHP 循环删插,网络中断会导致闭包表残缺。本方案将此逻辑封装为 MySQL 存储过程:

DELIMITER $$ CREATE PROCEDURE TransferAgentDownline( IN p_old_ancestor INT, IN p_new_ancestor INT, IN p_descendant INT ) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 删除旧路径:p_old_ancestor 到 p_descendant 及其所有后代 DELETE ac1 FROM agent_closure ac1 INNER JOIN agent_closure ac2 ON ac1.descendant = ac2.descendant WHERE ac1.ancestor = p_old_ancestor AND ac2.ancestor = p_descendant; -- 插入新路径:p_new_ancestor 到 p_descendant 及其所有后代 INSERT INTO agent_closure (ancestor, descendant, depth) SELECT p_new_ancestor, ac.descendant, ac.depth + 1 FROM agent_closure ac WHERE ac.ancestor = p_descendant; -- 补充自身节点 INSERT INTO agent_closure (ancestor, descendant, depth) VALUES (p_new_ancestor, p_new_ancestor, 0), (p_new_ancestor, p_descendant, 1); COMMIT; END$$ DELIMITER ;

PHP 中只需调用CALL TransferAgentDownline(?, ?, ?)即可。存储过程在数据库内执行,不受 PHP 进程中断影响,真正实现“要么全成功,要么全失败”。

3.4 结算单导出用流式 CSV,避免内存溢出

某次为某公司生成上月 20 万笔订单的结算单,原方案用fputcsv()全部读入内存再输出,PHP 直接 OOM。现改为流式处理:

// application/controller/SettlementController.php public function exportCsv(int $setId) { $settlement = $this->settlementModel->getById($setId); if (!$settlement || $settlement['status'] !== 'confirmed') { $this->response->error('结算单不可导出'); return; } // 设置响应头 header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="settlement_' . $setId . '.csv"'); $output = fopen('php://output', 'w'); fputcsv($output, [ '订单号', '商品名称', '销售价', '佣金比例', '佣金金额', '代理姓名', '代理手机号', '结算状态' ]); // 分页查询,每次 1000 条 $page = 1; do { $items = $this->settlementModel->getItemsBySettlementId($setId, $page, 1000); foreach ($items as $item) { fputcsv($output, [ $item['order_no'], $item['product_name'], $item['sale_price'], $item['commission_rate'] . '%', $item['commission_amount'], $item['agent_name'], $item['agent_phone'], $item['status_text'] ]); } $page++; } while (!empty($items)); fclose($output); }

fopen('php://output', 'w')直接将内容写入 HTTP 响应体,不占 PHP 内存;分页查询避免 MySQL 单次返回过大结果集。这是处理大数据量导出的标配姿势。

3.5 后台操作日志必须记录原始请求参数,而非仅记录结果

很多系统只记“管理员张三修改了代理 A 的状态”,但没记“改成什么状态、之前是什么状态”。一旦出问题,无法还原。本方案强制记录完整请求参数:

// application/middleware/LogMiddleware.php public function handle($request, $next) { $startTime = microtime(true); $response = $next($request); // 记录日志(仅限 admin 路由) if (strpos($request->uri, '/admin/') === 0 && in_array($request->method, ['POST', 'PUT', 'DELETE'])) { $logData = [ 'uri' => $request->uri, 'method' => $request->method, 'params' => $request->all(), // 记录全部 POST/PUT 参数 'ip' => $request->ip, 'user_id' => $_SESSION['admin_id'] ?? 0, 'duration_ms' => round((microtime(true) - $startTime) * 1000), 'status_code' => $response->getStatusCode(), ]; // 敏感字段脱敏 if (isset($logData['params']['password'])) { $logData['params']['password'] = '***'; } if (isset($logData['params']['id_card'])) { $logData['params']['id_card'] = substr($logData['params']['id_card'], 0, 4) . '****' . substr($logData['params']['id_card'], -4); } $this->logModel->insert($logData); } return $response; }

$request->all()获取原始参数,$response->getStatusCode()记录是否成功。脱敏逻辑防止身份证、密码等敏感信息落库。某次排查代理投诉“佣金被莫名清零”,正是靠这条日志查出是某管理员误点了“重置余额”按钮,且参数中明确写了reset_all=true。

4. 避坑指南:代理分销系统开发中最容易翻车的 4 个点及解决方案

做分销系统,90% 的问题不是不会写代码,而是没踩过坑、不知道哪里会塌方。下面这四条,每一条都对应一次真实的线上事故或答辩翻车,按“现象 → 原因 → 解决”给出可立即落地的方案。

4.1 现象:代理升级后,历史订单佣金没变,但新订单按新比例算,财务对不上账

原因:佣金计算逻辑写在订单创建时,但未区分“订单创建时间”和“佣金计算时间”。当代理在 5 月 10 日升级,5 月 1 日的订单仍按旧规则算,但财务要求所有订单统一按升级后规则追溯调整。
解决:在commission_rule表中增加effective_date字段,表示该规则生效日期。佣金计算时,取MIN(order.created_at, rule.effective_date)作为实际生效时间。同时,提供后台“批量重算历史佣金”功能,仅对effective_date早于代理升级日的订单触发重算,并生成差异日志供审计。切记:重算必须人工触发,不可自动执行。

4.2 现象:用户通过代理 A 的链接进站,但最终用代理 B 的账号下单,佣金算给了 B

原因:前端未严格隔离“访客身份”与“登录身份”。用户点击 A 的链接,存了 ref_id=A,但登录时用了 B 的账号,后端直接取user_id当作代理 ID,忽略了 ref_id。
解决:在用户登录成功后,检查 session 中是否存在ref_id,若存在且ref_id != user_id,则将ref_id存入user_ref_id字段(用户表新增),并在订单归属引擎中优先取user_ref_id,其次才是ref_idcookie。这样既尊重用户主动选择的代理关系,又保留邀请溯源能力。

4.3 现象:结算单生成后,代理申请退货,系统直接扣减佣金,但财务说“已结算的佣金不能动”

原因:未建立“结算单”与“订单”的强关联,也未对已结算订单做状态保护。退货逻辑只改订单状态,未检查该订单是否已在某结算单中。
解决:在orders表中增加settlement_id字段,记录该订单归属的结算单 ID(为 NULL 表示未结算)。退货接口增加校验:if ($order['settlement_id']) { throw new Exception("该订单已计入结算单,不可退货,请联系财务"); }。同时,结算单状态机中,“paid”状态后禁止任何修改,确保法律效力。

4.4 现象:代理在后台看到“可提现余额”是 5000 元,但提现时提示“余额不足”

原因:前端显示的“可提现余额”是SELECT SUM(commission) FROM balance_log WHERE agent_id = ? AND type = 'income',但未扣除已申请未打款的提现单。
解决:定义“可用余额 = 总收入 - 已提现 - 待打款提现单总额”。在agent_balance表中增加pending_withdrawal字段,每次创建提现单时,UPDATE agent_balance SET pending_withdrawal = pending_withdrawal + ? WHERE agent_id = ?;打款成功后,再减去该笔。前端展示余额时,直接查balance - pending_withdrawal。永远不要在应用层做“查-算-更”三步,必须用数据库字段固化状态。

5. 验收与答辩:如何用一份可运行的 Demo 和三张表说服导师/客户

答辩不是讲 PPT,是证明你真的做出来了、能跑通、没漏洞。我带过的所有项目,只要过了这三关,基本就稳了。下面是我自己总结的验收 checklist,也是每次交付前必走的流程。

5.1 第一关:本地环境一键启动,5 分钟内跑通核心链路

别指望导师装 Composer、配 Nginx。本方案提供docker-compose.yml,只需三步:

# 1. 下载源码后进入目录 cd php-distribution-system # 2. 启动容器(含 PHP-FPM、Nginx、MySQL、Redis) docker-compose up -d # 3. 初始化数据库(自动执行 migrations 和 seeders) docker-compose exec php php artisan migrate:fresh --seed

启动后,访问http://localhost:8080/admin,默认账号admin/admin123,即可看到完整后台。前台http://localhost:8080支持游客浏览、注册、下单。重点演示:注册两个账号 A 和 B → A 发展 B(B 注册时带 ref=A)→ B 下单 → 查看 A 的“待结算佣金” → 运行php artisan settlement:generate --month=2024-05→ 查看生成的结算单 → 导出 CSV。这一条链路必须 5 分钟内走完,中间不能卡顿、不能报错。如果导师说“你这环境我配不了”,那就等于没做完。

5.2 第二关:用三张表证明业务逻辑闭环

答辩时,导师最常问:“你说佣金实时计算,数据在哪?”、“代理关系怎么查?”、“结算单怎么保证不丢?” 这时,不要讲代码,直接打开 phpMyAdmin 或 Adminer,展示以下三张表的实际数据:

表名关键字段演示要点为什么重要
agent_closureancestor,descendant,depth展示 A→B(depth=1)、A→C(depth=2)、B→C(depth=1)三条记录证明多级关系不是靠递归查出来的,而是预计算好的,性能有保障
commission_rulesku,agent_level,type,value,effective_date,version展示同一 SKU 下,level=1(15%)、level=2(5%)、level=0(通用 3%)三条规则,且 version 递增证明分佣规则可配置、可追溯、可降级,不是硬编码
settlement_itemssettlement_id,order_id,commission_amount,status展示一笔订单对应多个结算项(如买 3 件商品,生成 3 行),且status为calculated证明佣金是按商品粒度计算的,不是按订单粗暴统算

提示:提前准备 3 条真实数据截图,答辩时直接投屏。比讲 10 分钟原理管用。

5.3 第三关:现场演示一个“反常识”操作,证明你懂风控

导师如果只看正向流程,说明他还没信。这时,主动演示一个“错误操作”,并解释系统如何防御:

操作:在订单提交页,F12 修改隐藏域<input type="hidden" name="token" value="xxx">为一个错误值,然后提交。
预期结果:页面跳转回购物车,并提示“非法请求”。
解释:这不是 bug,是设计。Token 用hash_equals()校验,防止时序攻击;且提交后立即作废,杜绝重放。同理,可演示:

  • 在结算单草稿页,手动修改 URL 中的id=123为id=999,返回 404;
  • 在代理列表页,用 Postman 发送 PUT 请求修改非本人代理信息,返回 403;
  • 在提现页输入负数金额,前端 JS 和后端 PHP 双重拦截。

注意:演示前备份数据库,避免误操作影响其他数据。所有防御逻辑必须在代码中可定位,答辩时能快速指出文件和行号。

从那以后我每次交付前,都强制走一遍这三关:先 docker 启动看能不能跑、再查三张表看数据对不对、最后故意搞点破坏看系统稳不稳。不是为了炫技,是确保每一个字写在论文里的功能,都能在导师面前点出来、看得到、摸得着。希望帮到你。

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

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

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

立即咨询