Skip to content

5.8 多租户隔离 ​

概述

采用共享数据库、共享表、tenant_id 字段隔离的方案。通过 BaseTenantLogic 基类实现声明式租户隔离,TenantMiddleware 注入租户上下文,BaseLogic::processTenantId() 自动填充租户ID。

设计原则 ​

text
┌──────────────────────────────────────────────────────────────┐
│                      tenant_id 语义                           │
│                                                              │
│   tenant_id = 0   →  超级管理员(平台级,不归属任何租户)       │
│   tenant_id = 1   →  默认租户(平台维护数据的归属租户)         │
│   tenant_id >= 2  →  业务租户(按注册顺序自增)                │
│                                                              │
│   ⚠️ 超管创建租户隔离数据时,自动代入默认租户(tenant_id=1)    │
│      而非写入 tenant_id=0                                     │
└──────────────────────────────────────────────────────────────┘

核心约定

tenant_id = 0 是权限标记(标识超级管理员),不是数据归属标记。超管创建的租户隔离数据归属默认租户(tenant_id = 1),而非归属"超管"(tenant_id = 0)。

表分类 ​

系统中的表分为两类,通过 Logic 基类选择来决定:

类型基类说明举例
系统共享表BaseLogic不做租户隔离,所有角色均可访问Menu、Dict、Config、Param、Tenant、FileTemplate
租户隔离表BaseTenantLogic按 tenant_id 自动过滤和填充User、Role、Article、Category、Dept、Level、Position、Link、Notice
php
// 系统共享表:继承 BaseLogic
class MenuLogic extends BaseLogic { ... }
class DictLogic extends BaseLogic { ... }

// 租户隔离表:继承 BaseTenantLogic
class UserLogic extends BaseTenantLogic { ... }
class RoleLogic extends BaseTenantLogic { ... }
class ArticleLogic extends BaseTenantLogic { ... }

继承选错的后果

  • 租户隔离表继承了 BaseLogic → 数据泄漏,所有租户互相可见
  • 系统共享表继承了 BaseTenantLogic → 菜单/字典等数据被隔离,租户用户看不到

隔离机制总览 ​

text
请求进入
  │
  ▼
┌──────────────────────┐
│   AuthMiddleware      │  解析 JWT → 注入 request->userInfo
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│   TenantMiddleware    │  查询用户 tenant_id → 注入 request->tenantId
│                       │  校验租户状态(存在、启用、未过期)
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│   BaseLogic           │
│   ├─ applyTenantScope │  查询时:tenantId=0 不过滤,tenantId>0 加 WHERE
│   └─ processTenantId  │  新增时:tenantId=0 代入默认租户,tenantId>0 自动填充
└──────────────────────┘

租户中间件 ​

php
// app/middleware/TenantMiddleware.php

public function handle(Request $request, \Closure $next): Response
{
    // 从 JWT 注入的 userInfo 取用户 ID
    $userInfo = $request->userInfo ?? null;
    $userId = $userInfo->uid ?? 0;

    $user = User::find($userId);
    $tenantId = $user->tenant_id ?? 0;

    // 注入租户上下文
    $request->tenantId = $tenantId;
    $request->isSuperAdmin = ($tenantId == 0);

    // 非超级管理员校验租户状态
    if ($tenantId > 0) {
        $tenant = Tenant::find($tenantId);
        if (!$tenant) {
            return json(['code' => 403, 'msg' => '租户不存在']);
        }
        if ($tenant->status != 1) {
            return json(['code' => 403, 'msg' => '租户已被禁用']);
        }
        if ($tenant->expire_time && strtotime($tenant->expire_time) < time()) {
            return json(['code' => 403, 'msg' => '租户已过期']);
        }
        $request->tenantInfo = $tenant;
    }

    return $next($request);
}
注入字段类型说明
request->tenantIdint当前用户所属租户ID,超管为 0
request->isSuperAdminbool是否为超级管理员(tenantId == 0)
request->tenantInfoTenant|null租户模型实例,超管为 null

