在动手二开之前,必须先区分多商户版和 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='租户表';
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'。
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 时,全局作用域不生效,平台管理员可以查看所有数据。
