应在查询构造阶段(如重写Query::parseWhere或使用事件监听)自动注入tenant_id条件,覆盖SELECT/UPDATE/DELETE及JOIN、关联、分页、缓存等全链路,严禁SQL层后置修补。

如何在 Query 构造器中自动注入租户 ID 条件
ThinkPHP 默认的 Db 和 Model 查询不感知租户,必须手动加 where('tenant_id', $tid),漏写就数据越界。核心思路是拦截所有 SELECT/UPDATE/DELETE 的 SQL 构建过程,在生成 WHERE 子句前动态塞入租户过滤条件。
实操上,最稳妥的位置是重写 Query 类的 parseWhere 方法(v6.0+)或通过事件监听 think\db\Connection::beforeExecute。但注意:不能只改 select,UPDATE/DELETE 同样要过滤,否则租户间数据可被误删。
- 推荐继承
think\db\Query,覆盖where方法,在调用父类前检查是否已存在tenant_id条件,没有则 prepend - 避免用全局
Db::connect()->where()静态设置,它会污染后续非租户查询 - 事务内多个模型操作时,务必确保每个
Model实例都携带当前租户上下文,否则save()可能绕过过滤
Model 层如何安全绑定租户上下文
直接在模型里写死 $this->where('tenant_id', $tid) 是反模式——既难测试,又无法支持同一请求跨租户操作(如后台管理)。正确做法是让模型“可配置租户”,而非“硬编码租户”。
ThinkPHP 6 的 Model 支持 scope,但 scope 是静态的,无法动态传参。更可行的是利用模型的 initialize 钩子 + 请求上下文提取:
立即学习“PHP免费学习笔记(深入)”;
- 在基类
BaseModel的initialize中调用self::useTenant(),该方法从think\App或自定义容器中取当前租户 ID - 租户 ID 来源必须可信:优先从 JWT payload 或 session 解析,**绝不**从 URL 参数或 header 字段直取,防伪造
- 若某模型需跳过租户隔离(如
Tenant自身表),加白名单判断:if (static::class !== 'app\model\Tenant') { ... }
JOIN 查询时 tenant_id 条件容易遗漏的坑
多表 JOIN 是租户隔离最易破防的场景。比如 Order JOIN User,即使 Order 加了 tenant_id,User 表没加,用户数据仍可能跨租户泄露。
ThinkPHP 的 join() 不自动继承主表的租户条件,必须显式补全。这不是语法糖能解决的,得靠机制约束:
- 禁止裸写
join('user', 'order.user_id = user.id');统一用封装方法$query->tenantJoin('user', 'order.user_id = user.id'),内部自动追加AND user.tenant_id = ? - 如果关联模型(如
Order->user())也需租户隔离,必须在关联定义里显式写->where('tenant_id', $this->tenantId),ORM 不会自动透传 - 注意 LEFT JOIN 场景:若右表无匹配行,
tenant_id IS NULL可能绕过过滤,需用ON ... AND user.tenant_id = ?而非WHERE
为什么不能依赖中间件做全局 where 过滤
有开发者尝试在中间件里执行 Db::listen(function ($sql) { ... }) 或修改 Connection 对象,试图“统一加 tenant_id”。这看似省事,实则危险——SQL 字符串拼接无法准确识别 WHERE 位置,极易破坏原有逻辑,比如把 INSERT INTO user 误改成 INSERT INTO user WHERE tenant_id=1,直接报错。
真正可控的点只有查询构造阶段,即 ORM 对象还在内存中、SQL 尚未生成时。任何 SQL 层面的“后置修补”都不可靠:
-
Db::raw()、闭包查询、原生 SQL 都会逃逸出构造器控制,必须单独审计 - 分页
paginate()底层会执行 COUNT 查询,若未在构造器层面注入条件,COUNT 可能统计全量数据,导致分页错乱 - 缓存键若未包含租户 ID,不同租户可能命中同一缓存,造成数据污染
租户隔离不是加一行代码的事,是贯穿模型实例化、查询构建、SQL 生成、结果映射的完整链路。最容易被忽略的,其实是软删除字段(delete_time)和租户字段的组合条件优先级——WHERE tenant_id = ? AND delete_time IS NULL 必须保证顺序,否则索引可能失效。



















