
简介一套积分商城与代理分销一体化系统源码面向需要快速搭建积分兑换、会员成长体系和代理推广返利场景的PHP开发者、产品运营及中小团队。系统包含商城前台、用户积分管理、订单处理、独立代理后台等模块覆盖商品展示、积分抵扣、订单流转、代理佣金结算的完整链路可有效缩短从需求到上线的时间成本。压缩包共2000个文件以html页面模板、php业务逻辑、css样式、js交互脚本、dat数据文件为主体并附带sql安装脚本、pem/cer证书及各类配置文件整体大小约291MB。其中html模板便于直接调整商城页面php文件承载核心业务js和css负责交互与样式dat与sql配合完成数据存储与初始化。配套说明明确部署环境为LinuxCentos7以上宝塔面板、Nginx1.18、PHP7.0、Mysql5.6同时提示在/Application/Common/Conf中修改数据库信息、伪静态选择thinkphp方便在此基础上做二次开发。目前已有87人学习适合作为积分商城或代理分销系统的源码参考与沿用。1. 积分商城系统为什么必须拆出独立代理后台做积分商城的项目最常见的翻车不是商品图不好看而是代理和会员共用一套后台。代理拿着同一个管理端入口既能看会员余额又能改订单状态运营上线第一个月就在对账上吵起来。奇偶商城系统源码的价值恰恰是把这件事分开一套给会员用的商城前台另一套是独立代理后台代理的登录、菜单、权限、分账都走单独的会话和路由。所谓“完美版”我的理解不是页面多华丽而是把积分获取、兑换、退分、代理结算这几条链路在数据层闭环。这篇文章不列功能清单只讲这套源码里最值得抄的表结构、交易顺序和代理分账逻辑适合准备用积分商城源码建站或者想给现有商城加代理分销体系的开发者。2. 先做对积分模型再做商城页面积分账户、流水与任务表很多商城源码把积分直接挂在用户表上一个points字段走天下这是最坑的设计。积分是资产资产不能只有余额还得有流水。奇偶商城系统源码里积分被拆成账户表和流水表余额只是流水的汇总任何积分变动都要落流水。这样对账、退分、代理分佣才有依据。界面可以后期改表结构错了后面所有统计都是错的。2.1 积分账户表可用积分和冻结积分必须分开会员账户表的第一版建表语句如下照抄到 MySQL 5.7 以上即可用。CREATE TABLE member_account ( id int(11) unsigned NOT NULL AUTO_INCREMENT, member_id int(11) NOT NULL DEFAULT 0 COMMENT 会员ID, points int(11) NOT NULL DEFAULT 0 COMMENT 当前可用积分, frozen_points int(11) NOT NULL DEFAULT 0 COMMENT 冻结积分下单后先冻结, total_earned int(11) NOT NULL DEFAULT 0 COMMENT 累计获得积分, total_spent int(11) NOT NULL DEFAULT 0 COMMENT 累计消耗积分, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, updated_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_member_id (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员积分账户;这里最关键的是points和frozen_points两个字段。用户下单时先把积分从points挪到frozen_points支付成功再转正订单取消再从frozen_points退回points。如果不分冻结字段就会出现用户下单后积分被其他订单花掉支付时不够扣的情况。total_earned和total_spent是冗余统计字段代理后台要展示“名下会员累计消费了多少积分”时直接读这一行不需要SUM全表流水。version字段给积分调整等低频写操作用乐观锁后面章节会讲具体用法。2.2 积分流水表一个订单号串起积分的一生积分流水表要记录每一次积分的来去包括获取、兑换、过期、退分、人工调整五类业务上最忌讳直接改余额不留记录。建表语句如下。CREATE TABLE points_transaction ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, member_id int(11) NOT NULL DEFAULT 0, order_no varchar(32) NOT NULL DEFAULT , type tinyint(4) NOT NULL DEFAULT 0 COMMENT 1获取 2兑换扣减 3过期 4退分 5手动调整, change_points int(11) NOT NULL DEFAULT 0 COMMENT 变动值收入为正支出为负, before_points int(11) NOT NULL DEFAULT 0, after_points int(11) NOT NULL DEFAULT 0, remark varchar(255) NOT NULL DEFAULT , created_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_member_type (member_id, type), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水表;order_no这个字段很重要必须跟业务单号关联一个订单从冻结、扣减到退分的所有流水都靠它串起来。before_points和after_points存的是变动前和变动后的余额快照后面做“积分从哪里来、到哪里去”的用户流水页时不需要再回查账户表。这里存的是快照代价是流水表会变大所以查询永远走member_id type或order_no索引不要写不带条件的全表查询。2.3 商品表和订单表库存也要拆分锁定库存积分商品和普通电商商品不一样它没有真实货币结算所以库存和订单状态必须自己管清楚。积分商品表建议加一个locked_stock下单锁定库存超时未支付自动释放。CREATE TABLE points_goods ( id int(11) unsigned NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL, points_price int(11) NOT NULL COMMENT 积分价格, stock int(11) NOT NULL DEFAULT 0 COMMENT 总库存, locked_stock int(11) NOT NULL DEFAULT 0 COMMENT 待支付锁定库存, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分商品表;订单表里要预留代理归属字段agent_id会员通过代理分享的链接注册或下单时这个字段就会被写入。返佣按订单归属来算而不是按会员归属这样代理之间抢人时也有据可查。订单表建表语句如下。CREATE TABLE points_order ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, member_id int(11) NOT NULL, goods_id int(11) NOT NULL, points int(11) NOT NULL COMMENT 实际扣减积分, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已兑换 2已发货 3已完成 4已取消 5已退分, agent_id int(11) NOT NULL DEFAULT 0 COMMENT 归属代理ID, created_at datetime NOT NULL, paid_at datetime DEFAULT NULL COMMENT 支付完成时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_member_status (member_id, status), KEY idx_agent_id (agent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分兑换订单表;订单表里冗余agent_id是给独立代理后台分账用的。不要在结算时再通过“会员表 - 代理关系表”反查归属代理改绑后历史订单结算会乱。下单时定格归属后面谁来了都改不动这笔单子的分佣对象。3. 积分交易主链路兑换、抵扣、退分的正确顺序积分交易和真实支付最大的不同在于积分本身就是钱且没有第三方支付回调。所以冻结、扣减、退分全要靠代码自己保证。我看到很多二次开发把顺序写反先扣库存再检查积分结果库存扣了积分不足用户卡在中间状态。正确的顺序是先锁库存再冻结积分再创建订单支付或确认收货后转正。3.1 库存预占用一条 UPDATE 防止超卖先看最前面的两个动作预占库存和冻结积分。这里不能用“先 SELECT 再 UPDATE”的写法并发下会超卖。// 1. 预占库存stock - locked_stock 是剩余可卖数 $locked $pdo-prepare( UPDATE points_goods SET locked_stock locked_stock 1 WHERE id :goods_id AND stock - locked_stock 0 ); $locked-execute([:goods_id $goodsId]); if ($locked-rowCount() 0) { throw new \RuntimeException(商品库存不足); } // 2. 冻结积分把可用积分挪到冻结字段 $freeze $pdo-prepare( UPDATE member_account SET points points - :need_points, frozen_points frozen_points :need_points WHERE member_id :member_id AND points :need_points ); $freeze-execute([ :need_points $needPoints, :member_id $memberId, ]); if ($freeze-rowCount() 0) { throw new \RuntimeException(积分不足); }第一步的WHERE stock - locked_stock 0是库存防超卖的关键数据库行锁保证同一时刻只有一个请求能把最后一件商品锁走。第二步的写法避免了先查余额再改余额的中间态points :need_points直接放进 UPDATE 的 WHERE 条件里InnoDB 会锁住这行账户记录判断和扣减是原子的。代码里我先扣库存再冻结积分是因为库存是稀缺资源先占住库存积分不足时再回滚释放。两步之间不用事务也行因为第二步失败时执行ROLLBACK或反向 UPDATE 释放锁定的库存。更稳妥的做法是把两步放到一个事务里下面讲退分时会看到同样的事务模式。3.2 支付确认冻结积分转正用户点击“确认兑换”或后台审核通过后执行转正操作。转正不是直接加积分而是把frozen_points减掉同时把订单状态推进到“已兑换”。$pdo-beginTransaction(); try { // 1. 冻结积分转正同时写流水 $pdo-prepare( UPDATE member_account SET frozen_points frozen_points - :points WHERE member_id :member_id )-execute([ :points $order[points], :member_id $order[member_id], ]); $pdo-prepare( INSERT INTO points_transaction (member_id, order_no, type, change_points, before_points, after_points, remark) VALUES (:member_id, :order_no, 2, :points, 0, 0, 积分兑换商品) )-execute([ :member_id $order[member_id], :order_no $order[order_no], :points -$order[points], ]); // 2. 订单状态置为已兑换 $pdo-prepare( UPDATE points_order SET status 1, paid_at NOW() WHERE order_no :order_no )-execute([:order_no $order[order_no]]); $pdo-commit(); } catch (\Throwable $e) { $pdo-rollBack(); throw $e; }流水表里的before_points和after_points字段在转正这一步不需要精确填因为冻结积分是不可用状态改的是frozen_points。但要保证type 2的流水跟订单号一一对应这样用户中心展示“兑换记录”时能直接 JOIN 订单表。3.3 退分先恢复积分再恢复库存积分订单超时未支付或者用户主动取消时要退分。退分和扣减是镜像操作但有一个常见错误直接把刚冻结的积分加回points却没有减frozen_points导致账户同时拥有可用积分和冻结积分。public function refund(PDO $pdo, string $orderNo): void { $order $this-loadOrder($pdo, $orderNo); if (!in_array($order[status], [0, 1], true)) { throw new \RuntimeException(订单状态不允许退分); } $pdo-beginTransaction(); try { // 1. 冻结积分退回可用积分 $pdo-prepare( UPDATE member_account SET frozen_points frozen_points - :refund_points, points points :refund_points WHERE member_id :member_id )-execute([ :refund_points $order[points], :member_id $order[member_id], ]); // 2. 写退分流水 $pdo-prepare( INSERT INTO points_transaction (member_id, order_no, type, change_points, before_points, after_points, remark) VALUES (:member_id, :order_no, 4, :refund_points, 0, 0, 订单取消退分) )-execute([ :member_id $order[member_id], :order_no $order[order_no], :refund_points $order[points], ]); // 3. 解锁库存 $pdo-prepare( UPDATE points_goods SET locked_stock locked_stock - 1 WHERE id :goods_id AND locked_stock 0 )-execute([:goods_id $order[goods_id]]); // 4. 订单状态置为已退分 $pdo-prepare( UPDATE points_order SET status 5 WHERE order_no :order_no )-execute([:order_no $orderNo]); $pdo-commit(); } catch (\Throwable $e) { $pdo-rollBack(); throw $e; } }参数说明change_points在退分场景写正数因为从用户视角是拿回积分。后面对账时type 4的流水加总就能算出当天退了多少积分。库存的locked_stock - 1必须加locked_stock 0条件防止重复退分把库存锁成负数。3.4 订单超时自动取消的定时任务已经冻结积分但一直不确认的订单需要定时任务清扫。常见的做法是每 5 分钟跑一次把超过 30 分钟未支付的订单全部挑出来调refund()。*/5 * * * * php /www/wwwroot/points/cli.php order auto-cancel --expire1800命令参数说明expire1800单位是秒即创建 30 分钟未支付就取消。这个定时任务要在 CLI 模式下跑不要用 Web 访问触发否则 PHP 脚本超时时间不够几万张订单会扫不完。任务执行时先查一批订单号再逐条调refund()每次最多处理 500 条避免单次执行内存溢出。参数建议值说明出错时的表现expire1800订单创建到自动取消的间隔秒数设置太短用户还没确认就被取消退分limit500每批处理订单数设置太大内存峰值高容易被宿主 killretry3退分失败重试次数不重试则积分冻结在账户里对账不平自动退分脚本跑完一定要看日志退分失败最常见的原因不是代码而是订单状态已经被人工改成“已发货”脚本检测到状态不满足in_array($status, [0, 1])就抛异常。这时不要改代码去后台查人工操作记录把冲突订单挑出来单独处理。4. 独立代理后台权限、会话隔离与分账结算独立代理后台是这源码标题里最核心的卖点。很多二次开发图省事给会员表加个is_agent字段会员登录后跳到一个代理菜单结果所有接口都在同一个会话里。这样做权限永远切不干净代理能调用户接口、用户能调代理接口。独立代理后台的正确做法是四个独立入口独立、会话独立、权限独立、数据独立。4.1 独立入口与会话隔离一行代码挡住串号常规的路由结构是前台index.php会员中心user.php管理后台admin.php代理端agent.php。代理端入口第一行代码要设置独立的 session 名称和用户端、管理端完全分开。?php // agent.php 入口 session_name(AGENTSESS); session_start();如果不在session_name上区分用户端登录后浏览器里存的是PHPSESSID代理端登录也是PHPSESSID两个 cookie 同名互相覆盖用户刷新一下变成代理身份代理刷新变成用户身份。用独立的 session 名称后用户端 cookie 是PHPSESSID代理端 cookie 是AGENTSESS互不干扰可以同时在线。4.2 代理账号独立建表账号体系和会员体系分开代理账号不应该挂在用户表里要建独立的agent表和权限表。代理账号由平台运营在后台创建不开放自助注册避免代理互相发展下线导致分佣关系混乱。CREATE TABLE agent ( id int(11) unsigned NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL, password_hash varchar(255) NOT NULL, parent_id int(11) NOT NULL DEFAULT 0 COMMENT 上级代理ID0为顶级, level tinyint(4) NOT NULL DEFAULT 1 COMMENT 代理等级对应不同分佣比, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT代理账号表;代理端登录成功后把agent_id存进独立 sessionAGENTSESS之后所有代理端接口先取这个值。如果agent.status为 0任何接口都要拦截。独立代理后台的登录逻辑写起来很长但核心就两条账号存在且状态正常密码哈希校验通过。密码一定要用password_hash()不要存 MD5 明文。4.3 代理权限控制菜单白名单加接口白名单代理能看的页面和能调的接口要用白名单控制不要用黑名单。黑名单是“除了这几个都允许”新增接口时容易漏配代理就能看到一个本不该看的页面。常见做法是把代理菜单定义成一个路由表每个路由对应一个控制器方法$agentMenus [ dashboard [label 数据概览, perm agent:dashboard], member_list [label 名下会员, perm agent:member:list], order_list [label 兑换订单, perm agent:order:list], commission_list [label 分佣明细, perm agent:commission:list], goods_shelve [label 上下架申请, perm agent:goods:shelve], settlement [label 结算提现, perm agent:settlement:apply], ];代理登录后把perm列表放进 session在路由分发前做一次校验$currentPerm $routeRule[perm] ?? ; if (!in_array($currentPerm, $_SESSION[agent_perms], true)) { http_response_code(403); exit(json_encode([code 403, msg 无权限访问])); }把权限校验做成一个统一的中间件函数所有代理端控制器在方法第一行调用。不要把权限判断散落在各个方法内否则漏改一处就是水平越权。权限表这里用了最简单的实现代理等级写死菜单顶级代理有全量权限下级代理由平台在后台逐项勾选。如果代理数量上千再考虑把perm列表抽到agent_permission表。4.4 代理分佣订单归属加幂等结算独立代理后台的最终目的是分账。下单时订单表已经记录了agent_id分佣要解决两个问题按什么比例算怎么保证不重复结算。代理等级和分佣比例维护在agent_rate表结算时按订单实际消耗的积分乘比例。注意商城没有真实金额流水这里的分佣单位是积分。CREATE TABLE agent_rate ( id int(11) unsigned NOT NULL AUTO_INCREMENT, agent_id int(11) NOT NULL, level tinyint(4) NOT NULL DEFAULT 1, rate decimal(5,4) NOT NULL DEFAULT 0.0000 COMMENT 分佣比例如0.1000表示10%, PRIMARY KEY (id), UNIQUE KEY uk_agent (agent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT代理分佣比例表; CREATE TABLE agent_commission ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, agent_id int(11) NOT NULL, order_no varchar(32) NOT NULL, member_id int(11) NOT NULL, order_points int(11) NOT NULL COMMENT 订单消耗积分, commission_points int(11) NOT NULL COMMENT 分佣积分, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待结算 1已结算 2已驳回, created_at datetime DEFAULT NULL, settled_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_agent_order (agent_id, order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT代理分佣明细表;分佣明细的生成放在每日结算脚本里前一天已兑换的订单统一生成INSERT INTO agent_commission (agent_id, order_no, member_id, order_points, commission_points, status, created_at) SELECT o.agent_id, o.order_no, o.member_id, o.points, ROUND(o.points * r.rate), 0, NOW() FROM points_order o JOIN agent_rate r ON r.agent_id o.agent_id WHERE o.status 1 AND o.paid_at DATE_SUB(CURDATE(), INTERVAL 1 DAY) AND o.paid_at CURDATE() AND NOT EXISTS ( SELECT 1 FROM agent_commission c WHERE c.order_no o.order_no );注意最后的NOT EXISTS子查询这是幂等保证。结算脚本跑挂了第二天重跑时不会重复插入同一条分佣。代理后台只显示agent_id等于当前登录代理的数据在查询前用WHERE agent_id :agent_id强过滤不能只靠界面隐藏不然代理改一下 URL 参数就能看别人的分佣明细。分佣结算的下一个环节是代理提现常见做法是只允许申请结算状态为“已结算”的积分平台后台审核后把积分打款或充值到对应账号。提现申请一旦提交明细状态改为“结算中”避免重复提交。这块代码是独立的settlement模块不在本文展开但数据表一定要在第一天设计时留status字段后面加提现审核流程不用改表。5. 并发扣减、积分对账与上线巡检清单最后一章讲三个上线前必须验证的点。积分系统的崩溃往往不是并发量多大而是余额算错没人发现。给几个可以直接抄的对账方案。5.1 三种并发扣减写法及适用场景方案核心写法适用场景要注意的问题条件更新UPDATE ... SET points points - ? WHERE points ?中小商城QPS 低于 500单库单表无法水平扩展乐观锁UPDATE ... SET points points - ?, version version 1 WHERE version ?积分调整、后台人工改分冲突会抛异常要重试Redis Luaif tonumber(redis.call(get, key)) amount then ...秒杀、高并发扣减需要异步回写 MySQL最终一致条件更新适合大多数积分商城源码建站的场景简单可靠。如果做秒杀活动先把积分预热到 Redis用 Lua 脚本原子扣减再把扣减消息丢进队列回写 MySQL。回写失败时以 Redis 扣减记录为准第二天对账补平。5.2 每日对账账户余额与流水加总必须一致对账脚本是积分系统最后一道防线。核心 SQL 是核对每个会员的当前余额等于历史流水之和SELECT a.member_id, a.points a.frozen_points AS account_balance, COALESCE(SUM(t.change_points), 0) AS flow_balance FROM member_account a LEFT JOIN points_transaction t ON t.member_id a.member_id GROUP BY a.member_id HAVING account_balance ! flow_balance;查询结果为空说明所有会员的余额和流水一致有结果就把member_id打出来走人工补偿。这个 SQL 放在每天凌晨 4 点跑数据量上来后要加created_at分段扫描不要一次全表 JOIN。5.3 上线前黑盒巡检清单最后给出上线前必须人工验证的 6 个场景前 4 个是功能后 2 个是数据安全。把每一项在测试库跑一遍再上线。检查项目操作方式通过标准并发下单超卖2 个浏览器同时下单同 1 件库存商品只有一个订单成功库存在 1 个订单里扣减积分不足拦截余额 100 积分下 101 积分的商品提示积分不足库存不被锁定取消订单退分下单后立即取消可用积分恢复冻结归零库存回补代理分佣唯一同一订单重复跑结算脚本分佣明细表只有一条记录退分重复执行对同一订单连续调用退款接口第二次执行被状态校验拦截会话串号同时登录会员中心和代理后台两个会话互不影响退出一个不踢另一个把第 6 项单独拿出来说大部分所谓“源码成品”在这一点上都是裸奔的。检查方法很简单浏览器登录代理后台再开无痕窗口登录会员中心然后回到代理后台操作如果代理后台自动退出说明 session 没隔离要回第 4 章把session_name的改动补上。巡检通过后再配置 Nginx 伪静态和 HTTPS生产环境关闭 PHP 错误显示把display_errors设为 Off日志写到/var/log/php-fpm/error.log。积分商城源码到这一步可以算是一个能运营的“完美版”了。本文还有配套的精品资源点击获取