"""Project-authorized compact hydraulic playback transport.""" from collections import OrderedDict import asyncio import time from fastapi import APIRouter, Depends, HTTPException, Query, Response from psycopg import AsyncConnection from app.auth.project_dependencies import ProjectContext, get_project_context, get_project_pg_connection from app.services.hydraulic_playback import MAX_FRAME_COUNT, PlaybackGeometry, read_frames, read_geometry from app.domain.time import parse_utc_time from .dependencies import get_timescale_connection router = APIRouter(prefix="/timeseries/realtime/playback") BINARY_RESPONSE = {200: {"description": "Versioned little-endian Float32 packet", "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}} # Bounded per-process cache, private to the authorized project and database route. _cache: OrderedDict[tuple, tuple[float, PlaybackGeometry]] = OrderedDict() _load_lock = asyncio.Lock() async def geometry_for_project(ctx: ProjectContext, conn: AsyncConnection) -> PlaybackGeometry: key = (str(ctx.project_id), conn.info.host, conn.info.port, conn.info.dbname) cached = _cache.get(key) if cached and time.monotonic() - cached[0] < 60: _cache.move_to_end(key) return cached[1] async with _load_lock: cached = _cache.get(key) if cached and time.monotonic() - cached[0] < 60: return cached[1] geometry = await read_geometry(conn) _cache[key] = (time.monotonic(), geometry) _cache.move_to_end(key) while len(_cache) > 4: _cache.popitem(last=False) return geometry @router.get("/geometry", summary="读取带版本的水力专题层静态几何", response_class=Response, responses=BINARY_RESPONSE) async def playback_geometry(ctx: ProjectContext = Depends(get_project_context), conn: AsyncConnection = Depends(get_project_pg_connection)): try: geometry = await geometry_for_project(ctx, conn) except ValueError as exc: raise HTTPException(422, str(exc)) from exc return Response(geometry.content, media_type="application/octet-stream", headers={"Cache-Control": "private, no-store", "ETag": f'"{geometry.version}"'}) @router.get("/frames", summary="读取索引一致的 Float32 水力时间块", response_class=Response, responses=BINARY_RESPONSE) async def playback_frames( start_time: str, model_version: str = Query(min_length=24, max_length=24), frame_count: int = Query(default=4, ge=1, le=MAX_FRAME_COUNT), step_seconds: int = Query(default=900, ge=60, le=3600), ctx: ProjectContext = Depends(get_project_context), pg_conn: AsyncConnection = Depends(get_project_pg_connection), ts_conn: AsyncConnection = Depends(get_timescale_connection), ): try: start = parse_utc_time(start_time, field_name="start_time") geometry = await geometry_for_project(ctx, pg_conn) if geometry.version != model_version: raise HTTPException(409, "管网模型已更新,请重新加载专题层") content = await read_frames(ts_conn, geometry, start, frame_count, step_seconds) except ValueError as exc: raise HTTPException(422, str(exc)) from exc return Response(content, media_type="application/octet-stream", headers={"Cache-Control": "private, no-store"})