查询隔离(applyTenantScope) ​

php
// app/BaseLogic.php

protected function applyTenantScope($model)
{
    if (empty($this->tenantScopeField)) {
        return $model;
    }

    $tenantId = request()->tenantId ?? 0;

    // 超级管理员不过滤(看到所有租户数据)
    if ($tenantId == 0) {
        return $model;
    }

    // 租户用户:只看到自己租户的数据
    return $model->where($this->tenantScopeField, $tenantId);
}

各角色查询行为:

角色tenantId查询条件可见数据
超级管理员0无 tenant_id 过滤所有租户数据
默认租户用户1WHERE tenant_id = 1默认租户数据(含超管创建的数据)
业务租户用户2WHERE tenant_id = 2仅本租户数据

新增填充(processTenantId) ​

php
// app/BaseLogic.php

protected function processTenantId(array $data): array
{
    if (empty($this->tenantScopeField)) {
        return $data;
    }

    $tenantId = request()->tenantId ?? 0;

    // 超级管理员代入默认租户
    if ($tenantId == 0) {
        $defaultTenantId = config('tenant.default_tenant_id', 1);
        if (!isset($data[$this->tenantScopeField])) {
            $data[$this->tenantScopeField] = $defaultTenantId;
        }
        return $data;
    }

    // 租户用户自动填充 tenant_id
    if ($tenantId > 0 && !isset($data[$this->tenantScopeField])) {
        $data[$this->tenantScopeField] = $tenantId;
    }

    return $data;
}

各角色新增行为:

角色tenantId写入的 tenant_id说明
超级管理员01(默认租户)自动代入,可通过配置修改
默认租户用户11自动填充
业务租户用户22自动填充

为什么不写入 tenant_id = 0?

如果超管创建的数据写入 tenant_id = 0:

  • 租户用户查询条件是 WHERE tenant_id = N,永远看不到这些数据
  • 语义混乱:tenant_id = 0 是权限标记,不应作为数据归属
  • 数据迁移困难:无法区分"平台数据"和"脏数据"

配置 ​

php
// config/tenant.php

return [
    // 默认租户ID,超管操作租户隔离表时自动代入
    'default_tenant_id' => 1,
];

超级管理员行为汇总 ​

操作行为
查询租户隔离数据不过滤,看到所有租户数据
新增租户隔离数据自动填充 tenant_id = 1(默认租户)
查询系统共享数据正常访问(共享表本身无租户过滤)
权限校验跳过(uid = 1 直接放行)
租户状态校验跳过(tenantId = 0 不校验)

与数据权限的关系 ​

多租户隔离和数据权限是两个独立维度,可同时生效:

text
┌─────────────────────────────────────────────┐
│  第一层:租户隔离(tenant_id)               │
│  不同租户数据完全隔离                        │
│                                             │
│  ┌───────────────────────────────────────┐  │
│  │  第二层:数据权限(data_scope)        │  │
│  │  同一租户内按部门/创建人进一步过滤     │  │
│  │                                       │  │
│  │  data_scope=1 → 全部数据              │  │
│  │  data_scope=2 → 本部门数据            │  │
│  │  data_scope=3 → 仅本人数据            │  │
│  └───────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

租户隔离优先级更高。详见 数据权限。

开发 Checklist ​

新增业务模块时,按以下清单判断租户隔离:

  • [ ] 该模块数据是否需要按租户隔离?→ 是:继承 BaseTenantLogic,否:继承 BaseLogic
  • [ ] 数据库表是否需要 tenant_id 字段?→ 租户隔离表必须有
  • [ ] 是否需要数据权限(部门/创建人过滤)?→ 配置 dataScopeDeptField 或 dataScopeUserField
  • [ ] 数据库脚本中是否已正确设置 tenant_id?→ 提供的数据库脚本已包含完整结构和数据

小蚂蚁云团队 · 提供技术支持