Files
next-tjwater-server/app/api/v1/endpoints/timeseries/playback.py
T
jiang cac43ad734
Generic Container CI/CD / test-build-publish (push) Failing after 2m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m11s
fix(api): synchronize hydraulic playback binary contract
2026-11-15 23:43:13 +08:00

74 lines
3.4 KiB
Python

"""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"})