Files

105 lines
3.5 KiB
Python

"""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",
]