"""统一响应体 {code, message, data, request_id} + 游标分页。""" from __future__ import annotations import base64 import binascii import json from datetime import datetime from typing import Any, Generic, TypeVar from uuid import UUID from pydantic import BaseModel, Field from .errors import AppError, ErrorCode from .logging_setup import request_id_ctx T = TypeVar("T") DEFAULT_LIMIT = 20 MAX_LIMIT = 100 class Envelope(BaseModel, Generic[T]): code: int = 0 message: str = "ok" data: T | None = None request_id: str = "-" class Page(BaseModel, Generic[T]): items: list[T] next_cursor: str | None = None has_more: bool = False class Cursor(BaseModel): """(created_at, id) 复合游标:单用 created_at 会在同毫秒记录上漏数据。""" created_at: datetime id: UUID def ok(data: Any = None, message: str = "ok") -> dict[str, Any]: return { "code": int(ErrorCode.OK), "message": message, "data": data, "request_id": request_id_ctx.get(), } def encode_cursor(created_at: datetime, row_id: UUID) -> str: raw = json.dumps({"t": created_at.isoformat(), "i": str(row_id)}, separators=(",", ":")) return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=") def decode_cursor(cursor: str) -> Cursor: try: padded = cursor + "=" * (-len(cursor) % 4) payload = json.loads(base64.urlsafe_b64decode(padded)) return Cursor(created_at=datetime.fromisoformat(payload["t"]), id=UUID(payload["i"])) except (binascii.Error, KeyError, ValueError, json.JSONDecodeError) as exc: raise AppError(ErrorCode.BAD_REQUEST, "分页游标不合法") from exc def clamp_limit(limit: int) -> int: if limit <= 0: return DEFAULT_LIMIT return min(limit, MAX_LIMIT) class Pagination(BaseModel): limit: int = Field(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT) cursor: str | None = None def parsed(self) -> tuple[int, Cursor | None]: return clamp_limit(self.limit), (decode_cursor(self.cursor) if self.cursor else None)