10 KiB
10 KiB
5.8 多租户隔离
:::tip 概述
采用共享数据库、共享表、tenant_id 字段隔离的方案。通过 BaseTenantService 基类实现声明式租户隔离(use HasDataScope + tenantScopeField='tenant_id'),ResolveTenant 中间件注入租户上下文,processTenantId() 自动填充租户 ID。
:::
设计原则
┌──────────────────────────────────────────────────────────────┐
│ tenant_id 语义 │
│ │
│ tenant_id = 0 → 平台用户(不属于任何租户,查询不过滤) │
│ tenant_id = 1 → 默认租户(DEFAULT_TENANT_ID,平台数据归属)│
│ tenant_id >= 2 → 业务租户(按注册顺序自增) │
│ │
│ ⚠️ uid=1(超级管理员)是权限标记,与 tenantId 独立: │
│ 超管创建的租户隔离数据自动代入默认租户(tenant_id=1), │
│ 而非写入 tenant_id=0 │
└──────────────────────────────────────────────────────────────┘
:::warning 两个独立概念
本项目严格区分:uid=1 是权限标记(跳过权限与租户校验),tenantId=0 是"平台用户"标记(查询不按租户过滤)。ResolveTenant 中间件的 isSuperAdmin 按 uid===1 判定,与 tenantId 是否为 0 无关。超管创建的租户隔离数据归属默认租户(tenant_id=1),而非归属"超管"(tenant_id=0)。
:::
表分类
系统中的表分为两类,通过 Service 基类选择来决定:
| 类型 | 基类 | 说明 | 举例 |
|---|---|---|---|
| 系统共享表 | BaseService |
不做租户隔离,所有角色均可访问 | Menu、Dict、Config、Param、Tenant、FileTemplate、Example |
| 租户隔离表 | BaseTenantService |
按 tenant_id 自动过滤和填充 |
User、Role、Article、Category、Dept、Level、Position、Link、Notice |
// 系统共享表:继承 BaseService
class MenuService extends BaseService { /* ... */ }
class DictCacheService extends BaseService { /* ... */ }
// 租户隔离表:继承 BaseTenantService
class UserService extends BaseTenantService { /* ... */ }
class RoleService extends BaseTenantService { /* ... */ }
class ArticleService extends BaseTenantService { /* ... */ }
:::danger 继承选错的后果
- 租户隔离表继承了
BaseService→ 数据泄漏,所有租户互相可见 - 系统共享表继承了
BaseTenantService→ 菜单/字典等数据被隔离,租户用户看不到 :::
隔离机制总览
请求进入
│
▼
┌────────────────────────────┐
│ Authenticate │ 解析 JWT → 注入 attributes['userInfo']
└──────────┬─────────────────┘
│
▼
┌────────────────────────────┐
│ ResolveTenant │ 查库取 tenant_id → 注入 attributes['tenantId']
│ │ 校验租户状态(存在、启用、未过期)
└──────────┬─────────────────┘
│
▼
┌────────────────────────────┐
│ BaseTenantService │
│ (use HasDataScope) │
│ ├─ applyTenantScope │ 查询时:平台用户(0)不过滤,租户用户加 WHERE
│ └─ processTenantId │ 新增时:平台用户代入默认租户,租户用户自动填充
└────────────────────────────┘
租户中间件(ResolveTenant,来自项目实际源码)
// app/Http/Middleware/ResolveTenant.php(节选)
public function handle(Request $request, Closure $next): Response
{
$userInfo = $request->attributes->get('userInfo');
if (! $userInfo) {
// 未认证请求:不注入租户上下文,直接放行
return $next($request);
}
$uid = $userInfo->uid ?? 0;
// 超级管理员(uid=1):跳过租户校验,直接注入 tenantId=0
if ($uid === 1) {
$request->attributes->set('tenantId', 0);
$request->attributes->set('isSuperAdmin', true);
return $next($request);
}
// 非超管:查库取 tenant_id(JWT 不含 tenant_id,需查库)
$user = User::find($uid);
if (! $user) {
return response()->json([
'code' => 401, 'ok' => false,
'msg' => '用户不存在,请重新登录', 'data' => null,
]);
}
$tenantId = $user->tenant_id ?? 0;
$request->attributes->set('tenantId', $tenantId);
$request->attributes->set('isSuperAdmin', false);
// 平台用户(tenantId=0):跳过租户校验
if ($tenantId > 0) {
$tenant = Tenant::find($tenantId);
// 租户不存在 → 403「租户不存在」
// 租户被禁用(status != 1)→ 403「租户已被禁用」
// 租户已过期(expire_time->isPast())→ 403「租户已过期」
$request->attributes->set('tenantInfo', $tenant);
}
return $next($request);
}
注入字段(request->attributes) |
类型 | 说明 |
|---|---|---|
tenantId |
int |
当前用户所属租户 ID,0=平台用户 |
isSuperAdmin |
bool |
是否超级管理员(uid === 1 时为 true) |
tenantInfo |
Tenant|null |
租户模型实例,平台用户为 null |
查询隔离(applyTenantScope)
// app/Services/Concerns/HasDataScope.php
protected function applyTenantScope($query)
{
if (empty($this->tenantScopeField)) {
return $query;
}
$tenantId = request()->attributes->get('tenantId', 0);
// 平台用户不过滤(可查看所有租户数据)
if ($tenantId == 0) {
return $query;
}
// 租户用户:只看到自己租户的数据
return $query->where($this->tenantScopeField, $tenantId);
}
各角色查询行为:
| 角色 | tenantId | 查询条件 | 可见数据 |
|---|---|---|---|
| 超级管理员(uid=1) | 0 | 无 tenant_id 过滤 | 所有租户数据 |
| 平台用户 | 0 | 无 tenant_id 过滤 | 所有租户数据 |
| 默认租户用户 | 1 | WHERE tenant_id = 1 |
默认租户数据(含超管创建的数据) |
| 业务租户用户 | 2 | WHERE tenant_id = 2 |
仅本租户数据 |
新增填充(processTenantId)
// app/Services/Concerns/HasDataScope.php(行为说明)
protected function processTenantId(array $data): array
{
// 未配置 tenantScopeField 则跳过
// 平台用户(tenantId=0)→ 代入 config('tenant.default_tenant_id')(默认 1)
// 租户用户(tenantId>0)→ 自动填充当前 tenantId
// 字段已有显式值时不覆盖
}
各角色新增行为:
| 角色 | tenantId | 写入的 tenant_id | 说明 |
|---|---|---|---|
| 超级管理员 / 平台用户 | 0 | 1(默认租户) | 自动代入,DEFAULT_TENANT_ID 可配置 |
| 默认租户用户 | 1 | 1 | 自动填充 |
| 业务租户用户 | 2 | 2 | 自动填充 |
:::tip 为什么不写入 tenant_id = 0?
如果平台侧创建的数据写入 tenant_id = 0:
- 租户用户查询条件是
WHERE tenant_id = N,永远看不到这些数据 - 语义混乱:
tenant_id = 0是"平台用户"标记,不应作为数据归属 - 数据迁移困难:无法区分"平台数据"和"脏数据" :::
配置
// config/tenant.php
return [
// 默认租户ID,平台用户操作租户隔离表时自动代入(.env: DEFAULT_TENANT_ID)
'default_tenant_id' => env('DEFAULT_TENANT_ID', 1),
];
超级管理员行为汇总
| 操作 | 行为 |
|---|---|
| 查询租户隔离数据 | 不过滤(uid=1 注入 tenantId=0),看到所有租户数据 |
| 新增租户隔离数据 | 自动填充 tenant_id = 1(默认租户) |
| 查询系统共享数据 | 正常访问(共享表本身无租户过滤) |
| 权限校验 | 跳过(uid = 1 直接放行) |
| 租户状态校验 | 跳过(tenantId = 0 不进入校验分支) |
与数据权限的关系
多租户隔离和数据权限是两个独立维度,可同时生效:
┌─────────────────────────────────────────────┐
│ 第一层:租户隔离(tenant_id) │
│ 不同租户数据完全隔离 │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ 第二层:数据权限(data_scope) │ │
│ │ 同一租户内按部门/创建人进一步过滤 │ │
│ │ │ │
│ │ data_scope=1 → 全部数据 │ │
│ │ data_scope=2 → 本部门数据 │ │
│ │ data_scope=3 → 仅本人数据 │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
租户隔离优先级更高。详见 5.9 数据权限。
开发 Checklist
新增业务模块时,按以下清单判断租户隔离:
- 该模块数据是否需要按租户隔离?→ 是:继承
BaseTenantService,否:继承BaseService - 数据库表是否需要
tenant_id字段?→ 租户隔离表必须有 - 是否需要数据权限(部门/创建人过滤)?→ 配置
dataScopeDeptField或dataScopeUserField - 数据库脚本中是否已正确设置
tenant_id?→ 提供的数据库脚本已包含完整结构和数据 - 租户角色/用户是否已创建?→ 租户管理中创建并分配账号