AI 生成后台列表后,分页 total 别只看第一页:Go + MySQL 怎么验
后台列表最容易被 AI 写成“能翻页就行”。第一页有数据page1pageSize20返回 200前端表格也能显示很多人就把这个模块过了。真正出问题通常晚一点才出现搜索条件一加total还是全表数量普通账号只能看自己租户的数据分页总数却把别的租户算进来了列表用了left join查角色名count(*)被 join 放大删除、禁用、草稿状态没有进 count 条件页面显示“共 128 条”翻到最后一页却是空的。这种 bug 不像 500 报错那么显眼但会直接影响后台判断。运营以为还有库存记录客服以为还有待处理订单管理员以为某个用户还在列表里。AI 生成后台列表之后分页 total 不能只看第一页至少要把过滤条件、权限条件、join 去重和空页都验一遍。一个容易误判的列表接口先看一个很常见的库存调整记录列表。为了方便复现表结构只保留关键字段CREATE TABLE inventory_adjust_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, product_id BIGINT NOT NULL, change_type VARCHAR(32) NOT NULL, change_qty INT NOT NULL, biz_no VARCHAR(64) NOT NULL, operator_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT done, deleted_at DATETIME NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_tenant_biz_no (tenant_id, biz_no), KEY idx_tenant_product_time (tenant_id, product_id, created_at), KEY idx_tenant_operator_time (tenant_id, operator_id, created_at) );AI 很可能生成这样的查询SELECT * FROM inventory_adjust_log WHERE product_id ? ORDER BY created_at DESC LIMIT ?, ?; SELECT COUNT(*) FROM inventory_adjust_log;列表看起来能跑问题却不少COUNT(*) 没有带 product_id 条件前端 total 一定偏大没有 tenant_id多租户后台会把别人的记录算进去没有 deleted_at IS NULL软删除记录还在分页里没有限制 status失败或回滚中的流水也可能混进结果如果后面 join 用户表或仓库表count 还可能被一对多关系放大。分页 total 的验收重点不是“有没有 total 字段”而是 list SQL 和 count SQL 是否共享同一套业务条件。count 和 list 要从同一个条件对象出来我一般不让 count 查询手写一份条件。手写两份迟早分叉。可以先把查询条件收拢成一个结构再由 list 和 count 复用。Go 里可以这样拆type AdjustLogQuery struct { TenantID int64 ProductID int64 WarehouseID int64 OperatorID int64 ChangeType string Status string StartTime string EndTime string Page int PageSize int } func normalizePage(page, size int) (int, int) { if page 1 { page 1 } if size 1 || size 100 { size 20 } return page, size }查询时把条件集中处理func buildAdjustLogWhere(q AdjustLogQuery) (string, []any) { where : []string{ tenant_id ?, deleted_at IS NULL, } args : []any{q.TenantID} if q.ProductID 0 { where append(where, product_id ?) args append(args, q.ProductID) } if q.WarehouseID 0 { where append(where, warehouse_id ?) args append(args, q.WarehouseID) } if q.OperatorID 0 { where append(where, operator_id ?) args append(args, q.OperatorID) } if q.ChangeType ! { where append(where, change_type ?) args append(args, q.ChangeType) } if q.Status ! { where append(where, status ?) args append(args, q.Status) } if q.StartTime ! { where append(where, created_at ?) args append(args, q.StartTime) } if q.EndTime ! { where append(where, created_at ?) args append(args, q.EndTime) } return WHERE strings.Join(where, AND ), args }list 和 count 都吃这个 wherefunc QueryAdjustLogs(ctx context.Context, db *sql.DB, q AdjustLogQuery) ([]AdjustLog, int64, error) { page, size : normalizePage(q.Page, q.PageSize) where, args : buildAdjustLogWhere(q) countSQL : SELECT COUNT(*) FROM inventory_adjust_log where var total int64 if err : db.QueryRowContext(ctx, countSQL, args...).Scan(total); err ! nil { return nil, 0, err } listSQL : SELECT id, tenant_id, warehouse_id, product_id, change_type, change_qty, biz_no, operator_id, status, created_at FROM inventory_adjust_log where ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ? listArgs : append(append([]any{}, args...), size, (page-1)*size) rows, err : db.QueryContext(ctx, listSQL, listArgs...) if err ! nil { return nil, 0, err } defer rows.Close() // scan rows ... return logs, total, nil }这里有两个细节别省。ORDER BY created_at DESC, id DESC比单独按时间稳。后台列表经常一秒内写入多条记录只按created_at排翻页时可能重复或漏行。tenant_id必须进 list 和 count。不能只在 list 上做租户隔离total 泄漏也算数据边界问题。用户看不到别人的行但能从 total 推出别的租户有多少记录这在一些后台场景里已经够敏感了。join 查询时total 要防止被放大后台列表很少只查一张表。库存记录通常要显示商品名、仓库名、操作者昵称。很多 total 错误都来自 join。错误写法SELECT COUNT(*) FROM inventory_adjust_log l LEFT JOIN sys_user_role ur ON ur.user_id l.operator_id WHERE l.tenant_id ? AND l.deleted_at IS NULL;如果一个操作人有多个角色这条 count 会把同一条库存日志算多次。列表里你可能只展示 20 行total 却变成 37。更稳的做法是主表计数只数主表展示字段另查或用不会放大的 join。SELECT COUNT(DISTINCT l.id) FROM inventory_adjust_log l WHERE l.tenant_id ? AND l.deleted_at IS NULL AND l.product_id ?;如果筛选条件确实依赖 join 表比如按角色筛操作人也要显式说明为什么用DISTINCT l.idSELECT COUNT(DISTINCT l.id) FROM inventory_adjust_log l JOIN sys_user_role ur ON ur.user_id l.operator_id WHERE l.tenant_id ? AND l.deleted_at IS NULL AND ur.role_id ?;不要把 ORM 自动生成的 count 当成一定正确。尤其是后台搜索条件越来越多的时候join、group by、having 都可能让 total 和列表语义分开。用 SQL 先造出会暴露问题的数据验分页不能只塞 3 条测试数据。至少造三类数据不同租户、已删除记录、同一秒多条记录。INSERT INTO inventory_adjust_log (tenant_id, warehouse_id, product_id, change_type, change_qty, biz_no, operator_id, status, deleted_at, created_at) VALUES (1, 10, 1001, manual_in, 5, ADJ-001, 11, done, NULL, 2026-07-28 10:00:00), (1, 10, 1001, manual_out, -2, ADJ-002, 11, done, NULL, 2026-07-28 10:00:00), (1, 10, 1002, manual_in, 3, ADJ-003, 12, done, NULL, 2026-07-28 10:01:00), (1, 10, 1001, manual_in, 1, ADJ-004, 12, done, 2026-07-28 10:02:00, 2026-07-28 10:02:00), (2, 20, 1001, manual_in, 9, ADJ-005, 21, done, NULL, 2026-07-28 10:03:00);然后直接查 count-- 租户 1、商品 1001、未删除应该是 2 SELECT COUNT(*) FROM inventory_adjust_log WHERE tenant_id 1 AND product_id 1001 AND deleted_at IS NULL; -- 如果返回 4 或 5说明条件没有同步到 count再查分页稳定性SELECT id, biz_no, created_at FROM inventory_adjust_log WHERE tenant_id 1 AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT 2 OFFSET 0; SELECT id, biz_no, created_at FROM inventory_adjust_log WHERE tenant_id 1 AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT 2 OFFSET 2;两页之间不应该重复也不应该因为同一秒写入导致顺序飘。curl 要覆盖 401、403、空页和越权 total接口验收不要只发管理员 token。至少四个请求未登录、无权限、正确权限、跨租户条件。# 1. 未登录应该 401 curl -i https://api.example.com/admin/inventory/adjust-logs?page1pageSize20productId1001 # 2. 普通账号无库存日志权限应该 403 curl -i https://api.example.com/admin/inventory/adjust-logs?page1pageSize20productId1001 \ -H Authorization: Bearer user-token # 3. 管理员查询租户 1total 应该等于 SQL 自查结果 curl -s https://api.example.com/admin/inventory/adjust-logs?page1pageSize20productId1001 \ -H Authorization: Bearer tenant-1-admin-token | jq .data.total, (.data.list | length) # 4. 同一个 productId 换租户 token不能把租户 2 的 total 泄漏给租户 1 curl -s https://api.example.com/admin/inventory/adjust-logs?page1pageSize20productId1001 \ -H Authorization: Bearer tenant-2-admin-token | jq .data.total还要测空页。比如 total 是 21pageSize20时第 2 页应该有 1 条第 3 页应该是空列表但 total 仍然是 21不应该被重算成 0。curl -s https://api.example.com/admin/inventory/adjust-logs?page3pageSize20productId1001 \ -H Authorization: Bearer tenant-1-admin-token | jq .data.total, .data.list很多前端分页组件会在空页时自动回到第一页。这个交互可以有但后端返回的 total 不能跟着乱变。日志里要记 count 条件不只记查询成功分页 total 出错时排查最痛苦的情况是日志只写了一句query success。最好把关键条件写进去至少能看出 list 和 count 是不是同一套条件。{ event: inventory_adjust_log_query, actor_id: 10086, tenant_id: 1, permission: inventory.adjustLog.list, filters: { product_id: 1001, warehouse_id: 10, change_type: manual_in, deleted_at: IS NULL }, page: 1, page_size: 20, total: 2, list_count: 2, result: ok }如果出现空页也应该能看见{ event: inventory_adjust_log_query, tenant_id: 1, page: 8, page_size: 20, total: 123, list_count: 0, result: empty_page }这类日志比“接口返回 200”有用。你能直接判断是前端页码没重置、筛选条件变了还是后端 count 条件漏了。代码生成器也要生成分页验收点这也是我看 XYGo Admin 这类 GoFrame Vue3 后台项目时比较在意的地方。它的 v1.4.6 Release 里提到过分页 total 修复仓库里也能看到server/internal/logic/gencodes/generate.go、server/internal/dao/sys_cron_log.go这类和生成逻辑、日志数据访问有关的路径。这个例子能验证本文的判断后台生成器如果只生成列表页面和查询接口还是不够至少要把 count 条件、租户条件、软删除条件、排序字段和空页验收一起带出来。源码可以看这个入口GitHub 仓库。这里不是让你照搬项目而是看它把后台列表、权限、日志和生成器放在同一套工程里时哪些边界容易被模板固定下来。发布前我会用这张表验一遍验收点怎么查不通过的表现list 和 count 条件一致对比 SQL where 条件total 比列表筛选范围大租户条件进入 count换租户 token 查同一 productIdtotal 泄漏别的租户数据软删除条件进入 count插入 deleted_at 数据删除记录仍计入总数join 不放大 total用多角色用户造数据total 大于主表行数排序稳定同一秒插入多条记录翻页两页重复或漏行空页语义稳定请求超过最后一页total 变 0 或状态码异常权限码有效普通 token 请求返回 200 或只靠前端隐藏日志可排查读取结构化日志看不出过滤条件和 total分页 total 是个小字段但它背后连着数据库条件、权限边界和前端判断。AI 能很快把列表生成出来这没问题问题是生成出来以后不能用“第一页能看”当上线标准。如果你们后台也有库存、订单、日志、会员这类列表我建议拿一个最复杂的筛选条件跑一遍带租户、带状态、带软删除、带 join、翻到最后一页。这个用例过了分页才算有点可信。