105 lines
3.5 KiB
Python
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",
|
|
]
|