Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_EleVue/zh/core/tenant.md
T
2026-10-08 17:50:29 +08:00

10 KiB
Raw Blame History

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?→ 提供的数据库脚本已包含完整结构和数据
  • 租户角色/用户是否已创建?→ 租户管理中创建并分配账号