"""游标分页的输入校验(文档 §3.8、§16.1)。 文档 §3.8 规定列表接口统一使用 `cursor` + `limit`,§16.1 要求"分页游标绑定过滤 条件,非法游标返回 `400 INVALID_CURSOR`"。当前实现的游标就是**记录 ID 边界** (`id < cursor`,取更旧的一页),本次**不引入新的不透明编码机制**:这里的职责只有 一件事——把"能安全当边界用"的游标筛出来,其余一律 400,不再静默忽略。 为什么非法游标必须报错而不是当成"没有游标":静默忽略会把客户端的笔误(`abc`、 `-1`、超范围 ID)变成"返回第一页且没有更多数据",客户端据此认为已经翻到底, 数据丢失无法察觉;而规范化成"从头开始"又会造成重复读取。两种都比 400 更难排查。 取值范围的依据(上界与下界都不是随手取的): - 下界 `1`:游标列是自增 BIGINT 主键,从 1 起;`0`/负数只可能来自客户端计算错误, 作为 `id < 0` 的边界会让列表恒为空。 - 上界 `2**63 - 1`:与基线 BIGINT 有符号上界一致。更大的值不可能是真实 ID, 只会在 SQL 里退化成一次无意义的全范围比较。 - 只接受纯 ASCII 十进制数字:`+1`、`-1`、`1.0`、`0x10`、`1e3`、"1 2" 以及各种 Unicode 数字(`str.isdigit()` 对它们也为真)一律拒绝——`int()` 与不同数据库方言 对同一字符串的解释并不一致,输入层只放行一种唯一解释。 错误消息只说明原因与期望格式,**不回显客户端原始取值**:避免把客户端可控内容 (可能含不可见控制字符,文档 §3.7)搬进错误响应体。 """ from app.core.errors import InvalidCursorError # BIGINT 有符号上界,与 `docs/00-新数据库基线设计.md` 的 id 列类型一致。 MAX_CURSOR_VALUE = 2**63 - 1 def parse_cursor(raw: str | None, *, field: str = "cursor") -> int | None: """把查询串里的游标解析成记录 ID 边界;`None` 与空白串都表示不翻页。 `?cursor=`(空值)在实际客户端里是"没有更多页"的常见写法,把它当非法会让正常 客户端翻到最后一页时突然收到 400;因此空白串按"未携带游标"处理,其余一律严格校验。 非法取值抛 `InvalidCursorError`(`400 INVALID_CURSOR`,文档 §3.6)。 """ if raw is None: return None value = raw.strip() if not value: return None if not value.isascii() or not value.isdigit(): raise InvalidCursorError(f"{field} 必须是十进制数字(记录 ID),不允许符号、小数或十六进制") parsed = int(value) if parsed < 1: raise InvalidCursorError(f"{field} 必须为正整数(记录 ID 从 1 开始)") if parsed > MAX_CURSOR_VALUE: raise InvalidCursorError(f"{field} 超出记录 ID 的取值范围(最大 {MAX_CURSOR_VALUE})") return parsed