diff --git a/app/api/v1/endpoints/timeseries/playback.py b/app/api/v1/endpoints/timeseries/playback.py index 8129f67..a2c749d 100644 --- a/app/api/v1/endpoints/timeseries/playback.py +++ b/app/api/v1/endpoints/timeseries/playback.py @@ -13,6 +13,8 @@ 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() @@ -36,7 +38,8 @@ async def geometry_for_project(ctx: ProjectContext, conn: AsyncConnection) -> Pl return geometry -@router.get("/geometry", summary="读取带版本的水力专题层静态几何") +@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: @@ -47,7 +50,8 @@ async def playback_geometry(ctx: ProjectContext = Depends(get_project_context), headers={"Cache-Control": "private, no-store", "ETag": f'"{geometry.version}"'}) -@router.get("/frames", summary="读取索引一致的 Float32 水力时间块") +@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), diff --git a/contracts/manifest.json b/contracts/manifest.json index 92b6543..ddd981b 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "9801087d40ac0e1f67871f9a1db850cbcce47614cceec0a7e8771cdf58385f53" + "sha256": "04aeed6f1a7414b37267d84141d036d00d2f8579ee28af37590bd4b92137e712" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index a9c25fa..d03cf83 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -50912,6 +50912,246 @@ ] } }, + "/api/v1/timeseries/realtime/playback/frames": { + "get": { + "operationId": "get_timeseries_realtime_playback_frames", + "parameters": [ + { + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "title": "Start Time", + "type": "string" + } + }, + { + "in": "query", + "name": "model_version", + "required": true, + "schema": { + "maxLength": 24, + "minLength": 24, + "title": "Model Version", + "type": "string" + } + }, + { + "in": "query", + "name": "frame_count", + "required": false, + "schema": { + "default": 4, + "maximum": 8, + "minimum": 1, + "title": "Frame Count", + "type": "integer" + } + }, + { + "in": "query", + "name": "step_seconds", + "required": false, + "schema": { + "default": 900, + "maximum": 3600, + "minimum": 60, + "title": "Step Seconds", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Versioned little-endian Float32 packet" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "读取索引一致的 Float32 水力时间块", + "tags": [ + "TimescaleDB - Hydraulic Playback" + ] + } + }, + "/api/v1/timeseries/realtime/playback/geometry": { + "get": { + "operationId": "get_timeseries_realtime_playback_geometry", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Versioned little-endian Float32 packet" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "读取带版本的水力专题层静态几何", + "tags": [ + "TimescaleDB - Hydraulic Playback" + ] + } + }, "/api/v1/timeseries/realtime/records": { "get": { "description": "查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", diff --git a/tests/api/test_hydraulic_playback_endpoints.py b/tests/api/test_hydraulic_playback_endpoints.py index 6a9d098..f362099 100644 --- a/tests/api/test_hydraulic_playback_endpoints.py +++ b/tests/api/test_hydraulic_playback_endpoints.py @@ -77,3 +77,10 @@ def test_cache_separates_projects_and_database_routes(monkeypatch): asyncio.run(run()) assert calls == ["one", "two", "one"] playback._cache.clear() + + +def test_contract_advertises_binary_packets_instead_of_json(): + schema = build_test_app(playback.router, "/api/v1").openapi() + for suffix in ("geometry", "frames"): + content = schema["paths"][f"/api/v1/timeseries/realtime/playback/{suffix}"]["get"]["responses"]["200"]["content"] + assert content == {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}