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