必须在paginate()前用max(1, min($page, $total_pages))校验页码,否则非法page导致OFFSET越界、空查询及数据库压力;TP6+应使用input('page/d',1)获取并修正页码,query参数需同步更新$safe_page,且$total_pages须实时计算不可缓存。

直接用 max(1, min($page, $total_pages)) 重置页码,否则用户手动输 ?page=999 会查出空数据、OFFSET 越界、甚至拖垮数据库。
为什么$page超出$total_pages还能访问到空页
ThinkPHP 的 paginate() 不会自动拦截非法页码,它只按传入的 page 值计算 OFFSET 并执行查询。比如总记录 23 条、listRows = 10,$total_pages 是 3,但若请求 ?page=10,框架仍会生成 LIMIT 10 OFFSET 90 —— MySQL 执行无错,只是返回空数组,前端渲染出“第 10 页”链接,逻辑完全断裂。
常见表现:
- 模板中
{$list->lastPage()}返回 3,但当前$list->currentPage()是 10 -
$list->render()仍显示“10”这个页码,点进去还是空 - 日志里看到大量
SELECT ... LIMIT 10 OFFSET 90这类低效查询
如何在 ThinkPHP 中安全约束页码范围
不能依赖前端传参或模板判断,必须在 paginate() 调用前就完成校验和修正。TP6+ 推荐用 input() + max/min 组合,而不是直接读 $_GET['page']。
立即学习“PHP免费学习笔记(深入)”;
- 先算出合法最大页码:
$total_pages = ceil($total_count / $listRows)(注意必须用ceil(),不用round()或整除) - 取当前页并强转为整数:
$page = (int) input('page/d', 1),/d保证非数字时回退为 0,再被max(1, ...)拉回 1 - 强制约束范围:
$safe_page = max(1, min($page, $total_pages)) - 把
$safe_page显式传给paginate():UserModel::paginate(['list_rows' => 15, 'page' => $safe_page])
query 参数透传时容易漏掉页码重写
如果你用了 'query' => ['per_page' => input('per_page/d', 15)] 保留筛选参数,但没同步更新 page 值,那么“下一页”链接里拼的仍是原始非法 page=999,导致越界问题在翻页后复现。
-
query数组里必须包含修正后的页码:'query' => array_merge(request()->param(), ['page' => $safe_page]) - 不要在模板里用
{$list->render()}就完事——它内部读的是原始page参数,不是你算出来的$safe_page - 更稳妥的做法是:把
$safe_page也 assign 到模板,前端分页组件(如 JS 渲染)直接用它生成链接
游标分页场景下无需$total_pages,但页码概念本身失效
当数据量大、改用基于 id 的游标分页(如 where('id', '>', $last_id))时,根本不存在“总页数”和“第 N 页”的概念。此时强行校验 page 范围反而误导用户。
- 游标分页应禁用传统页码栏,只提供“下一页”按钮,并在响应中返回下一个
cursor值 - 若仍要兼容页码跳转(如后台管理),需额外维护一个映射表或用时间分段索引,不能靠
OFFSET算 - TP 默认
paginate()和游标逻辑互斥——混用会导致$list->total()为 0 或分页对象丢失
最易被忽略的一点:$total_pages 必须每次请求都重新计算,不能缓存或复用上一次的结果;尤其在后台有实时增删操作时,缓存的 $total_count 会导致 $total_pages 错误,进而让 max/min 校验失效。



















