Files
DadaoTask/backend/app/schemas.py
T
Steven 0c53aa7062 feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件
- alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT
- tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行)
- web: React18+Vite 骨架(登录、工作区、邀请落地页)
- android: Compose 骨架(登录、工作区列表)
- 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
2026-10-28 02:38:53 +08:00

78 lines
2.0 KiB
Python

"""统一响应体 {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)