分页本身不破坏租户隔离,但默认 paginate() 绕过模型 baseQuery 和全局作用域,导致 tenant_id 条件丢失;必须手动在 Db::name() 查询中显式添加 tenant_id 条件,包括 count()、关联查询和缓存 key。

分页本身不破坏租户隔离,但默认的 paginate() 会绕过模型层的 baseQuery 和全局作用域,导致 tenant_id 条件丢失——这是多租户系统中最隐蔽的数据越界源头。
为什么 paginate() 会漏掉租户条件
ThinkPHP 的 paginate() 是查询构造器(Query)方法,不是模型方法。它直接走 Db::table()->where()->paginate() 链路,完全跳过模型的 baseQuery()、全局作用域(addGlobalScope)、甚至 initialize() 钩子。
常见现象:User::where('status', 1)->paginate(10) 返回了其他租户的数据;User::paginate(10) 直接报错或查空——因为没带 tenant_id,数据库里根本没这条记录。
- 即使你在基类模型写了
baseQuery(),它对paginate()无效 - 即使你注册了全局作用域,
paginate()默认不启用它(除非显式传参) -
with()关联分页时,关联模型更难控制,容易只主表过滤、关联表放行
正确写法:强制在 paginate() 前注入 tenant_id
不能依赖模型自动补,必须手动加。最稳妥的方式是统一用 Db::name() + 显式 where(),并确保 tenant_id 来源可信。
立即学习“PHP免费学习笔记(深入)”;
示例(在控制器中):
$tenantId = app('request')->tenantId; // 从中间件已绑定的 request 实例取
$data = Db::name('user')
->where('tenant_id', $tenantId)
->where('status', 1)
->paginate(10);
- 绝不用
User::paginate()这类静态模型调用,它无法保证租户上下文 - 如果必须用模型,改用
(new User())->db()->where(...)->paginate(),确保走的是当前模型实例的连接和配置 - 关联分页需分别处理:
Db::name('order')->where(...)->with(['user' => function ($q) use ($tenantId) { $q->where('tenant_id', $tenantId); }])->paginate()
聚合分页(如带 count() 的自定义分页)要单独补租户条件
ThinkPHP 分页底层会执行一次 count() 查询,而 count() 同样不触发模型逻辑。若你重写了分页总数逻辑(比如用 total() 手动传总数),这个总数查询也必须带 tenant_id。
错误示范:
$total = User::where('status', 1)->count(); // 漏 tenant_id,总数算错
$lists = Db::name('user')->where('status', 1)->paginate(10, false, ['total' => $total]);
正确做法:
$tenantId = app('request')->tenantId;
$total = Db::name('user')->where('tenant_id', $tenantId)->where('status', 1)->count();
$lists = Db::name('user')
->where('tenant_id', $tenantId)
->where('status', 1)
->paginate(10, false, ['total' => $total]);
- 不要复用模型的
count(),一律走Db::name()+ 显式where('tenant_id', ...) - 如果用了软删除,
count()还得叠加delete_time IS NULL,顺序不能错:先租户,再状态,再软删 - 缓存分页总数时,key 必须包含
tenant_id,例如cache('user_paginate_total_' . $tenantId)
分页模板里租户上下文不能靠 $model->tenant_id 反推
分页对象(LengthAwarePaginator 或 TP 的 Paginator)本身不携带租户 ID。如果你在视图里试图用 $list->firstItem()->tenant_id 来渲染租户专属链接或按钮,风险极高——firstItem() 可能为空,或数据已被缓存/预加载,租户 ID 不可靠。
正确做法:把租户 ID 显式传给模板。
return view('user/list', [
'list' => $data,
'tenant_id' => $tenantId, // 主动传入
]);
- 分页链接里的参数(如跳转页码)应拼接
?tenant_id=xxx,避免依赖服务端隐式上下文 - 前端 JS 调用分页 API 时,请求头必须带
X-Tenant-ID,后端不再从 URL 解析租户 ID - 任何基于租户 ID 的权限判断(如“编辑按钮是否显示”),都应在控制器层完成,不要丢给模板逻辑
分页不是独立模块,它是查询链路的末端;租户隔离也不是“设个作用域就完事”。最容易被忽略的是:分页的 count、limit、offset 全部运行在无模型上下文的裸查询层面,所有过滤都得手动、显式、重复地写一遍——少一个 where,就漏一整页数据。



















