一、先厘清概念:SaaS 版与多商户版的本质差异
在动手二开之前,必须先区分多商户版和 SaaS 版,两者的技术模式完全不同,用错方案会导致架构方向性错误。
| 维度 | 多商户版 | SaaS 版 |
|---|---|---|
| 业务模型 | 一个平台,多个商家入驻 | 一套系统,多个独立租户 |
| 用户归属 | 用户是平台级的,可跨店购物 | 用户是租户级的,只能在所属租户商城购物 |
| 数据隔离 | 按 shop_id 隔离商家数据 | 按 tenant_id 隔离租户数据 |
| 后台体系 | 平台后台 + 商家后台 | 平台运营后台 + 租户独立后台 |
| 资金结算 | 平台统一收款,分账给商家 | 每个租户独立收款,平台只收 SaaS 服务费 |
| 域名 | 平台一个域名,商家共用 | 每个租户可绑定独立域名或子域名 |
多商户版的用户是全平台通用的,一个用户可以在多个商家下单;SaaS 版的用户是租户私有的,A 租户的会员在 B 租户的商城里不存在;多商户隔离的是“商家数据”,SaaS 版隔离的是“整个商城的数据”。
LikeShop 单商户 SaaS 版聚焦于“无限多开”——一套代码部署一次,可以为多个客户开通独立的商城账号,每个客户用自己的域名或子域名访问自己的商城后台;SaaS 版通常采用共享数据库 + tenant_id 字段的方案,所有业务表都带一个 tenant_id 字段,每一次查询都强制拼上这个条件。
LikeShop 单商户SaaS版目录结构要点:服务端根目录为 server/,应用目录下包含 adminapi/(租户管理后台接口)、shopapi/(商城接口)等模块,app/common/model 存放数据层模型;理解这个目录结构是后续二开定位文件的前提。
二、租户识别:无限多开的第一步
2.1 三种租户识别方式
SaaS 版通常支持三种租户识别方式,按优先级从高到低:
方式一:独立域名识别。每个租户绑定自己的域名,比如 shop-a.com 对应租户 A,shop-b.com 对应租户 B;系统根据请求的 Host 头识别租户。
方式二:子域名识别。平台提供一个主域名,租户使用子域名访问,比如 a.platform.com 对应租户 A,b.platform.com 对应租户 B;系统根据子域名前缀识别租户。
方式三:请求参数识别。在 URL 或 Header 中携带租户标识,比如 ?tenant_id=1 或 X-Tenant-Id:1;这种方式一般用于 API 调试或内部调用。
2.2 租户识别中间件的实现
在 LikeShop 的 ThinkPHP 架构中,租户识别必须封装在中间件中,在请求进入 Controller 之前完成租户解析;租户识别必须在请求生命周期最早执行,否则模型事件、日志记录、缓存写入等操作可能已基于错误上下文完成,导致数据泄露或上下文错了。
// server/app/common/middleware/TenantMiddleware.php namespace app\common\middleware; use app\common\model\TenantModel; use think\facade\App; use think\facade\Db; class TenantMiddleware { public function handle($request, \Closure $next) { // 1. 优先从独立域名识别 $host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''; $host = explode(':', $host)[0]; $tenant = TenantModel::where('domain', $host) ->where('status', 1) ->where('expire_time', '>', time()) ->find(); // 2. 域名未匹配,尝试从子域名识别 if (!$tenant) { $mainDomain = config('saas.main_domain'); $pattern = '/^([a-z0-9\-]+)\.' . preg_quote($mainDomain, '/') . '$/i'; if (preg_match($pattern, $host, $matches)) { $subdomain = $matches[1]; $tenant = TenantModel::where('subdomain', $subdomain) ->where('status', 1) ->where('expire_time', '>', time()) ->find(); } } // 3. 仍未匹配,尝试从请求参数识别(仅限内部调用) if (!$tenant) { $tenantId = $request->header('X-Tenant-Id', ''); if ($tenantId && $this->isInternalRequest($request)) { $tenant = TenantModel::where('id', $tenantId) ->where('status', 1) ->where('expire_time', '>', time()) ->find(); } } // 4. 租户不存在或已过期 if (!$tenant) { if ($this->isPlatformRequest($host)) { // 平台后台请求,不绑定租户 return $next($request); } return json(['code' => 404, 'msg' => '商城不存在或已过期']); } // 5. 校验租户到期状态 if ($tenant['expire_time'] < time()) { return json(['code' => 403, 'msg' => '商城已到期,请联系平台续费']); } // 6. 清洗租户标识,绑定到可信上下文 $safeTenantId = (int)$tenant['id']; App::instance()->tenant = $safeTenantId; App::instance()->tenantInfo = $tenant; // 7. 动态重建数据库连接(多库隔离模式) if (config('saas.isolation_mode') === 'multi_db') { $dbConfig = [ 'type' => 'mysql', 'hostname' => config('database.hostname'), 'database' => 'db_tenant_' . $safeTenantId, 'username' => config('database.username'), 'password' => config('database.password'), 'charset' => 'utf8mb4', 'prefix' => 'ls_', ]; config(['database.connections.tenant' => $dbConfig]); Db::clearConnection('tenant'); } return $next($request); } private function isInternalRequest($request) { $internalIps = ['127.0.0.1', '::1']; return in_array($request->ip(), $internalIps); } private function isPlatformRequest($host) { return $host === config('saas.platform_domain'); } }该中间件必须注册在 app/widdleware.php 的首位,确保在所有其他中间件之前执行。
2.3 租户表设计
CREATE TABLE `ls_tenant` ( `id` int UNSIGNED NOT NULL AUTO_INCREMENT, `tenant_name` varchar(50) NOT NULL COMMENT '租户名称', `domain` varchar(100) NOT NULL DEFAULT '' COMMENT '独立域名', `subdomain` varchar(50) NOT NULL DEFAULT '' COMMENT '子域名前缀', `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', `package_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '套餐ID', `expire_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '到期时间', `template_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '模板ID', `create_time` int UNSIGNED NOT NULL DEFAULT 0, `update_time` int UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_domain` (`domain`), UNIQUE KEY `uk_subdomain` (`subdomain`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';三、数据隔离:三种方案与 LikeShop 的实现选择
3.1 三种隔离方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 单库多租户 | 所有租户数据存同一库同一表,通过 tenant_id 区分 | 维护成本低,部署简单 | 隔离性弱,单表数据量大 | 中小规模 SaaS |
| 多库多租户 | 每个租户独立数据库 | 隔离性强,数据安全 | 维护成本高,连接数多 | 中大规模 SaaS |
| 混合模式 | 公共表共享,业务表分库 | 平衡隔离与成本 | 架构复杂 | 成长型 SaaS |
LikeShop SaaS 版通常用的是共享数据库 + tenant_id 方案,所有业务表都带一个 tenant_id 字段,每一次查询都强制拼上这个条件;likeadmin-PHP-SaaS 版也支持自定义租户分表策略,允许创建租户时选择公表或者单独分表模式存储租户数据。
3.2 单库多租户下的强制隔离
即使数据库连接正确,ThinkPHP 模型本身不感知租户,User::find(1) 仍可能返回其他租户的数据;必须通过全局作用域在所有查询构建器中自动注入 tenant_id 条件。
// server/app/common/model/scope/TenantScope.php namespace app\common\model\scope; use think\Model; use think\facade\App; class TenantScope implements \think\Model\ScopeInterface { public function apply(Model $model, $query) { $tenantId = App::instance()->tenant ?? 0; if ($tenantId > 0) { $query->where($model->getTable() . '.tenant_id', $tenantId); } } } // server/app/common/model/BaseModel.php namespace app\common\model; use think\Model; use app\common\model\scope\TenantScope; class BaseModel extends Model { protected $tenantTables = [ 'goods', 'order', 'user', 'cart', 'coupon', 'article', 'note', 'recharge_order', 'after_sale' ]; protected static function boot() { parent::boot(); $tableName = (new static())->getTable(); // 仅对业务表启用租户作用域,公共配置表不启用 if (in_array($tableName, (new static())->tenantTables)) { static::addGlobalScope('tenant', new TenantScope()); } } }3.3 全局作用域的覆盖盲区
需要特别的注意,全局作用无法覆盖 count()、JOIN 和软删除;User::where('status',1)->count() 仍可能返回 0(实际有数据),因为聚合查询绕过模型生命周期;with('profile') 关联默认不继承租户条件。
// 正确做法:聚合查询显式加 tenant_id $count = UserModel::where('tenant_id', $tenantId) ->where('status', 1) ->count(); // 正确做法:JOIN 查询显式指定租户条件 $list = OrderModel::alias('o') ->join('order_goods og', 'o.id = og.order_id') ->where('o.tenant_id', $tenantId) ->where('og.tenant_id', $tenantId) ->select();3.4 多库隔离模式的连接重建
如果选择多库隔离模式,必须在模型查询发生前完成配置更新与连接池清理;ThinkPHP 的数据库连接采用懒初始化与连接池复用机制,仅修改配置项不会触发实际连接切换,旧连接仍指向默认库。
关键步骤:构建完整数据库连接配置(database 字段必须为合法库名如 db_tenant1)→ 调用 config() 覆盖连接配置 → 立即执行 Db::clearConnection('tenant') 清空连接缓存 → 所有业务模型显式声明 protected $connection = 'tenant'。
四、独立域名绑定:从 DNS 到 Nginx 的完整链路
4.1 域名绑定的三种模式
| 模式 | 示例 | 实现复杂度 | 适用阶段 |
|---|---|---|---|
| 平台子域名 | a.platform.com | 低(泛解析即可) | 起步阶段 |
| 租户独立域名 | shop-a.com | 中(需DNS验证+SSL) | 成长阶段 |
| 混合模式 | 同时支持以上两种 | 高 | 成熟阶段 |
参考 SaaS 平台通用的域名管理方案:企业统一配置泛域名,新创建的项目可获得该企业品牌下的专属子域名;租户需要使用自有域名时,也可以单独绑定并管理证书。
4.2 泛解析与子域名自动分配
租户创建时自动分配子域名:
class TenantDomainService { /** * 创建租户时自动生成子域名 */ public function assignSubdomain($tenantId, $tenantName) { // 基于租户名称生成拼音子域名 $subdomain = strtolower(Pinyin::permalink($tenantName)); $subdomain = preg_replace('/[^a-z0-9]/', '', $subdomain); // 冲突检测 $exists = TenantModel::where('subdomain', $subdo main)->count(); if ($exists > 0) { $subdomain .= '_' . $tenantId; } TenantModel::where('id', $tenantId)->update([ 'subdomain' => $subdomain, 'domain' => $subdomain . '.' . config('saas.main_domain'), ]); return $subdomain; } }4.3 独立域名的 DNS 验证流程
当租户要绑定自己的域名(如 shop-a.com)时,需要验证域名所有权,防止恶意绑定他人域名:
class CustomDomainService { /** * 生成域名验证令牌 */ public function generateVerifyToken($tenantId, $domain) { $token = md5($tenantId . $domain . config('saas.domain_verify_salt')); // 存储待验证记录 TenantDomainVerifyModel::updateOrCreate( ['tenant_id' => $tenantId, 'domain' => $domain], [ 'verify_token' => $token, 'status' => 0, // 待验证 'expire_time' => time() + 86400, ] ); return $token; } /** * 验证域名所有权 */ public function verifyDomain($tenantId, $domain) { $record = TenantDomainVerifyModel::where('tenant_id', $tenantId) ->where('domain', $domain) ->where('status', 0) ->find(); if (!$record) { throw new Exception('验证记录不存在或已验证'); } // 方式一:DNS TXT 记录验证 $txtRecords = dns_get_record($domain, DNS_TXT); $verified = false; foreach ($txtRecords as $txt) { if ($txt['txt'] === 'likeshop-verify=' . $record['verify_token']) { $verified = true; break; } } // 方式二:文件验证(备用) if (!$verified) { $verifyUrl = 'http://' . $domain . '/.well-known/likeshop-verify.txt'; $content = @file_get_contents($verifyUrl); if ($content === $record['verify_token']) { $verified = true; } } if (!$verified) { throw new Exception('域名验证失败,请检查 DNS TXT 记录或验证文件'); } // 验证通过,绑定域名 Db::startTrans(); try { TenantModel::where('id', $tenantId)->update(['domain' => $domain]); TenantDomainVerifyModel::where('id', $record['id'])->update([ 'status' => 1, 'verify_time' => time() ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } // 触发 SSL 证书自动签发 Queue::push(SslProvisionJob::class, [ 'tenant_id' => $tenantId, 'domain' => $domain, ]); return true; } }4.4 Nginx 配置:泛域名与独立域名的兼容
#泛域名解析(子域名模式) server { listen 80; server_name *.platform.com; root /www/likeshop/server/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/tmp/php-cgi.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # 传递真实 Host 到 PHP fastcgi_param HTTP_HOST $host; } } # 独立域名(租户自定义域名) server { listen 80; server_name shop-a.com www.shop-a.com; root /www/likeshop/server/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/tmp/php-cgi.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_param HTTP_HOST $host; } }Nginx 将真实 Host 传递给 PHP 后,TenantMiddleware 从 $_SERVER['HTTP_HOST']
读取域名匹配租户。
五、独立模版与独立支付配置
5.1 租户独立模版体系
每个租户可以有自己的店铺装修风格;模版隔离的方案为:平台提供模版市场,租户选择模版后,模版配置数据存储在租户级配置中。
CREATE TABLE `ls_tenant_template` ( `id` int UNSIGNED NOT NULL AUTO_INCREMENT, `tenant_id` int UNSIGNED NOT NULL COMMENT '租户ID', `template_code` varchar(50) NOT NULL COMMENT '模板编码', `template_config` text COMMENT '模板配置,JSON格式', `is_active` tinyint NOT NULL DEFAULT 0 COMMENT '是否当前使用', `create_time` int UNSIGNED NOT NULL DEFAULT 0, `update_time` int UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_tenant_id` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户模板配置表';模版切换时,读取租户的 template_code 加载对应的视图文件:
class TemplateService { public function getTemplatePath($tenantId) { $template = TenantTemplateModel::where('tenant_id', $tenantId) ->where('is_active', 1) ->find(); $templateCode = $template ? $template['template_code'] : 'default'; // 模板目录结构:view/{template_code}/ return 'view/' . $templateCode . '/'; } }5.2 独立支付配置
每个租户使用自己的微信/支付宝商户号独立收款,平台只收 SaaS 服务费;支付配置存储在租户配置表中,key 为 tenant_{tenant_id}_pay_config:
class TenantPayService { /** * 获取租户的支付配置 */ public function getPayConfig($tenantId, $payType) { // 优先读取租户级配置 $config = TenantConfigModel::where('tenant_id', $tenantId) ->where('config_key', 'pay_' . $payType) ->value('config_value'); if ($config) { return json_decode($config, true); } // 降级到平台级配置 return ConfigService::get('pay_' . $payType, []); } /** * 保存租户支付配置 */ public function savePayConfig($tenantId, $payType, $config) { TenantConfigModel::updateOrCreate( ['tenant_id' => $tenantId, 'config_key' => 'pay_' . $payType], ['config_value' => json_encode($config, JSON_UNESCAPED_UNICODE)] ); } }注意:likeadmin-PHP-SaaS版中曾有“租户端微信支付无法获取商户号”的修复记录,说明租户级支付配置的读取逻辑在初始化阶段容易遗漏,二开时需确保支付回调中也能正确读取租户配置。
5.3 配置隔离的分层设计
配置分为平台级和租户级两层,读取时遵循“租户级优先,平台级兜底”原则:
class TenantConfigService { public static function get($key, $default = null) { $tenantId = App::instance()->tenant ?? 0; if ($tenantId > 0) { $tenantValue = TenantConfigModel::where('tenant_id', $tenantId) ->where('config_key', $key) ->value('config_value'); if ($tenantValue !== null) { return $tenantValue; } } return ConfigService::get($key, $default); } }每个租户有独立的配置,避免平台级配置被租户级配置覆盖。
六、租户权限分级管理改造
6.1 双层角色模型
SaaS 平台的权限分为两层:平台级角色和租户级角色。平台管理员管理所有租户和平台配置,租户管理员管理自己商城内的业务操作。
| 层级 | 角色 | 权限范围 |
|---|---|---|
| 平台级 | 超级管理员 | 全部租户管理、套餐配置、平台设置 |
| 平台级 | 运营管理员 | 租户审核、到期管理、公告发布 |
| 租户级 | 租户管理员 | 租户内全部业务配置 |
| 租户级 | 租户运营 | 商品、订单、营销管理 |
| 租户级 | 租户客服 | 订单查看、售后处理 |
6.2 权限表设计
-- 租户管理员表 CREATE TABLE `ls_tenant_admin` ( `id` int UNSIGNED NOT NULL AUTO_INCREMENT, `tenant_id` int UNSIGNED NOT NULL COMMENT '所属租户ID', `user_id` int UNSIGNED NOT NULL COMMENT '用户ID', `role_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '角色ID', `is_owner` tinyint NOT NULL DEFAULT 0 COMMENT '是否租户所有者', `status` tinyint NOT NULL DEFAULT 1, `create_time` int UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_user` (`tenant_id`, `user_id`), KEY `idx_tenant_id` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户管理员表';6.3 权限校验中间件
在租户后台的请求中,校验当前用户是否属于该租户:
class TenantAuthMiddleware { public function handle($request, \Closure $next) { $tenantId = App::instance()->tenant ?? 0; if ($tenantId <= 0) { return json(['code' => 403, 'msg' => '未识别到租户']); } $userId = $this->getAdminUserId($request); $isAdmin = TenantAdminModel::where('tenant_id', $tenantId) ->where('user_id', $userId) ->where('status', 1) ->find(); if (!$isAdmin) { return json(['code' => 403, 'msg' => '无权访问该租户后台']); } // 注入租户权限上下文 App::instance()->tenantAdminRoleId = $isAdmin['role_id']; App::instance()->tenantAdminIsOwner = $isAdmin['is_owner']; return $next($request); } }七、完整开发步骤总结
7.1 数据库变更清单
| 操作 | 表名 | 说明 |
|---|---|---|
| CREATE | ls_tenant | 租户主表 |
| CREATE | ls_tenant_config | 租户配置表 |
| CREATE | ls_tenant_template | 租户模板配置表 |
| CREATE | ls_tenant_admin | 租户管理员表 |
| CREATE | ls_tenant_domain_verify | 域名验证记录表 |
| ALTER | 所有业务表 | 新增 tenant_id 字段和索引 |
7.2 后端开发清单
| 模块 | 文件/类 | 工作内容 |
|---|---|---|
| 中间件 | TenantMiddleware | 租户识别、上下文绑定、数据库连接重建 |
| 中间件 | TenantAuthMiddleware | 租户后台权限校验 |
| 数据层 | TenantModel / TenantConfigModel | 租户主表和配置表 Model |
| 数据层 | BaseModel | 基类模型,自动注入租户作用域 |
| 数据层 | TenantScope | 全局作用域类 |
| 业务层 | TenantDomainService | 子域名分配、域名验证、SSL 签发 |
| 业务层 | TemplateService | 租户模板加载和切换 |
| 业务层 | TenantPayService | 租户支付配置的读写 |
| 业务层 | TenantConfigService | 分层配置读取 |
| 后台接口 | PlatformTenantController | 平台端租户管理接口 |
| 命令 | TenantDatabaseCrontab | 租户数据库连接初始化命令 |
7.3 开发注意事项
租户识别不可依赖 session/cookie:租户 ID 必须从 _ SERVER{'HTTP_X_FORWARDED_HOST'} 或 _ SERVER{'HTTP_HOST'} 提取,不能用 session()、input('tenant_id') 或 request()->domain();CLI 命令、队列任务、定时器没有 session,URL 参数可被伪造,request()->domain() 可能已被 Nginx rewrite 规则污染。
缓存必须按租户隔离:Redis 缓存键需加上 tenant:{tenant_id}:前缀,避免 A 租户的缓存被 B 租户读取;Session 同理,建议使用独立的 Session 存储或按租户隔离的 Cookie 作用域。
租户到期处理:租户到期后,中间件应拦截所有商城端请求,但租户后台的续费页面仍需可访问;到期后数据保留一定期限(如 30 天),超期后归档或清理。
兼容性:tenant_id 字段设置默认值 0,保证存量数据的兼容性;平台后台的请求不绑定租户,App::instance()->tenant 为 0 时,全局作用域不生效,平台管理员可以查看所有数据。