因病历数据写入频繁、时间戳可能重复且含软删除和多租户约束,第三方paginate插件会漏判deleted_at、跨tenant泄漏、同毫秒记录排序错乱;手写分页须做三件事:强校验page/pagesize、显式双字段排序(如created_at desc, id desc)、count查询独立建新db会话。

为什么不能直接用第三方 Paginate 插件查病历
因为病历数据写入频繁、时间戳可能重复、且存在软删除(deleted_at)、多租户(tenant_id)等约束,第三方插件如 github.com/joshbetz/pagination 会默认忽略这些条件,导致:查出已逻辑删除的病历、跨租户泄漏数据、同毫秒创建的两条病历在分页中顺序颠倒甚至丢失。它底层仍是 Limit/Offset,但不强制你加 Order("id DESC, created_at DESC"),也不帮你隔离 Count 查询中的 WHERE 条件。
手写 Limit + Offset 分页必须做的三件事
医疗系统对数据一致性要求极高,跳过任一环节都可能造成前端翻页错乱或漏诊记录:
-
参数强校验:page 必须 ≥ 1,pageSize 必须在 1–50 之间(病历列表不宜过长),用
strconv.Atoi前先检查空字符串和非数字; -
显式双字段排序:仅
Order("created_at DESC")不够——同一毫秒可能有多个病历,必须补上Order("created_at DESC, id DESC")或Order("updated_at DESC, id DESC"); -
Count 查询必须独立:不能复用同一个
*gorm.DB实例,否则Where("tenant_id = ?", tenantID).Deleted(false)会被带进Count(),但Limit()后的链式调用会继承前面所有条件,导致总数不准。正确写法是:db.Session(&gorm.Session{NewDB: true}).Model(&MedicalRecord{}).Where("tenant_id = ? AND deleted_at IS NULL", tenantID).Count(&total)
什么时候必须切游标分页查历史病历
当科室日均新增病历超 5000 条、且支持“加载更多”无限滚动时,Offset 分页会在第 200 页后明显变慢(MySQL 实际扫描前 10 万行再丢弃)。此时应改用游标分页,但医疗场景有特殊约束:
- 不能只依赖
created_at—— 时钟回拨或批量导入会导致时间重复,必须组合主键:WHERE id ; - 首次请求用
ORDER BY id DESC LIMIT 20,返回最旧一条的id作为下一页的last_id; - 禁止在游标查询中用
Preload("Patient")—— 关联患者信息必须拆成两步:先查出 20 个patient_id列表,再用IN批量查患者基础信息,否则会触发 N+1 查询且无法控制关联数据条数。
最容易被忽略的软删除与租户隔离细节
病历表普遍启用 GORM 的 SoftDelete,但开发者常忘记在分页时显式排除已删除记录。更隐蔽的问题是:如果用 Scopes 封装租户过滤,而该 Scope 写在 Count 查询之前,但没在 Find 查询里重复应用,就会出现“总数 100 条,但只查出 80 条”的现象。根本原因是 Count() 和 Find() 是两个独立 SQL,必须各自确保包含 Where("tenant_id = ?").Unscoped().Where("deleted_at IS NULL") 这类条件。别指望一次 Scope 覆盖全部。











