"""Shared time bounds for expensive administrator analytics reads. The administrative audit, leaderboard, and metric pages are intentionally a recent operational view rather than an unbounded reporting warehouse. Keep the policy in one small module so every route (including downloads) applies the same rolling window before it reaches SQL. """ from __future__ import annotations from datetime import datetime, time, timedelta from fastapi import HTTPException from deerflow.persistence.types import BEIJING_TZ ADMIN_QUERY_WINDOW_DAYS = 30 ADMIN_AUDIT_RETENTION_DAYS = 90 def _as_beijing(value: datetime) -> datetime: """Treat timezone-free query parameters as local Beijing time.""" if value.tzinfo is None: return value.replace(tzinfo=BEIJING_TZ) return value.astimezone(BEIJING_TZ) def _parse_datetime(value: str, *, field_name: str) -> datetime: text = value.strip() if not text: raise HTTPException(status_code=422, detail=f"{field_name} must be an ISO 8601 datetime") if text.endswith("Z"): text = text[:-1] + "+00:00" try: return _as_beijing(datetime.fromisoformat(text)) except ValueError as exc: raise HTTPException( status_code=422, detail=f"{field_name} must be an ISO 8601 datetime", ) from exc def resolve_admin_query_window( *, days: int = ADMIN_QUERY_WINDOW_DAYS, since: str | None = None, until: str | None = None, now: datetime | None = None, ) -> tuple[datetime, datetime, int]: """Return a validated, at-most-30-calendar-day recent query range. Hand-crafted URLs cannot bypass this guard: the whole range must fall in the rolling 30-day window, not merely have a duration shorter than 30 days. A future ``until`` is reduced to the current instant so normal date inputs that represent the rest of today remain convenient. """ if not 1 <= days <= ADMIN_QUERY_WINDOW_DAYS: raise HTTPException( status_code=422, detail=f"管理员查询仅支持最近 {ADMIN_QUERY_WINDOW_DAYS} 天的数据", ) current = _as_beijing(now or datetime.now(BEIJING_TZ)) end = _parse_datetime(until, field_name="until") if until else current if end > current: end = current earliest_date = current.date() - timedelta(days=ADMIN_QUERY_WINDOW_DAYS - 1) default_start_date = end.date() - timedelta(days=days - 1) start = ( _parse_datetime(since, field_name="since") if since else datetime.combine(default_start_date, time.min, tzinfo=BEIJING_TZ) ) if start > end: raise HTTPException(status_code=422, detail="since must be before until") if start.date() < earliest_date or end.date() < earliest_date: raise HTTPException( status_code=422, detail=f"管理员查询仅支持最近 {ADMIN_QUERY_WINDOW_DAYS} 天的数据", ) range_days = (end.date() - start.date()).days + 1 if range_days > ADMIN_QUERY_WINDOW_DAYS: raise HTTPException( status_code=422, detail=f"管理员查询仅支持最近 {ADMIN_QUERY_WINDOW_DAYS} 天的数据", ) return start, end, range_days def recent_admin_query_cutoff(*, now: datetime | None = None) -> datetime: """Return the inclusive earliest timestamp in the current admin window.""" start, _end, _days = resolve_admin_query_window(now=now) return start __all__ = [ "ADMIN_AUDIT_RETENTION_DAYS", "ADMIN_QUERY_WINDOW_DAYS", "recent_admin_query_cutoff", "resolve_admin_query_window", ]