# 5.8 多租户隔离 :::tip 概述 采用共享数据库、共享表、`tenant_id` 字段隔离的方案。通过 `BaseTenantService` 基类实现声明式租户隔离(`use HasDataScope` + `tenantScopeField='tenant_id'`),`ResolveTenant` 中间件注入租户上下文,`processTenantId()` 自动填充租户 ID。 ::: ## 设计原则 ```text ┌──────────────────────────────────────────────────────────────┐ │ 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 | ```php // 系统共享表:继承 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` → 菜单/字典等数据被隔离,租户用户看不到 ::: ## 隔离机制总览 ```text 请求进入 │ ▼ ┌────────────────────────────┐ │ Authenticate │ 解析 JWT → 注入 attributes['userInfo'] └──────────┬─────────────────┘ │ ▼ ┌────────────────────────────┐ │ ResolveTenant │ 查库取 tenant_id → 注入 attributes['tenantId'] │ │ 校验租户状态(存在、启用、未过期) └──────────┬─────────────────┘ │ ▼ ┌────────────────────────────┐ │ BaseTenantService │ │ (use HasDataScope) │ │ ├─ applyTenantScope │ 查询时:平台用户(0)不过滤,租户用户加 WHERE │ └─ processTenantId │ 新增时:平台用户代入默认租户,租户用户自动填充 └────────────────────────────┘ ``` ## 租户中间件(ResolveTenant,来自项目实际源码) ```php // 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) ```php // 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) ```php // 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` 是"平台用户"标记,不应作为数据归属 - 数据迁移困难:无法区分"平台数据"和"脏数据" ::: ## 配置 ```php // 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 不进入校验分支) | ## 与数据权限的关系 多租户隔离和数据权限是两个独立维度,可同时生效: ```text ┌─────────────────────────────────────────────┐ │ 第一层:租户隔离(tenant_id) │ │ 不同租户数据完全隔离 │ │ │ │ ┌───────────────────────────────────────┐ │ │ │ 第二层:数据权限(data_scope) │ │ │ │ 同一租户内按部门/创建人进一步过滤 │ │ │ │ │ │ │ │ data_scope=1 → 全部数据 │ │ │ │ data_scope=2 → 本部门数据 │ │ │ │ data_scope=3 → 仅本人数据 │ │ │ └───────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` 租户隔离优先级更高。详见 [5.9 数据权限](/zh/core/data-scope.html)。 ## 开发 Checklist 新增业务模块时,按以下清单判断租户隔离: - [ ] 该模块数据是否需要按租户隔离?→ 是:继承 `BaseTenantService`,否:继承 `BaseService` - [ ] 数据库表是否需要 `tenant_id` 字段?→ 租户隔离表必须有 - [ ] 是否需要数据权限(部门/创建人过滤)?→ 配置 `dataScopeDeptField` 或 `dataScopeUserField` - [ ] 数据库脚本中是否已正确设置 `tenant_id`?→ 提供的数据库脚本已包含完整结构和数据 - [ ] 租户角色/用户是否已创建?→ 租户管理中创建并分配账号