ThinkPHP6 模型关联查询:我写了 `withJoin` 却漏看 `field` 的坑,结果生产环境爆出一整张用户表
昨晚翻旧项目的 git 记录,看到一段自己两年前写的代码,血压又上来了。当时接了个需求:文章列表页要显示作者昵称和头像,但作者表里有手机号、真实姓名、身份证号这些敏感字段。我拍着胸脯说"用关联查询就行,简单",结果上线第二天就被安全组约谈——列表接口把用户整张表的数据全吐出去了。
先上我当时写的"自信代码":
// 错误示范:withJoin 没控字段,等于裸奔
public function getArticleList()
{
return Article::withJoin('author', 'LEFT')
->where('status', 1)
->select();
}
看着挺规矩对吧?`withJoin` 做左关联,条件也加了。但问题就出在 `withJoin` 的第二个参数我从来没细看过文档。ThinkPHP6 的 `withJoin` 如果不指定 `field`,默认会把关联模型的所有字段全拉进来。更坑的是,因为用了 `LEFT JOIN`,SQL 执行计划里这些字段混在主查询里,连 ORM 的隐藏属性保护都绕过去了——`hidden()` 和 `visible()` 对这种 join 场景根本不起作用。
当时接口返回的数据大概长这样,我截了个当时的日志:
{
"id": 1,
"title": "建站心得",
"author_id": 42,
"author__id": 42,
"author__nickname": "老站长",
"author__avatar": "https://...",
"author__phone": "138****1234", // 炸了
"author__real_name": "张三", // 彻底炸了
"author__id_card": "110101......" // 我可以去领盒饭了
}
修复过程倒是快,但复盘的时候我发现自己对"关联查询的字段控制"理解完全是碎片化的。现在我的写法分两种场景,贴出来给兄弟们参考:
场景一:只取展示字段,敏感数据碰都不要碰
// 正确写法1:withJoin 显式指定 field
public function getArticleList()
{
return Article::withJoin([
'author' => ['id', 'nickname', 'avatar']
], 'LEFT')
->where('status', 1)
->select();
}
注意这里 `withJoin` 的第一个参数要写成数组形式,键是关联名,值是要取的字段数组。这个语法我当初看了三遍文档才确认,因为 `with` 和 `withJoin` 的参数结构不完全一样,混着用容易抄错。
场景二:关联数据要二次处理,或者字段太多不想手写
// 正确写法2:用 with 预查询 + 模型里定好可见字段
// Article 模型里
public function author()
{
return $this->belongsTo(User::class, 'author_id', 'id')
->visible(['id', 'nickname', 'avatar']);
}
// 控制器里
public function getArticleList()
{
return Article::with(['author'])
->where('status', 1)
->select();
}
这种走预查询(IN 语句),性能上比 join 多一次查询,但模型层的 `visible` 能管住字段白名单,而且后续想加缓存、做数据脱敏都方便。我现在默认用这种,除非列表页明确要按作者字段排序才考虑 join。
还有个更隐蔽的坑:如果关联模型里你写了 `append` 属性或者获取器,用 `withJoin` 的时候这些也会跟着执行。我有一次在 User 模型里加了个 `get_balance` 获取器,本来只是算虚拟币余额,结果文章列表接口每个作者都触发了一次钱包查询,QPS 直接爆炸。后来改成 `with` 预查询,配合 `withCache` 才压下去。
最后说个检查技巧:开发环境我把数据库日志开了 `DB::listen`,每次接口请求完都扫一眼实际执行的 SQL。如果看到 `SELECT *` 或者关联表字段明显过多,立刻回溯。这招比代码审查快,也比上线后抓包安全。
你们有没有被 ORM 的"便利性"背刺过?我现在的原则是:任何关联查询,字段必须显式白名单,宁可多写两行,不留裸奔隐患。