news 2026/10/14 8:00:17

LikeShop 单商户SaaS版二开:租户数据隔离与独立域名绑定实现方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LikeShop 单商户SaaS版二开:租户数据隔离与独立域名绑定实现方案

一、先厘清概念: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 数据库变更清单

操作表名说明
CREATEls_tenant租户主表
CREATEls_tenant_config租户配置表
CREATEls_tenant_template租户模板配置表
CREATEls_tenant_admin租户管理员表
CREATEls_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 时,全局作用域不生效,平台管理员可以查看所有数据。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 8:00:17

基于Java的班级管理系统毕业设计:从选题到答辩全流程指南

每年到三月&#xff0c;私信里十个计算机方向的学弟学妹&#xff0c;至少有三四个在问同一个方向&#xff1a;“学长&#xff0c;基于Java的班级管理系统这种题目&#xff0c;是不是太普通了&#xff1f;是不是显得没水平&#xff1f;”如果你也是带着这个疑问找到这篇文章的&a…

作者头像 李华
网站建设 2026/10/14 8:00:05

光谱器件国产化评估指南:如何区分源头自研厂商与拼装贸易商

引言越来越多仪器硬件团队启动 TDLAS 器件国产化替换&#xff0c;降低进口器件交期、成本压力。但市场供应商能力参差不齐&#xff0c;部分贸易商外购芯片、腔体简单组装贴牌&#xff0c;缺少底层仿真、筛选、可靠性验证能力。样机指标很好&#xff0c;批量供货出现批次离散大、…

作者头像 李华
网站建设 2026/10/14 7:59:56

SpringBoot音乐播放器小程序开发全流程解析

1. 项目背景与核心价值音乐播放器作为移动互联网时代的刚需应用&#xff0c;一直是计算机专业学生毕业设计的热门选题。基于SpringBoot的音乐播放器小程序结合了后端API开发与前端交互设计&#xff0c;能够全面展示学生的全栈开发能力。这个毕业设计项目包含完整源码、数据库结…

作者头像 李华
网站建设 2026/10/14 7:59:53

C语言字符数组与字符串操作完全指南

1. 为什么字符数组是C语言初学者的第一道坎第一次接触C语言的大学生&#xff0c;往往会在字符数组这个知识点上栽跟头。这不是因为概念有多难&#xff0c;而是C语言对字符串的特殊处理方式与其他高级语言完全不同。在Python里你可以直接写str "hello"&#xff0c;但…

作者头像 李华
网站建设 2026/10/14 7:59:42

OpenCV进阶操作:从调用函数到精准干预图像的实战指南

1. 项目概述&#xff1a;为什么“进阶操作”才是OpenCV真正上手的分水岭很多人学完OpenCV的cv2.imread、cv2.imshow、cv2.cvtColor就以为自己会了&#xff0c;结果一碰到实际项目——比如从监控画面里稳定提取移动物体、在手机拍摄的模糊照片中还原文字轮廓、或者让机械臂实时识…

作者头像 李华
网站建设 2026/10/14 7:59:27

工业AI落地三要素:轻量化模型、可信智能体与确定性边缘推理

1. 标题解码&#xff1a;这不是新闻简报&#xff0c;而是一份工业AI落地的路线图看到“AI日报&#xff5c;Anthropic把前沿模型带进工业防御 / Google给Agent独立身份 / 边缘决策模型压到3B”这个标题&#xff0c;第一反应不是点开看热闹&#xff0c;而是立刻在脑子里拆解出三个…

作者头像 李华