ThinkPHP6 模型关联里我写了 `hasMany()->where()` 链式调用,结果分页总数和列表数据对不上了
昨晚给一个客户项目做订单中心,需求很简单:查某个用户的订单,并且只要状态为"已支付"的,带分页。我随手就写了下面这串代码,本地测试列表数据没问题,上线后客户反馈"第1页显示10条,分页却提示只有3页"。
错误写法(我最初的代码):
// OrderController.php
public function index($userId)
{
$user = User::find($userId);
// 注意这里:在关联方法上直接链式 where
$orders = $user->orders()->where('status', 2)->paginate(10);
return json($orders);
}
看起来没毛病对吧?`$user->orders()` 返回关联查询构造器,再 `where` 过滤,最后分页。但问题藏在 `paginate` 的实现里——TP6 的分页需要先执行 `COUNT(*)` 查总数,再执行 `LIMIT` 查列表。上面这段代码的 `COUNT` 和 `LIMIT` 两条 SQL,where 条件只作用在了 LIMIT 那条,COUNT 的时候把该用户所有订单都算进去了,总数虚高。
我翻源码跟到 `think\db\BaseQuery::paginate()`,发现它内部会 clone 当前查询对象去做聚合。但 `hasMany` 关联在 `__clone` 时的行为和我预期不一样,链式 `where` 后的状态在 clone 时丢了一部分约束。简单说就是:关联查询上直接链式条件,分页计数会"失忆"。
正确写法(两种方案):
方案A:用关联的查询范围(推荐)
// User 模型里定义关联时加 scope
public function paidOrders()
{
return $this->hasMany(Order::class, 'user_id', 'id')
->where('status', 2); // 条件写在关联定义里
}
// 控制器直接调用
$orders = $user->paidOrders()->paginate(10);
这样条件在关联定义阶段就固化进去了,clone 的时候不会丢。
方案B:控制器里用闭包预加载(适合动态条件)
// 控制器
$orders = Order::where('user_id', $userId)
->where('status', 2)
->paginate(10);
或者如果必须用关联入口,用 `with` 预加载时带闭包:
$user = User::with(['orders' => function ($query) {
$query->where('status', 2);
}])->find($userId);
// 但这里要注意:with 的闭包只影响预加载数据,不影响关联查询本身
// 如果还要分页,得走 Order 模型直接查,别绕 user->orders()
我最后选了方案A,因为"已支付订单"这个查询在业务里出现频率很高,封装成命名关联更干净。但这里有个坑中坑:如果你在 `paidOrders()` 关联里写了 `->order('id desc')`,那 `paginate` 里的排序参数会和它冲突,需要 `->removeOption('order')` 清掉默认排序再传新的。
另外说个血泪教训:发现总数不对的时候,我先怀疑的是缓存,清了两遍 Redis,又怀疑是软删除 `withTrashed` 的问题,加了一堆 `->whereNull('delete_time')`,越修越乱。最后是在日志里把 `Db::getLastSql()` 打印出来,对比 COUNT 和 SELECT 的 WHERE 子句,才发现条件长度不一样——排这种"数据对不上"的 bug,第一时间抓 SQL 对比,别瞎猜。
你们有没有遇到过关联查询和分页"各算各的"的情况?或者 `hasMany` 里还埋过别的定时炸弹?