feat(api): add compact hydraulic playback endpoints
Generic Container CI/CD / test-build-publish (push) Failing after 34s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 34s

This commit is contained in:
2026-11-15 23:36:29 +08:00
parent dd31f7c159
commit f5f972bbec
5 changed files with 427 additions and 0 deletions
@@ -0,0 +1,69 @@
"""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")
# 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="读取带版本的水力专题层静态几何")
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 水力时间块")
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"})