简介:易优第三方商家(会员变商家)商城插件面向易优建站系统的运营者与二次开发者,重点解决普通注册会员申请升级为商家、经后台审核授权后自主发布商品、管理订单并处理客户咨询的流程需求,适合中小电商平台扩充品类与轻量级多商户运营场景。压缩包共526个文件、约7.47MB,以250个js脚本、75个css样式、65个png与24个gif图标资源及63个htm页面为主,辅以少量php逻辑文件、sql脚本和字体文件,前端模板与静态资源占比较高,便于直接部署或按页面调整。已有135人学习下载。包内可见application、template、weapp等模块痕迹,分别对应业务逻辑、前端展示与微信小程序端配置,读者可据此理清会员转商家、商品与订单管理的目录组织,并参考现有模板和样式做界面定制、接口联调与功能扩展,减少从零搭建多商户链路的时间。
1. 会员变商家:这个插件替你省下的到底是哪两百行代码
站点用易优 CMS 跑了一段时间,商城插件也装上了,商品能卖、订单能下,但所有下单的人都是普通会员——想让人家自己开店、自己发货、自己结算,就得从会员组、店铺表、商品归属、订单拆单一路改下去。这套「易优第三方商家(会员变商家)商城插件」干的就是这件事:让前台注册的会员递交入驻申请,审核通过后升级成商家身份,挂上独立店铺、独立商品、独立订单和独立结算账户。它属于典型的软件/插件类资源,不是从零写一套 B2B2C,而是在既有商城模块上补一层身份与数据隔离。适合已经用易优跑通单商户、想低成本试多商户的站长和二次开发者;如果你连基础商城都没跑起来,先别碰它。
2. 拆开插件看骨架:目录落位、五张表与会员身份的状态机
拿到一个插件包,我第一件事不是上传覆盖,而是先看它改了哪些原有文件、加了哪些新表。多商家这类插件最怕的就是「悄悄改了商城核心控制器」,等你下次升级商城,改动全被冲掉。所以这一节先把骨架摸清楚,后面再谈装。
2.1 插件装完,文件到底落在哪几个位置
易优的目录结构相对固定,插件包解压后一般能对应到下面这几处。表里是我按常见分发结构整理的落位约定,实际包里的目录名可能略有出入,但职责划分基本一致。
| 路径 | 职责 | 是否需要合并 |
|---|---|---|
application/shop/controller/ | 商城前台与后台控制器,入驻、店铺、结算的入口都在这里 | 新增为主,少量覆盖 |
application/shop/model/ | 店铺、申请、账单模型 | 新增 |
application/shop/logic/ | 审核、拆单、结算的业务逻辑 | 新增 |
application/admin/controller/ | 平台后台的商家管理、审核、抽佣配置 | 新增,可能动了菜单配置 |
template/pc/shop/与template/mobile/shop/ | 入驻页、店铺首页、商家中心模板 | 新增 |
public/static/shop/ | 商家中心用到的 js、css | 新增 |
data/或install/ | 安装 SQL、初始配置 | 只在首次安装时用 |
extend/ | 公共函数、支付或短信扩展 | 按需 |
判断一个包是不是「覆盖式安装」,看它有没有在压缩包里就摆好了application/shop/controller/Goods.php这种和你现有文件同名的路径。有的话,先把你线上的同名文件备份出来,再用 diff 工具比一遍。我一般的做法是:把插件包解压到本地一个空目录,用diff -rq对着线上代码跑一次,只列出「两边都有但内容不同」的文件,这部分才是真正需要人工确认的合并点。
# 把线上代码拉一份到本地做对照,只比对不写入 rsync -av --exclude='runtime' --exclude='data/cache' \ user@host:/www/wwwroot/site/application/shop/ ./online_shop/ # 比对插件包与线上同名文件,输出差异清单 diff -rq ./online_shop/ ./plugin_shop/ | grep "differ"命令本身没难度,重点在--exclude那两个目录:runtime是编译缓存,data/cache是站点缓存,这两个目录参与比对只会产生一堆噪音。跑完拿到的differ清单如果超过十个文件,说明这个插件对核心改动比较深,安装前必须做全站备份,包括数据库和template目录。
2.2 五张核心表与它们之间的字段语义
多商家插件最核心的东西不在 PHP 里,在表结构里。表设计得对不对,直接决定后面商品会不会串店、订单会不会算错账。下面这段建表 SQL 是按常见的分法整理的,字段名以你实际安装后的ey_前缀为准,逻辑关系是通用的。
-- 店铺主表:一个会员最多对应一条记录 CREATE TABLE `ey_shop_store` ( `store_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `users_id` int(10) unsigned NOT NULL COMMENT '所属会员ID,唯一', `store_name` varchar(80) NOT NULL COMMENT '店铺名称', `store_logo` varchar(255) DEFAULT '' COMMENT '店铺LOGO', `cat_id` int(10) unsigned DEFAULT 0 COMMENT '主营类目', `contact` varchar(30) DEFAULT '' COMMENT '联系人', `mobile` varchar(20) DEFAULT '' COMMENT '联系电话', `province_id` int(10) DEFAULT 0 COMMENT '省', `city_id` int(10) DEFAULT 0 COMMENT '市', `district_id` int(10) DEFAULT 0 COMMENT '区', `address` varchar(200) DEFAULT '' COMMENT '详细地址', `license` varchar(255) DEFAULT '' COMMENT '营业执照图', `commission_rate` decimal(5,2) DEFAULT '0.00' COMMENT '平台抽佣百分比', `status` tinyint(1) DEFAULT 1 COMMENT '1正常 0冻结 2关闭', `add_time` int(10) DEFAULT 0, PRIMARY KEY (`store_id`), UNIQUE KEY `uk_users` (`users_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家店铺表'; -- 入驻申请表:允许被驳回后重新提交,所以不做唯一索引 CREATE TABLE `ey_shop_store_apply` ( `apply_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `users_id` int(10) unsigned NOT NULL, `store_name` varchar(80) NOT NULL, `apply_data` text COMMENT '表单快照JSON', `audit_status` tinyint(1) DEFAULT 0 COMMENT '0待审 1通过 2驳回', `audit_remark` varchar(255) DEFAULT '', `audit_time` int(10) DEFAULT 0, `add_time` int(10) DEFAULT 0, PRIMARY KEY (`apply_id`), KEY `idx_users` (`users_id`), KEY `idx_audit` (`audit_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家入驻申请表'; -- 结算账户:提现打款用,与店铺一对一 CREATE TABLE `ey_shop_store_account` ( `account_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `store_id` int(10) unsigned NOT NULL, `bank_name` varchar(80) DEFAULT '', `account_name` varchar(50) DEFAULT '', `account_no` varchar(50) DEFAULT '', `alipay_no` varchar(80) DEFAULT '', `is_default` tinyint(1) DEFAULT 0, PRIMARY KEY (`account_id`), KEY `idx_store` (`store_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家结算账户'; -- 账单流水:每一笔应结金额一条,不直接改余额 CREATE TABLE `ey_shop_store_bill` ( `bill_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `store_id` int(10) unsigned NOT NULL, `order_id` int(10) unsigned NOT NULL, `sub_order_id` int(10) unsigned DEFAULT 0, `order_amount` decimal(10,2) DEFAULT '0.00' COMMENT '订单实付', `commission` decimal(10,2) DEFAULT '0.00' COMMENT '平台抽佣', `settle_amount` decimal(10,2) DEFAULT '0.00' COMMENT '商家应得', `status` tinyint(1) DEFAULT 0 COMMENT '0待结算 1已结算 2已提现', `add_time` int(10) DEFAULT 0, PRIMARY KEY (`bill_id`), KEY `idx_store_status` (`store_id`,`status`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家账单流水';第五张是子订单表,结构上依附主订单,但必须带store_id,否则一次下单买了三家店的东西,后台没法按店拆发货。这五张表之外,插件通常还会给ey_users加is_store、store_id两个字段,给ey_shop_goods、ey_shop_order各加一个store_id。这几处改动看起来不起眼,却是后面所有查询的锚点。
关于commission_rate,我建议放在店铺表而不是全局配置里。平台对不同类目、不同商家给不同抽佣是常态,全局一个值后面一定要改。
2.3 会员身份的状态机与权限判定
「会员变商家」本质是一次身份迁移,不能只靠一个is_store = 1布尔值。真实流程里至少有四个阶段:没申请、待审核、已通过、被驳回,另外还得有冻结态。我一般用申请表和店铺表两张表共同表达状态:申请表的audit_status管流程,店铺表的status管经营。
判定一个会员现在能不能进商家中心,逻辑要写成下面这样,而不是直接读 session。
<?php // 商家身份判定,放在公共函数或 Base 控制器里,所有商家中心入口统一调用 function getStoreIdentity($usersId) { // 1. 先看店铺表,有店且状态正常,直接返回 $store = Db::name('shop_store') ->where('users_id', $usersId) ->where('status', 1) ->find(); if ($store) { return ['state' => 'store', 'store' => $store]; } // 2. 没店铺,看最近一次入驻申请 $apply = Db::name('shop_store_apply') ->where('users_id', $usersId) ->order('apply_id desc') ->find(); if (!$apply) { return ['state' => 'none']; // 从未申请 } if ((int)$apply['audit_status'] === 0) { return ['state' => 'auditing', 'apply' => $apply]; // 审核中 } if ((int)$apply['audit_status'] === 2) { return ['state' => 'rejected', 'apply' => $apply]; // 被驳回 } // 3. 申请已通过但店铺表没数据,属于数据不一致,记日志排查 Log::write('store_identity_broken users_id=' . $usersId); return ['state' => 'none']; }三个要点。第一,永远以店铺表为准,不要以ey_users.is_store为准——那个字段只是索引,容易在审核失败回滚时忘记改回来,血泪经验。第二,申请被驳回后允许再次提交,所以ey_shop_store_apply不能加users_id唯一索引,取「最近一条」判断状态。第三,最后那个store_identity_broken分支必须留日志,因为一旦出现「申请通过但店铺不存在」,用户会一直卡在审核中页面,前台没有任何报错,只能靠日志定位。
3. 走一遍完整流程:从提交入驻申请到店铺能上架第一件商品
骨架清楚了,接着跑一遍真实流程。这一节按操作顺序走,每一步都落具体配置或代码,你照着抄改一遍,基本能把一个会员变成能卖货的店主。
3.1 后台三个开关先开对,否则前台入口根本出不来
插件装完,很多人第一件事就是去前台找入驻入口,找不到就以为装坏了。实际是后台还有三个开关没开:
第一个是商城的「多商家模式」开关,位置一般在商城设置的多商户分组里,关着的时候所有订单按平台自营处理,前台不渲染商家入口。第二个是会员组的入驻权限,得指定哪几个会员组可以申请,默认只放开注册会员组,如果你把新注册用户放在别的组里,就会一直是「暂无申请资格」。第三个是店铺审核开关,开启后申请必须人工审核,关闭则提交即通过,测试阶段可以先关,正式跑起来一定要开。
改完这三个开关记得清一次缓存。易优的配置项缓存比较重,后台改完配置前台不生效,八成是缓存没刷。操作路径是后台首页右上角的清除缓存,或者在服务器上直接删runtime下的编译目录。
# 清缓存,注意别把用户上传目录一起清了 cd /www/wwwroot/site rm -rf runtime/temp/* runtime/cache/* runtime/log/* chmod -R 755 runtime chown -R www:www runtime这里有个细节:runtime目录要保证 web 用户可写,很多人用 root 跑过一次rm之后,目录属主变了,导致后面所有页面都报「模板编译失败」。所以清完缓存顺手把属主改回去。
3.2 入驻申请表单与后端校验
前台入口一般是/shop/store/apply.html,模板在template/pc/shop/store_apply.htm。表单字段和第二节的申请表对应,重点是三个必填项:店铺名、手机号、结算账户。手机号要查重,因为一个手机号挂两家店的纠纷最难处理。
控制器里我会这么写校验,用 ThinkPHP 的验证器而不是手写 if:
<?php namespace app\shop\controller; use think\Controller; use think\Db; use think\Validate; class Store extends Controller { public function apply() { if (!session('users_id')) { return $this->error('请先登录'); } $data = [ 'store_name' => input('post.store_name/s', ''), 'contact' => input('post.contact/s', ''), 'mobile' => input('post.mobile/s', ''), 'cat_id' => input('post.cat_id/d', 0), 'address' => input('post.address/s', ''), 'bank_name' => input('post.bank_name/s', ''), 'account_no' => input('post.account_no/s', ''), ]; $rule = [ 'store_name' => 'require|length:2,40', 'contact' => 'require|length:2,20', 'mobile' => 'require|mobile', 'cat_id' => 'require|number', 'address' => 'require|length:5,200', 'bank_name' => 'require', 'account_no' => 'require|length:8,40', ]; $msg = [ 'store_name.require' => '店铺名称不能为空', 'mobile.mobile' => '联系电话格式不正确', 'account_no.require' => '结算账户不能为空', ]; $validate = new Validate($rule, $msg); if (!$validate->check($data)) { return $this->error($validate->getError()); } // 同一会员不允许存在待审或已通过的申请 $exists = Db::name('shop_store_apply') ->where('users_id', session('users_id')) ->whereIn('audit_status', [0, 1]) ->count(); if ($exists) { return $this->error('你已提交过申请,请勿重复提交'); } Db::startTrans(); try { $applyId = Db::name('shop_store_apply')->insertGetId([ 'users_id' => session('users_id'), 'store_name' => $data['store_name'], 'apply_data' => json_encode($data, JSON_UNESCAPED_UNICODE), 'audit_status' => 0, 'add_time' => time(), ]); // 结算账户先落库,等审核通过再绑 store_id Db::name('shop_store_account')->insert([ 'store_id' => 0, 'bank_name' => $data['bank_name'], 'account_no' => $data['account_no'], 'is_default' => 1, ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); return $this->error('提交失败,请稍后重试'); } return $this->success('申请已提交,等待审核'); } }参数上要留意的有三处。apply_data存整份表单 JSON 快照,这是个后悔药——商家后期改了店铺名,你得能看到当初提交的是什么。结算账户用store_id = 0先占位,审核通过后再回填,好处是审核环节能直接取出账户信息给财务核对。whereIn('audit_status', [0, 1])这个判断挡住了重复提交,但要注意如果商家被冻结、店铺被关闭,状态不是 1 而是 0 或 2,此时允许他重新申请还是走申诉流程,取决于你的业务,我一般改成判断店铺表是否存在记录。
3.3 审核通过时那一次数据库事务
整个插件最不能出错的就是这一步。点击「审核通过」的瞬间,至少要完成五件事:改申请状态、建店铺、回写会员表、初始化店铺角色、写一条站内消息。少一件,后面就会出现「有店铺没权限」或者「有权限没店铺」的鬼数据。
<?php // 后台审核通过 public function pass() { $applyId = input('post.apply_id/d', 0); $rate = input('post.commission_rate/f', 0); // 抽佣百分比,0-100 $apply = Db::name('shop_store_apply')->where('apply_id', $applyId)->find(); if (!$apply || (int)$apply['audit_status'] !== 0) { return json(['code' => 0, 'msg' => '申请不存在或已处理']); } Db::startTrans(); try { // 1. 建店铺 $storeId = Db::name('shop_store')->insertGetId([ 'users_id' => $apply['users_id'], 'store_name' => $apply['store_name'], 'commission_rate' => $rate, 'status' => 1, 'add_time' => time(), ]); // 2. 回填结算账户 Db::name('shop_store_account') ->where('store_id', 0) ->where('account_no', json_decode($apply['apply_data'], true)['account_no'] ?? '') ->update(['store_id' => $storeId]); // 3. 回写会员表,注意同时改会员组 Db::name('users')->where('users_id', $apply['users_id'])->update([ 'is_store' => 1, 'store_id' => $storeId, ]); // 4. 更新申请状态 Db::name('shop_store_apply')->where('apply_id', $applyId)->update([ 'audit_status' => 1, 'audit_time' => time(), ]); // 5. 站内消息,告诉用户去哪儿进商家中心 Db::name('users_message')->insert([ 'users_id' => $apply['users_id'], 'title' => '入驻审核已通过', 'content' => '你的店铺已开通,请前往商家中心完善资料并上架商品。', 'add_time' => time(), ]); Db::commit(); return json(['code' => 1, 'msg' => '审核通过']); } catch (\Exception $e) { Db::rollback(); return json(['code' => 0, 'msg' => '审核失败:' . $e->getMessage()]); } }参数上两个关键点。commission_rate在审核时就要落值,不要拖到结算时再问平台要,否则第一批订单永远算不出该扣多少。回填结算账户那段用的是account_no匹配,如果同一个账户号被多个用户提交过,会一次改到多条,稳妥做法是在申请表里就存一条account_id,审核时按 ID 回填。这一点我在第二个项目里才改过来,第一个项目上线后确实踩了一次。
3.4 模板层怎么区分「我是买家」和「我是店主」
前台模板要按身份切换导航。常见做法是在公共头部模板里读一次身份,赋给模板变量,各页面复用。
<!-- template/pc/shop/public/header.htm --> {eyou:php} $identity = getStoreIdentity(session('users_id')); $this->assign('identity', $identity); {/eyou:php} <div class="nav"> <a href="/shop/cart.html">购物车</a> {if condition="$identity.state eq 'store'"} <a href="/shop/store/index.html">商家中心</a> <a href="/shop/store/goods.html">商品管理</a> {elseif condition="$identity.state eq 'auditing'"/} <a href="/shop/store/apply_status.html">入驻审核中</a> {else /} <a href="/shop/store/apply.html">我要开店</a> {/if} </div>模板里不要直接写session('users_id') > 0来判断,那样会把普通会员也当店主。用统一函数的好处是判断逻辑只有一份,将来加冻结态、加试用期都只改函数。另外{eyou:php}标签在部分版本里需要后台开启模板 PHP 解析权限,如果解析不出来,改成一个自定义标签或控制器里 assign。
4. 商品、订单与结算:多商家场景里最容易塌的三根梁
单商户商城的代码,绝大多数查询默认「所有商品都是我的」。一旦变成多商家,这个隐含假设就成了最大的隐患。这一节讲三件事:商品怎么不串店、订单怎么拆、钱怎么对。
4.1 商品归属:store_id 必须贯穿每一次查询
商品表加了store_id只是第一步,真正的工作量在于把商家中心里所有列表查询都补上这个条件。我见过最典型的事故是:商家后台的商品列表用的是公共的Goods模型方法,方法里只写了分页没写条件,结果 A 商家登录后能看到全站所有商品,还能点进去编辑——数据越权,这是多商家插件最容易出问题的地方。
做法是给商家中心的查询单独封装一个作用域方法,所有列表统一走它。
<?php // 商家中心专用查询,强制带 store_id function storeGoodsQuery($storeId, $where = []) { return Db::name('shop_goods') ->where('store_id', $storeId) // 强制条件,不接受外部传入覆盖 ->where($where) ->whereNull('delete_time'); // 软删除字段,别漏 } // 列表接口 public function goodsList() { $storeId = $this->checkStore(); // 内部走 getStoreIdentity,不是店主直接抛异常 $keyword = input('get.keyword/s', ''); $where = []; if ($keyword !== '') { $where['goods_name'] = ['like', '%' . $keyword . '%']; } $list = storeGoodsQuery($storeId, $where) ->order('goods_id desc') ->paginate(20, false, ['query' => request()->param()]); return $this->fetch('', ['list' => $list]); }这里有个刻意的设计:store_id写在函数内部而不是作为$where参数传进来。参数化的话,某天有人在拼接$where时用数组覆盖了store_id,越权就回来了。写死是最土也最有效的办法。同样要处理的还有商品编辑页的单条查询、上下架接口、库存修改接口,一个都不能漏——建议用grep把所有直接查shop_goods的代码列出来,逐条确认。
# 把所有直接访问商品表的位置列出来,逐条核对是否带 store_id grep -rn "name('shop_goods')" application/shop/ application/admin/ grep -rn "name(\"shop_goods\")" application/shop/ application/admin/两条命令只是引号和双引号的区别,实际项目里两种写法都有人用。跑完你会发现有些地方是后台平台侧用的,本来就不该带店铺条件,这属于正常;但前台和商家中心里出现不带条件的,就是漏洞。
4.2 一次下单拆成多个子订单
用户购物车里同时放了三个店的商品,下单后必须拆成主订单加多条子订单,否则三个商家谁发货、谁退款、谁结算全乱。拆单逻辑要放在下单事务里,和库存扣减一起完成。
<?php // 下单主流程(简化版),按 store_id 分组建子订单 public function createOrder() { $cart = getCart(session('users_id')); if (empty($cart)) { return json(['code' => 0, 'msg' => '购物车是空的']); } // 1. 按店铺分组 $group = []; foreach ($cart as $item) { $group[$item['store_id']][] = $item; } Db::startTrans(); try { $totalAmount = 0; $subOrders = []; foreach ($group as $storeId => $items) { $subAmount = 0; $subId = Db::name('shop_order_sub')->insertGetId([ 'store_id' => $storeId, 'status' => 0, 'add_time' => time(), ]); foreach ($items as $it) { $subAmount += $it['price'] * $it['num']; Db::name('shop_order_sub_item')->insert([ 'sub_order_id' => $subId, 'goods_id' => $it['goods_id'], 'store_id' => $storeId, 'price' => $it['price'], 'num' => $it['num'], ]); // 扣库存,条件里必须带库存量,防止超卖 $affect = Db::name('shop_goods') ->where('goods_id', $it['goods_id']) ->where('stock', '>=', $it['num']) ->setDec('stock', $it['num']); if (!$affect) { throw new \Exception('商品库存不足:' . $it['goods_id']); } } Db::name('shop_order_sub')->where('id', $subId)->update([ 'amount' => $subAmount, ]); $subOrders[] = ['sub_order_id' => $subId, 'store_id' => $storeId, 'amount' => $subAmount]; $totalAmount += $subAmount; } // 2. 主订单只在拆完后创建,金额是子订单合计 $orderId = Db::name('shop_order')->insertGetId([ 'order_sn' => date('YmdHis') . mt_rand(1000, 9999), 'users_id' => session('users_id'), 'amount' => $totalAmount, 'status' => 0, 'add_time' => time(), ]); // 3. 回填子订单的主订单ID Db::name('shop_order_sub') ->whereIn('id', array_column($subOrders, 'sub_order_id')) ->update(['order_id' => $orderId]); Db::commit(); return json(['code' => 1, 'order_id' => $orderId]); } catch (\Exception $e) { Db::rollback(); return json(['code' => 0, 'msg' => $e->getMessage()]); } }参数上三处必须注意。扣库存的where('stock', '>=', $it['num'])不能省,这是防超卖的最朴素手段,靠先查后扣在高并发下一定会翻车。主订单的amount用子订单合计,不要用购物车原价合计,否则运费、优惠一叠加就对不上。子订单的store_id必须从购物车项里取,不能从 session 推断——用户可能同时是店主又是买家,买东西时身份是买家。
运费也得按店铺分别算。每个店铺有自己的运费模板和包邮门槛,合并计算必然算错。优惠券同理,跨店满减在做不到的情况下,宁可限制「优惠券只能用于单店铺订单」,也别含糊地按总额减,后期对账没法解释。
4.3 结算、抽佣与提现的账目闭环
订单完成(确认收货)之后,钱还不能直接进商家口袋。常见做法是进入待结算状态,冻结 N 天,然后生成账单,商家申请提现,平台打款。这条链路上最容易出问题的是「退款之后账单没冲掉」。
<?php // 确认收货后生成账单,注意幂等 public function confirmReceive($subOrderId) { $sub = Dbname('shop_order_sub')->where('id', $subOrderId)->find(); if (!$sub || (int)$sub['status'] !== 1) { return false; } // 幂等:已经生成过账单直接返回 $exists = Db::name('shop_store_bill')->where('sub_order_id', $subOrderId)->count(); if ($exists) { return false; } $store = Db::name('shop_store')->where('store_id', $sub['store_id'])->find(); $rate = $store['commission_rate'] ?? 0; // 退款金额要在这里扣掉,不能只看子订单原金额 $refund = Db::name('shop_order_refund') ->where('sub_order_id', $subOrderId) ->where('status', 1) ->sum('amount'); $paid = max(0, $sub['amount'] - $refund); $fee = round($paid * $rate / 100, 2); Db::name('shop_store_bill')->insert([ 'store_id' => $sub['store_id'], 'order_id' => $sub['order_id'], 'sub_order_id' => $subOrderId, 'order_amount' => $paid, 'commission' => $fee, 'settle_amount' => round($paid - $fee, 2), 'status' => 0, 'add_time' => time(), ]); Db::name('shop_order_sub')->where('id', $subOrderId)->update(['status' => 2]); return true; }三个要点。账单表加sub_order_id上的唯一性校验或者代码里的幂等判断,因为确认收货接口可能被重复调用。退款金额必须从账单里减掉,而且要按子订单维度减,不能拿主订单的退款去摊。抽佣基数用「实付减退款」而不是原价,否则商家退了货还要为退掉的部分付佣金,这个坑商家一定会来找你。
对账的时候直接跑一条 SQL,能立刻看出账目是否平衡:
-- 主订单金额与子订单合计的差额,正常应为 0 SELECT o.order_id, o.amount AS main_amount, SUM(s.amount) AS sub_amount, o.amount - SUM(s.amount) AS diff FROM ey_shop_order o LEFT JOIN ey_shop_order_sub s ON s.order_id = o.order_id GROUP BY o.order_id HAVING diff <> 0;这条查出来有数据,就说明拆单流程中间断过,通常是某个子订单在修改金额后没有回填主订单。
5. 避坑与排查:五条把站点搞挂过的血泪记录
插件能不能用,功能列表只说明一半,另一半在出问题时的排查路径上。下面五条是我和同行在真实站点里遇到过的,按现象、原因、解决三步写清楚,你装完先照着自查一遍。
5.1 身份与权限类
现象:审核通过了,用户登录进去还是提示「暂无店铺,请先申请入驻」。
原因:权限判定读的是缓存或者 session 里的旧会员信息。易优在登录时会把会员信息写进 session,审核操作是在后台完成的,改的是数据库,用户那边的 session 不会自动刷新。另外部分配置项走了文件缓存,不清缓存新状态读不出来。
解决:在getStoreIdentity里不要只读 session,用users_id回查一次数据库;审核通过后主动清一次该用户的缓存,或者在用户下一次请求时比对is_store标记。最直接的办法是让用户重新登录一次,但这是把问题丢给用户,不建议。
现象:A 商家在商家中心能看到 B 商家的商品,甚至能编辑。
原因:商品列表、编辑页的单条查询没有强制带store_id,用的是平台公共方法。
解决:按 4.1 的方式把商家中心所有查询收口到一个强制带store_id的函数里,然后用grep把直接访问商品表的地方全部过一遍。编辑页尤其要注意,不要只在列表加了条件,详情接口忘了加。
现象:会员冻结了,但店铺还能正常下单。
原因:会员表和店铺表状态没联动,插件只处理了审核环节,没处理冻结环节。
解决:在会员冻结的逻辑后面挂一个联动,把对应店铺status置 0,同时把该店铺所有在售商品下架或标记为不可购买。下单入口也要加一道店铺状态校验,不能只依赖商品状态。
5.2 数据与账目类
现象:用户一次买了三个店的商品,退款时只退了一部分,商家账单金额和实际不符。
原因:退款逻辑写在了主订单层,按主订单金额整笔退,子订单的退款记录没有同步,账单生成时读不到退款数据。
解决:退款必须落到子订单和子订单明细上,账单生成时按子订单汇总退款金额。已经上线的站点,写一条补偿 SQL 把历史子订单的退款金额补进去,再重算账单。
现象:商城前台访问/shop路径 404,或者跳到别的页面。
原因:伪静态规则和原有商城路由冲突,插件新增的路由没写进伪静态配置,或者.htaccess里商城规则排在前面拦截了。
解决:检查伪静态配置里shop模块的规则顺序,把商家中心的路由放在通用规则之前。Nginx 下还要确认location块的匹配优先级,改完重启生效。这类问题不涉及数据,但排查时容易误以为插件没装好,白白重装一遍。
6. 上线前的三道验证:用 SQL 和脚本把权限、拆单、对账跑一遍
功能点测试通过不等于能上线。多商家插件真正的事故都发生在数据边界上,我一般在上线前固定跑三组检查,全部通过才开公网访问。
第一组是身份一致性检查,把所有「有店铺标记但没店铺记录」「有店铺记录但会员组没升级」的脏数据捞出来。
-- 会员标记与店铺表不一致 SELECT u.users_id, u.is_store, u.store_id FROM ey_users u LEFT JOIN ey_shop_store s ON s.users_id = u.users_id WHERE (u.is_store = 1 AND s.store_id IS NULL) OR (u.is_store = 0 AND s.store_id IS NOT NULL); -- 申请已通过但店铺不存在 SELECT a.apply_id, a.users_id, a.store_name FROM ey_shop_store_apply a LEFT JOIN ey_shop_store s ON s.users_id = a.users_id WHERE a.audit_status = 1 AND s.store_id IS NULL;第二组是拆单平衡检查,用 4.3 那条主订单与子订单合计的对比 SQL,另外再加一条子订单金额与明细合计的对比。两条都返回空,说明拆单链路是干净的。
第三组是账单一致性检查,重点看抽佣和应结金额是否对得上,以及有没有重复账单。
-- 抽佣金额与应结金额之和是否等于订单金额 SELECT bill_id, order_amount, commission, settle_amount, order_amount - commission - settle_amount AS diff FROM ey_shop_store_bill HAVING diff <> 0; -- 同一子订单是否生成了多条账单 SELECT sub_order_id, COUNT(*) AS cnt FROM ey_shop_store_bill GROUP BY sub_order_id HAVING cnt > 1;除了这三组 SQL,上线前我还会用两个不同会员账号做一次交叉操作:账号甲开店上架商品,账号乙下单购买,然后甲登录商家中心确认能看到这笔订单、能发货;再用乙申请退款,看甲的账单是否同步减少。这套动作走一遍,比点二十个后台菜单管用。
| 检查项 | 通过标准 | 不通过时的第一动作 |
|---|---|---|
| 身份一致性 | 两条 SQL 均返回空 | 手工修正 users 表或补建店铺 |
| 拆单平衡 | 主订单与子订单差额为 0 | 检查下单事务是否中途回滚 |
| 账单一致性 | 抽佣加应结等于订单金额 | 核对退款是否已计入 |
| 越权访问 | 换号登录看不到别家商品 | 收口查询函数,补 store_id |
| 店铺状态联动 | 冻结会员后无法下单 | 补状态联动与下单校验 |
还有一个容易被忽略的点:备份策略要跟着改。原来是单商户,备份数据库就完事;多商家之后,商家上传的图片、营业执照、结算凭证都在public/uploads下,这些文件丢了比数据丢了更难恢复。我现在的习惯是数据库每天全量,上传目录每周增量同步到另一台机器,两边都留七天。
说个自己的教训。第一个跑这个插件的站点,我图省事直接在线上环境覆盖安装,装完发现商家中心的商品列表页 500,查了半天是插件里的一个模板文件用了本地没有的标签库。从那以后我每次装这类商城插件,都强制走一遍「本地空环境安装 → 导入一份生产数据副本 → 跑上面三组 SQL → 再合并到线上」的流程,宁可多花两小时,也不在公网上调。希望帮到你。
本文还有配套的精品资源,点击获取