简介:面向易优商城运营者与二次开发者的第三方商家插件,核心是把普通会员升级为商家,打通入驻审核、商品发布、订单处理与客户咨询等环节,适合需要扩展商品供给或多商户运营的电商站点。资源包共526个文件、约7.47MB,以JavaScript、CSS、PNG、HTM页面文件为主,辅以少量PHP、SQL及字体图标资源,整体呈现前端模板、样式脚本与后端逻辑分离的插件结构。内容涉及application、template、weapp等模块,可帮助读者理解会员转商家的权限流转、后台业务处理、页面渲染以及微信小程序端店铺管理的实现方式,也便于对照目录结构做二次开发与功能裁剪。已有135人学习下载,适合具备一定易优或PHP基础、希望研究多商户商城插件机制的开发者参考。
1. 会员点一下变商家:这套插件改的是角色,不是权限
后台会员列表里选中一个普通账号,点「升级为商家」,页面转了两秒又回来了,权限纹丝没动。这是易优第三方商家(会员变商家)商城插件最常见的第一次接触:很多人以为是权限节点没配,其实是会员表里的角色标识和商家档案表没对上。这份资源做的事很明确——把站点原有的单商户结构改成多商户结构,让前台注册的普通会员能申请开店、上传商品、接单发货,后台再把抽佣和结算管起来。它适合手上已经有一套易优站点、想低成本加一个商家入驻模块的站长和外包开发者,也适合拿来当多商户权限隔离的教学样本。不适合指望开箱即用、不愿意读建表语句的人。
2. 环境对齐与文件落位:插件装上却打不开的三个前提
2.1 先看清这套插件吃的是什么运行时
拿到包先别急着解压到站点根目录。整套插件是跑在易优现有的内核上的,它对运行时的要求不是插件自己定的,而是跟着主程序走,所以第一步是去后台首页底部或者application/config.php里确认当前内核版本和 PHP 小版本,常见做法是 PHP 7.x 配 MySQL 5.6 以上,PHP 8 在某些老内核上会因为函数签名报错。这一步的意义在于:插件包里带的建表语句、钩子注册、模板标签,全都是按一套特定内核写的,版本差一个中版本,路由注册方式就可能不一样。
先把环境清单核一遍,我一般按这个顺序:
- PHP 版本与主程序要求一致,
php -v看 CLI,后台首页看 FPM 那一侧,两边要一样; - 扩展齐全,
pdo_mysql、mbstring、gd、fileinfo是必须的,缺fileinfo商家上传营业执照会静默失败; - 伪静态已经生效,前台商家中心走的是重写路由,伪静态没开会出现「页面能打开但样式和链接全错」;
- 磁盘和目录权限,
application、public、runtime、template四个目录的属主和 PHP 进程用户一致。
用一条命令把关键信息打出来,比在后台翻半天快:
# 打印运行时关键信息,和后台首页底部对照 php -v | head -n 1 php -m | grep -Ei 'pdo_mysql|mbstring|gd|fileinfo' # 查看站点根目录权限归属,www 换成实际的 PHP 运行用户 ls -ld /path/to/wwwroot/{application,public,runtime,template}php -m那行如果少输出某个扩展名,就是没装,别往下走。ls -ld看的是目录属主,常见翻车是上传文件属于 root、PHP 进程是 www,运行时写不进runtime,表现是白屏但不报错,属于典型的黑匣子故障。
2.2 备份、上传与建表语句的执行顺序
顺序错了会很麻烦。插件包里通常会有一个install.sql(有的包叫install/eyou_merchant.sql),执行它之前必须先把库备份,因为这类插件往往会往会员表加字段,加完再想退回去,没有备份就得手工一条条删。备份分两块:文件和数据。
# 1. 先备份,插件类操作的回滚全靠这一步 cd /path/to/wwwroot tar -czf ../backup_files_$(date +%Y%m%d_%H%M).tar.gz ./ # eyou_db 换成实际库名,-p 会交互式输入密码 mysqldump -u root -p --default-character-set=utf8mb4 eyou_db > ../backup_db_$(date +%Y%m%d).sql # 2. 解压到临时目录,先看清结构再决定放哪 unzip eyou_merchant_plugin.zip -d /tmp/_plugin find /tmp/_plugin -maxdepth 2 -type d # 3. 按目录一一落位,rsync 会保留层级,比 cp -r 更可控 rsync -av /tmp/_plugin/application/ ./application/ rsync -av /tmp/_plugin/public/ ./public/ rsync -av /tmp/_plugin/template/ ./template/ # 4. 建表语句单独执行,先看再跑 mysql -u root -p eyou_db < /tmp/_plugin/install.sqltar -czf里的$(date ...)是给备份文件打时间戳,避免同名覆盖;mysqldump --default-character-set=utf8mb4是为了防止中文店铺名在恢复时变成问号,这个参数漏掉过一次就够记一辈子。rsync -av的-a是归档模式,保留权限和时间戳,-v只用来确认哪些文件被覆盖。
install.sql执行前一定要cat一遍。重点看三件事:表前缀是不是和你站点的前缀一致(默认ey_,改过前缀的必须先用sed替换,否则插件找不到表)、是不是有ALTER TABLE ey_users这类改主表的语句、有没有DROP TABLE。改主表的语句要特别小心,先DESCRIBE ey_users看现有字段,撞名了就得手工改成别的列名。
2.3 缓存清理与后台菜单、路由的注册
文件落位、库表建好,还不算装完。ThinkPHP 系的内核对配置和路由有缓存,runtime不清,插件在后台菜单里就是不出现。我一般的处理顺序是:清运行时缓存 → 进后台清一次系统缓存 → 后台退出重新登录 → 再看菜单。
# 清运行时缓存,注意别把整站 runtime 目录删掉,只删里面的内容 rm -rf ./runtime/* # 权限回正,避免上传后被 gzip 解压成 644 导致模板读不了 chmod -R 755 ./application ./public chown -R www:www ./application ./public ./runtime ./templaterm -rf ./runtime/*只清内容不删目录,因为有些内核会把 session 存在 runtime 下,目录本身删了反而要重建。chown里的www:www换成实际的 PHP 运行用户和组。
后台菜单没出现,多半是两个原因:一是插件的后台入口没有注册到菜单表,需要手动在系统设置里加一条菜单记录,链接指到插件控制器;二是权限节点没写入,超管能看到、普通管理员看不到,这是设计如此。前者是配置问题,后者不用管。前台商家中心的入口则要看伪静态规则里有没有放行插件的前缀,常见做法是在重写规则里给商家中心路径留一条RewriteRule,规则写在主站伪静态下面,别写到.htaccess和 nginx 配置各写一份,容易冲突。
3. 角色字段联动:把 ey_users 里的会员升级成商家
3.1 从一张表看懂会员到商家的映射关系
整套插件的核心不是权限,是数据关系。「会员变商家」这件事在数据结构上通常是这么表达的:会员还在ey_users里,不删不改,另外建一张商家档案表,用users_id和会员表一对一挂钩,商家的审核状态、店铺名、结算比例、开店时间全部落在这张档案表上。会员是身份,商家是资质,两者分离,会员被删了商家档案还能留痕,这是比较稳妥的做法。
建表语句一般长这样,字段名不同版本可能略有出入,但骨架是这个:
-- 商家档案表:一条记录对应一个已经"变成商家"的会员 CREATE TABLE IF NOT EXISTS `ey_merchant` ( `merchant_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `users_id` int(10) unsigned NOT NULL COMMENT '对应 ey_users.id,一对一', `shop_name` varchar(80) NOT NULL DEFAULT '' COMMENT '店铺名,前台展示用', `shop_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待审 1通过 2拒绝 3停用', `commission` decimal(5,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽佣百分比,0-100', `add_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '开店申请时间戳', PRIMARY KEY (`merchant_id`), UNIQUE KEY `uk_users` (`users_id`), KEY `idx_status` (`shop_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_users这个唯一索引是关键:同一个会员不可能开两家店,有它在,重复写入会直接被数据库挡掉,比在代码里写if exists再查一次要可靠得多。commission用decimal(5,2)而不是float,是因为浮点数在累加抽佣金额的时候会出现 0.01 的偏差,结算对不上账,这种玄学问题排查起来特别费时间。shop_status用四个枚举值而不是布尔,是为了留出「停用」这个状态——商家违规时先停用而不是直接删档案,历史订单还挂在他名下。
表前缀这块再强调一次:上面写的是ey_,你站点如果改过前缀,建表和后面的查询都要同步替换,否则页面会报「表不存在」,而且报错信息里往往只给表名不给行号,新手容易卡在这一步。
3.2 用一条 SQL 把存量会员批量升成商家
手动点后台一个一个升级,十个八个还行,几十个就受不了。存量会员批量升级,我一般直接用 SQL 做,做完再去后台核对。关键是加LEFT JOIN排重,不能裸INSERT,否则跑第二遍就撞唯一索引报错。
-- 把指定会员批量写进商家档案表,已经存在的跳过 INSERT INTO `ey_merchant` (`users_id`, `shop_name`, `shop_status`, `commission`, `add_time`) SELECT u.`id`, CONCAT(IFNULL(NULLIF(u.`username`, ''), '会员'), '的小店'), 1, -- 1 = 直接审核通过,批量导入一般不再走审核 5.00, -- 平台抽佣 5%,按你的规则改 UNIX_TIMESTAMP() FROM `ey_users` u LEFT JOIN `ey_merchant` m ON m.`users_id` = u.`id` WHERE u.`id` IN (1001, 1002, 1003) -- 换成你要升级的会员 ID AND m.`merchant_id` IS NULL; -- 只插还没档案的NULLIF(u.username, '')是为了处理用户名为空串的情况,IFNULL再兜一层,保证CONCAT出来的店铺名不会是null的小店。UNIX_TIMESTAMP()生成秒级时间戳,和你站点其他表的时间字段格式保持一致。WHERE u.id IN (...)里放会员 ID,如果会员很多,建议改成按注册时间段或者按会员等级筛,比如u.level_id = 3,别用全表,几万条会员一次性插进去,前台会卡。
跑完必须验证,别信「影响行数」那个数字:
-- 验证:商家档案和会员能不能对上,有没有孤儿记录 SELECT m.`merchant_id`, m.`users_id`, u.`username`, m.`shop_name`, m.`shop_status` FROM `ey_merchant` m LEFT JOIN `ey_users` u ON u.`id` = m.`users_id` WHERE u.`id` IS NULL OR m.`shop_status` <> 1;这条查询会同时暴露两类问题:u.id IS NULL说明商家档案挂了一个不存在的会员(多半是会员被删了档案没清),shop_status <> 1说明有档案但状态不是通过。两类都要处理,第一类删档案,第二类去后台点审核。
3.3 审核开关与权限组的绑定
批量导入是把状态直接设成 1,走的是「先上车后补票」的路子。正常流程应该是:会员在前台提交开店申请 → 后台生成一条shop_status = 0的档案 → 管理员审核 → 改成 1。这条链路上有两个开关必须同时打开,少一个前台就没入口。
第一个开关在插件配置里,通常叫「是否开启商家入驻」,关掉之后前台会员中心那个「我要开店」的按钮直接不渲染。第二个是权限组,商家中心的所有控制器都挂在某个权限组下面,如果这个组没有把菜单节点写进去,商家登录进去看到的是一片空白,这种翻车最容易让人误判成「插件没装好」。
权限校验我一般单独抽一个行为类,不要在每个控制器里复制粘贴判断逻辑:
<?php // application/common/behavior/MerchantAuth.php namespace app\common\behavior; use think\Db; class MerchantAuth { // 进商家中心之前的统一入口校验 public function run(&$params) { $usersId = session('users_id'); // 前台登录会员 ID,键名以你的登录逻辑为准 if (empty($usersId)) { return ['code' => 401, 'msg' => '请先登录']; } $merchant = Db::name('merchant')->where('users_id', $usersId)->find(); if (empty($merchant)) { return ['code' => 403, 'msg' => '当前账号还不是商家,请先提交入驻申请']; } if ((int)$merchant['shop_status'] !== 1) { // 1 = 审核通过 return ['code' => 403, 'msg' => '店铺未通过审核或已停用']; } // 把商家身份挂到参数上,后续控制器直接取,避免每次重复查库 $params['merchant'] = $merchant; return ['code' => 200]; } }session('users_id')的键名一定要和你站点实际的登录逻辑一致,改过登录模块的站点这里是重灾区,users_id可能是user_id、member_id,键名错了就永远是 401。返回结构用了code+msg,好处是控制器里可以统一json出去,前端不用写两套判断。最后把$merchant挂到$params上是个小优化,一次请求只查一次库,商家中心页面多的时候能省不少查询。
4. 商品与订单的归属隔离:商家只能看见自己那一摊
4.1 商品表加商家标识与查询作用域
商家能发商品之后,最要紧的不是上传功能好不好用,是隔离。A 商家能不能在列表里看到 B 商家的商品,能不能改 B 商家的商品,全靠查询条件里的那个merchant_id。所有商家后台的列表查询,必须无条件带上这个条件,一个地方漏了就是越权。
商品表通常是在原有商品主表上加一列,或者插件自建一张表。不管是哪种,都要有一个明确的归属字段:
-- 商品主表增加商家归属列(如果 install.sql 里已经加过,跳过) ALTER TABLE `ey_archives` ADD COLUMN `merchant_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '商家ID,0 表示平台自营' AFTER `users_id`, ADD KEY `idx_merchant` (`merchant_id`);DEFAULT '0'这个设计很实用:历史商品默认都是平台自营,加完列不用回填数据,老商品也不会突然消失。AFTER users_id只是让列位置好看一点,不影响功能。idx_merchant索引必须加,商家中心列表页是按这个字段过滤的,没索引的话商家一多,列表就慢得没法看。
控制器这边的写法我一般固定成这个模式,先把商家 ID 取出来,再拼进where:
// 商家后台商品列表:作用域锁定在当前商家 $merchantId = (int)$params['merchant']['merchant_id']; // 来自上一个行为类 if ($merchantId <= 0) { return json(['code' => 403, 'msg' => '商家身份异常']); } $list = Db::name('archives') ->where('merchant_id', $merchantId) // 这一行是隔离的命门 ->where('status', 1) ->order('id desc') ->paginate(20); // 编辑、删除前的归属校验,别只在列表里做 $row = Db::name('archives')->where('id', $id)->find(); if (empty($row) || (int)$row['merchant_id'] !== $merchantId) { return json(['code' => 403, 'msg' => '无权操作该商品']); }列表带条件只是第一步,编辑和删除接口必须再校验一次归属。血泪经验:只在列表做了过滤、编辑接口直接按id更新,等于把别人家的商品 ID 猜出来就能改,这是这类插件最典型的越权口子。我见过有人用Db::name('archives')->where('id', $id)->update(...)一把梭,连商家 ID 都不带,上线三天就被人批量改价。
4.2 下单时把订单挂到商家头上
商品有了归属,订单也得有。订单表加merchant_id列,写入时机是下单成功那一刻,值必须由服务端从商品表反查,绝对不能从前端表单里接。前端传什么就存什么,等于把订单归属交给了浏览器。
// 下单成功后写入商家归属,取值必须服务端反查 $goodsList = Db::name('archives') ->whereIn('id', $goodsIds) ->column('id,merchant_id,price'); // 只取需要的列,别 select * // 一个订单只属于一个商家,跨商家商品要拆单 $merchantIds = array_unique(array_column($goodsList, 'merchant_id')); if (count($merchantIds) > 1) { // 按商家拆成多个子订单,主订单号共用,子订单各自挂商家 foreach ($merchantIds as $mid) { // ... 计算该商家的小计金额并落一条子订单 } } else { Db::name('shop_order')->insert([ 'order_sn' => $orderSn, 'users_id' => $usersId, 'merchant_id' => (int)$merchantIds[0], // 反查得到,非前端传入 'order_amount' => $total, 'order_status' => 0, // 0 = 待付款 'add_time' => time(), ]); }column('id,merchant_id,price')这写法比select()省内存,商品多了差别明显。跨商家购物车必须拆单,这是多商户商城的通用规则,不拆单的话结算时没法算各自的佣金,退款时也没法按商家退。order_status的枚举值要和你站点原有的订单状态机对齐,别自己另起一套,否则后台订单列表和商家中心列表会显示成两种状态。
拆单这块如果插件自带的逻辑只支持单商家,改造成本不低,我建议先在前端做限制:购物车检测到多商家商品时提示分开下单。改造代码是后话,先保证不产生脏订单。
4.3 前台模板的商家入口怎么挂
最后是入口。会员登录后,会员中心里要有「商家中心」的链接,商家中心里要有商品管理、订单管理、结算记录三个菜单。模板这块插件一般会给一份示例文件,通常是template/pc/users/merchant.html这类的路径,需要你按自己站点的模板结构复制一份,再把链接挂到会员中心的左侧菜单。
挂之前先确认两件事:一是模板文件里的标签是不是插件自带的标签库,插件没启用标签库,页面会直接输出原始标签文本;二是链接地址是不是走了伪静态,写成index.php?m=merchant&a=index这种也能用,但和站点其他链接风格不统一,能重写就重写。挂完用普通会员账号和商家账号各登一次,普通会员应该看不到入口或者点进去提示申请,商家账号应该能直接进——这两种表现如果反了,说明入口的权限判断写反了。
5. 装完就翻车:五类高频问题按现象逐一排查
5.1 安装阶段的三类硬故障
现象一:后台一片空白,前台正常。原因:插件后台控制器的命名空间或者类名和主程序期望的不一致,PHP 直接 fatal,而主程序开了错误抑制,页面上什么都看不到。也可能是runtime里残留了旧的配置缓存,内核读到的还是安装前的路由表。 解决:先把application/config.php里的app_debug临时打开,或者去runtime/log里翻当天的错误日志,日志里会明确写出哪个文件哪一行。定位到类名之后对照主程序现有控制器的写法改命名空间,改完删掉runtime/*再来一次。不要在什么都没看的情况下反复重装,重装解决不了命名空间问题。
现象二:提示「表 xxx 不存在」。原因:站点改过表前缀,install.sql里的ey_没跟着改,建出来的表名和程序查询的表名对不上。也有一种情况是install.sql只执行了一半,中间某条语句报错中断,后面的表根本没建。 解决:先SHOW TABLES LIKE '%merchant%'看实际建了哪些表,再拿程序里Db::name('merchant')推出来它期望的表名,两边一比就知道差在哪。缺的表手工补建,前缀不对的用RENAME TABLE改过来。执行 SQL 的时候建议加--show-warnings,中间报错能立刻看到。
现象三:商家上传图片失败,页面无提示。原因:fileinfo扩展没装,或者public/uploads目录没有写权限,也有可能是 PHP 的upload_max_filesize比插件要求的小,营业执照照片超过限制被丢弃。 解决:php -m | grep fileinfo确认扩展,ls -ld public/uploads确认属主,php -i | grep upload_max_filesize确认上传上限。三个都排掉还不行,就看 nginx 的client_max_body_size,这个默认值往往只有 1M,图片一超就被网关挡在 PHP 之前,PHP 日志里干干净净,特别容易误判。
5.2 运行阶段的越权与状态错乱
现象四:商家 A 能看到商家 B 的商品。原因:列表查询的where条件漏了merchant_id,或者条件拼在一个可以被前端参数覆盖的数组里,比如$where = input('param.');这种写法,前端传一个merchant_id就把作用域改了。 解决:把所有商家侧的查询统一走一个基类方法,基类里强制拼merchant_id,业务方法不允许自己拼这个条件。这个改法比逐个文件检查可靠,因为新加的页面自动继承。历史代码里搜一遍input('param.'),凡是直接拿去当where的全要改。
现象五:会员在后台被升级成商家,前台还是进不去。原因:后台的「升级」按钮只改了会员表的等级字段,没有写商家档案表;或者写了档案但shop_status是 0,前台校验只放行 1。还有一种隐蔽情况是登录 session 没刷新,会员表改了,session 里缓的还是旧数据。 解决:先SELECT * FROM ey_merchant WHERE users_id = ?看有没有档案、状态是多少,没有就补写,状态不对就改。都正常的话让会员退出重新登录一次,session 刷新后一般就好了。这个坑的本质是「后台操作」和「前台校验」用了两套数据源,长期解法是让后台的升级按钮直接调前台那套校验逻辑,别各写各的。
5.3 一套固定的排查顺序
遇到问题别乱试,按这个顺序走,能省掉大量时间:
- 看日志:
runtime/log当天的文件,PHP 错误和 SQL 错误都在里面; - 看数据:直接连库查相关表,确认数据到底写没写进去,别信页面提示;
- 看会话:
session里存的身份信息是不是最新的,改过数据必须重新登录; - 看权限:文件属主、目录权限、数据库账号权限,三样一起看;
- 看缓存:
runtime清空、后台系统缓存清空、浏览器强刷,三件套走一遍。
这个顺序的道理是先拿事实(日志、数据)再猜原因,反过来先猜原因容易在错误的方向上越走越远。我吃过一次亏,一个「商家登录后白屏」的问题查了两个小时,最后发现是模板文件属主是 root、PHP 读不了,日志里只有一行 warning,早就写了,只是没第一时间去看。
6. 结算与二次校验:把每个商家当成一个小租户来管
插件装通、能发商品能下单,只是走到了半程。真正决定这套东西能不能长期用的是结算和二次校验——商家把钱收了,平台怎么抽佣,抽佣数据从哪来,出了问题怎么对。
结算的第一步不是写代码,是把账算清楚。我一般先用一条 SQL 把所有商家的应收应付拉出来,和订单表逐条对,对不上就不写结算逻辑:
-- 按商家维度对账:订单数、总额、已结算、待结算 SELECT o.`merchant_id`, COUNT(*) AS order_cnt, SUM(o.`order_amount`) AS total_amount, SUM(CASE WHEN o.`is_settle` = 1 THEN o.`order_amount` ELSE 0 END) AS settled, SUM(CASE WHEN o.`is_settle` = 0 THEN o.`order_amount` ELSE 0 END) AS pending, ROUND(SUM(CASE WHEN o.`is_settle` = 0 THEN o.`order_amount` ELSE 0 END) * m.`commission` / 100, 2) AS platform_fee FROM `ey_shop_order` o LEFT JOIN `ey_merchant` m ON m.`merchant_id` = o.`merchant_id` WHERE o.`order_status` = 2 -- 只算已完成的订单,别的状态不参与结算 GROUP BY o.`merchant_id`, m.`commission`;order_status = 2这个条件必须卡死,待付款、已发货、退款中的订单都不能进结算池,否则商家会提前拿到钱。ROUND(..., 2)是必须的,decimal乘完百分比之后小数位会变长,不收敛的话结算单上的数字会很难看。这条 SQL 我是当验收工具用的,改造完结算逻辑就跑一遍,和页面上的数字对,对不上说明中间某一步的口径不一致。
第二步是权限的二次校验。前面行为类做的是入口校验,但结算接口、提现接口这类敏感操作,必须在方法内部再查一次商家状态。原因是入口校验发生在请求进来时,如果请求过程中商家被停用了,入口那一次校验就失效了。常见做法是在结算方法开头重新查一次shop_status,不是 1 就直接拒。多这一条查询,成本很低,能挡掉一类很尴尬的问题。
第三步是留痕。结算记录我建议单独建一张流水表,每笔结算写一条,字段至少包含商家 ID、结算金额、佣金、结算时间、操作人。不要只更新订单表上的is_settle字段,那个字段只能告诉你「结没结」,告诉不了你「什么时候结的、谁结的、结了多少」。出过一次纠纷之后我就学乖了:任何涉及钱的字段变更,都必须有一条独立的流水记录,这条记录不允许被更新和删除。
最后一件事,改完这套东西之后,我会强制自己用一个全新的普通会员账号走一遍完整链路:注册 → 申请开店 → 后台审核 → 发商品 → 另一个账号下单 → 后台发货 → 确认收货 → 结算。全程不跳过任何一步,也不用自己的管理员账号测。这个习惯是被坑出来的——有一次商家侧一切正常,普通会员下单时因为购物车没做多商家判断,直接生成了一个商家 ID 为 0 的孤儿订单,后台怎么都对不上账,查了大半天。从那以后我每次动完订单相关的代码,都强制走一遍这个全链路,宁可多花二十分钟,也不想再对着流水表一行行找。希望帮到你。
本文还有配套的精品资源,点击获取