签入版本

This commit is contained in:
小蚂蚁云
2026-10-25 17:24:27 +08:00
parent 48911cc08f
commit fdbeae092a
1010 changed files with 30682 additions and 95274 deletions
+44 -221
View File
@@ -1,245 +1,68 @@
# 5.8 多租户隔离
# 5.9 多租户隔离
:::tip 概述
采用共享数据库、共享表、`tenant_id` 字段隔离的方案。通过 `BaseTenantLogic` 基类实现声明式租户隔离,`TenantMiddleware` 注入租户上下文,`BaseLogic::processTenantId()` 自动填充租户ID。
租户隔离是双侧执行的红线:**写侧**全局 Create 钩子补齐 tenant_id,**读侧** repository Scope 追加租户条件。判定依据是 `hooks.tenantTables` 白名单(9 张表),租户状态由租户中间件在请求入口校验。
:::
## 设计原则
## 隔离模型
```text
┌──────────────────────────────────────────────────────────────┐
│ tenant_id 语义 │
│ │
│ tenant_id = 0 → 超级管理员(平台级,不归属任何租户) │
│ tenant_id = 1 → 默认租户(平台维护数据的归属租户) │
│ tenant_id >= 2 → 业务租户(按注册顺序自增) │
│ │
│ ⚠️ 超管创建租户隔离数据时,自动代入默认租户(tenant_id=1) │
│ 而非写入 tenant_id=0 │
└──────────────────────────────────────────────────────────────┘
平台级(tenantID=0,超管) → 不过滤,全量可见
租户用户 → tenant_id IN (0, 自身租户)
(0 为平台级公共数据,租户可见)
```
:::warning 核心约定
`tenant_id = 0` 是**权限标记**(标识超级管理员),不是**数据归属标记**。超管创建的租户隔离数据归属默认租户(`tenant_id = 1`),而非归属"超管"(`tenant_id = 0`)。
:::
## 双侧执行
## 表分类
| 侧 | 执行点 | 行为 |
|---|---|---|
| 写 | Create 钩子(createStamp) | 租户用户未指定时强制携带自身租户(防跨租户逃逸);超管未指定时取 `tenant.defaultId`,显式传 0 可创建平台共享行 |
| 读 | repository Scope(ApplyCtx) | 租户表追加 `tenant_id IN (0, 自身租户)` |
系统中的表分为两类,通过 Logic 基类选择来决定:
业务代码零参与——只要走 `Ctx(ctx)`,隔离自动生效。
| 类型 | 基类 | 说明 | 举例 |
|------|------|------|------|
| 系统共享表 | `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 { ... }
```
:::danger 继承选错的后果
- 租户隔离表继承了 `BaseLogic` → 数据泄漏,所有租户互相可见
- 系统共享表继承了 `BaseTenantLogic` → 菜单/字典等数据被隔离,租户用户看不到
:::
## 隔离机制总览
## 租户白名单(9 张表)
```text
请求进入
│
▼
┌──────────────────────┐
│ AuthMiddleware │ 解析 JWT → 注入 request->userInfo
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ TenantMiddleware │ 查询用户 tenant_id → 注入 request->tenantId
│ │ 校验租户状态(存在、启用、未过期)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ BaseLogic │
│ ├─ applyTenantScope │ 查询时:tenantId=0 不过滤,tenantId>0 加 WHERE
│ └─ processTenantId │ 新增时:tenantId=0 代入默认租户,tenantId>0 自动填充
└──────────────────────┘
gin_user / gin_role / gin_dept / gin_level / gin_position
gin_article / gin_category / gin_notice / gin_link
```
**新增租户级表必须同步登记** `internal/model/hooks` 的 tenantTables——漏登记的表写侧不带租户ID、读侧不过滤,等于隔离失效。表还需要真实存在 tenant_id 列。
## 租户中间件
```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->tenantId` | `int` | 当前用户所属租户ID,超管为 0 |
| `request->isSuperAdmin` | `bool` | 是否为超级管理员(tenantId == 0) |
| `request->tenantInfo` | `Tenant\|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 过滤 | 所有租户数据 |
| 默认租户用户 | 1 | `WHERE tenant_id = 1` | 默认租户数据(含超管创建的数据) |
| 业务租户用户 | 2 | `WHERE 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 | 说明 |
|------|----------|-----------------|------|
| 超级管理员 | 0 | 1(默认租户) | 自动代入,可通过配置修改 |
| 默认租户用户 | 1 | 1 | 自动填充 |
| 业务租户用户 | 2 | 2 | 自动填充 |
:::tip 为什么不写入 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 不校验) |
## 与数据权限的关系
多租户隔离和数据权限是两个独立维度,可同时生效:
链位置:JWT 之后、Authz 之前。校验逻辑:
```text
┌─────────────────────────────────────────────┐
│ 第一层:租户隔离(tenant_id) │
│ 不同租户数据完全隔离 │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ 第二层:数据权限(data_scope) │ │
│ │ 同一租户内按部门/创建人进一步过滤 │ │
│ │ │ │
│ │ data_scope=1 → 全部数据 │ │
│ │ data_scope=2 → 本部门数据 │ │
│ │ data_scope=3 → 仅本人数据 │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
免登录 → 放行
平台级(tenantID=0)→ 放行
租户摘要(缓存 60 秒):
状态非 1(禁用)→ 拒绝
过期时间早于当前 → 拒绝
摘要加载失败 / 加载器未注册 → 放行(降级:不因租户设施故障阻断全部请求)
```
租户隔离优先级更高。详见 [数据权限](/zh/core/data-scope.html)。
## 登录时的租户定位
## 开发 Checklist
登录请求可带 `tenantCode`:指定时在租户内定位用户;留空时全局匹配(优先平台租户)。租户不存在/禁用/过期分别返回对应业务错误码。
新增业务模块时,按以下清单判断租户隔离:
## 租户管理能力
- [ ] 该模块数据是否需要按租户隔离?→ 是:继承 `BaseTenantLogic`,否:继承 `BaseLogic`
- [ ] 数据库表是否需要 `tenant_id` 字段?→ 租户隔离表必须有
- [ ] 是否需要数据权限(部门/创建人过滤)?→ 配置 `dataScopeDeptField` 或 `dataScopeUserField`
- [ ] 数据库脚本中是否已正确设置 `tenant_id`?→ 提供的数据库脚本已包含完整结构和数据
- 租户 CRUD(gin_tenant,平台级表)
- 租户状态启停、有效期管理
- 超管新增租户级数据的默认归属(`tenant.defaultId`)
管理界面见 [6.7 租户管理](/zh/modules/tenant.html)。
## 设计边界
| 在租户隔离内 | 不在租户隔离内 |
|---|---|
| 白名单 9 张表的常规读写 | 字典、参数、配置等平台级表 |
| 在线用户列表/强退(按租户过滤) | 日志表(审计归属平台) |
| 内容模块(文章/分类/公告/友链) | 示例表 gin_example |
## 下一步
→ [5.10 数据权限](/zh/core/data-scope.html)