GORM权限分页必须先Where过滤再Offset/Limit,否则易漏数据或越权;权限条件须作为WHERE一部分,总数统计需隔离DB会话,高并发大偏移应改用带联合索引的游标分页。

GORM 没有内置权限分页功能,必须把权限过滤逻辑和分页逻辑拆开处理,否则极易漏数据或越权。
权限条件必须在分页前完成 Where 过滤
权限控制(如部门可见性、角色数据范围)是 WHERE 条件的一部分,不是装饰性逻辑。如果先分页再过滤,Offset 和 Limit 会作用在未过滤的全量数据上,导致:第 1 页返回了用户 A 的数据,但 A 不在其权限范围内,实际应被跳过——结果就是页面空或错乱。
- 正确顺序:先拼好所有权限相关
Where(例如Where("dept_id IN ?", deptIDs)或Where("status != ?", "deleted")),再调用Offset和Limit - 避免用
Preload做权限判断:比如Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("status = 'paid'" })只影响关联加载,不参与主表筛选 - 复杂权限(如 RBAC + 数据级规则)建议封装成
Scope函数,例如WithPermissionScope(userID),保证复用性和可测性
分页参数校验必须独立于权限逻辑执行
前端传来的 page 和 page_size 是不可信输入,校验步骤不能依赖任何数据库查询,也不能放在权限判断之后——否则恶意请求可能绕过前置检查直接触发慢查询。
-
page小于等于 0 时强制设为 1,别返回错误;page_size超过上限(如 100)就截断,不拒掉请求 - 用
c.ShouldBindQuery(&p)绑定结构体,配合binding:"required,min=1,max=100"标签做字段级校验,比手动c.Query()更可靠 - 不要在
Where条件里拼接未校验的字符串参数,防止 SQL 注入,尤其当权限规则含动态字段名时(如Where(fmt.Sprintf("%s = ?", field), value))
总数统计必须隔离权限条件且避免复用 DB 实例
Count 查询必须和主查询使用完全一致的 Where 条件,但不能共用同一个 *gorm.DB 链——因为 Limit 和 Offset 会影响 Count 结果(它会返回带分页限制后的行数,永远 ≤ page_size)。
- 推荐写法:
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total),用NewDB: true隔离会话 - 如果权限条件特别复杂(如多层 JOIN + 子查询),
Count容易出错,直接手写子查询更稳:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN ... WHERE ...) AS t").Scan(&total) - 高并发场景下,总数非必需时可省略:查
page_size + 1条,若结果长度 >page_size,则返回has_next: true,避免全表 COUNT
大偏移或高并发下必须放弃 Offset 分页
当权限过滤后剩余数据量仍很大(如千万级用户中某角色可见 50 万条),Offset(499999).Limit(20) 在 MySQL 中需扫描 50 万行再丢弃,响应时间从毫秒升至秒级,且无法靠加索引解决。
- 改用游标分页:前端传上一页最后一条记录的
id和created_at,后端查WHERE id > ? AND created_at >= ? ORDER BY created_at ASC, id ASC LIMIT 20 - 游标字段必须有联合索引,例如
INDEX idx_perm_cursor (created_at, id),且权限Where条件中的字段也尽量加入索引覆盖 - 游标分页天然不支持“跳转到第 N 页”,但内部系统通常不需要——翻页按钮只提供“下一页”和“上一页”,更符合权限场景的实际使用路径
权限和分页叠加时,最容易被忽略的是:权限条件变更(如用户被移出部门)会导致同一页数据在两次请求间不一致,而 Offset 分页对此完全无感知。游标分页虽不能彻底解决,但至少让翻页行为稳定在有序数据流上,而不是依赖全局行号。











