diff --git a/app/api/v1/endpoints/project_data.py b/app/api/v1/endpoints/project_data.py index d4706d3..02fa3ce 100644 --- a/app/api/v1/endpoints/project_data.py +++ b/app/api/v1/endpoints/project_data.py @@ -3,7 +3,6 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query from psycopg import AsyncConnection -from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.postgresql.analysis import AnalysisRepository from app.auth.project_dependencies import get_project_pg_connection @@ -17,24 +16,6 @@ async def get_database_connection( yield conn -@router.get("/scada-info/database-view", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息") -async def get_scada_info_with_connection( - conn: AsyncConnection = Depends(get_database_connection), -): - """ - 获取所有SCADA信息 - - 返回项目中所有的SCADA设备信息 - """ - try: - scada_data = await ScadaInfoRepository.get_scadas(conn) - return {"success": True, "data": scada_data, "count": len(scada_data)} - except Exception as e: - raise HTTPException( - status_code=500, detail=f"查询SCADA信息时发生错误: {str(e)}" - ) - - @router.get("/analysis/runs", summary="获取分析运行列表") async def get_analysis_runs( conn: AsyncConnection = Depends(get_database_connection), diff --git a/app/api/v1/endpoints/scada.py b/app/api/v1/endpoints/scada.py index c050c46..4486d3e 100644 --- a/app/api/v1/endpoints/scada.py +++ b/app/api/v1/endpoints/scada.py @@ -1,34 +1,42 @@ from typing import Any -from fastapi import APIRouter, Query +from fastapi import APIRouter, Depends, HTTPException +from psycopg import AsyncConnection -from app.services.tjnetwork import ( - get_all_scada_info, - get_scada_info, - get_scada_info_schema, -) +from app.auth.project_dependencies import get_project_pg_connection +from app.domain.schemas.scada import ScadaDeviceResponse +from app.infra.db.postgresql.scada import ScadaInfoRepository, get_scada_info_schema router = APIRouter() @router.get("/network-schemas/scada-device", summary="获取 SCADA 设备结构") -def get_scada_device_schema( - network: str = Query(..., description="管网名称(或数据库名称)"), -) -> dict[str, dict[str, Any]]: - return get_scada_info_schema(network) +def get_scada_device_schema() -> dict[str, dict[str, Any]]: + return get_scada_info_schema("") -@router.get("/scada-devices", summary="获取 SCADA 设备列表") -def get_scada_devices( - network: str = Query(..., description="管网名称(或数据库名称)"), +@router.get( + "/scada-devices", + summary="获取 SCADA 设备列表", + response_model=list[ScadaDeviceResponse], +) +async def get_scada_devices( + conn: AsyncConnection = Depends(get_project_pg_connection), ) -> list[dict[str, Any]]: - return get_all_scada_info(network) + return await ScadaInfoRepository.get_scadas(conn) -@router.get("/scada-devices/detail", summary="获取 SCADA 设备") -def get_scada_device( - network: str = Query(..., description="管网名称(或数据库名称)"), - device_id: str = Query(..., description="SCADA 设备 ID"), +@router.get( + "/scada-devices/{device_id}", + summary="获取 SCADA 设备", + response_model=ScadaDeviceResponse, +) +async def get_scada_device( + device_id: str, + conn: AsyncConnection = Depends(get_project_pg_connection), ) -> dict[str, Any]: - return get_scada_info(network, device_id) + device = await ScadaInfoRepository.get_scada(conn, device_id) + if device is None: + raise HTTPException(status_code=404, detail="SCADA 设备不存在") + return device diff --git a/app/domain/schemas/scada.py b/app/domain/schemas/scada.py new file mode 100644 index 0000000..1a68fa1 --- /dev/null +++ b/app/domain/schemas/scada.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + + +class ScadaDeviceResponse(BaseModel): + """Project SCADA metadata keyed by the canonical device identifier.""" + + device_id: str + device_type: str + node_id: str | None = None + link_id: str | None = None + api_query_id: str | None = None + transmission_mode: str + transmission_frequency: str + reliability: int | None = None + x: float | None = None + y: float | None = None + longitude: float | None = None + latitude: float | None = None diff --git a/app/infra/db/postgresql/scada.py b/app/infra/db/postgresql/scada.py index 6fdea18..3eafe97 100644 --- a/app/infra/db/postgresql/scada.py +++ b/app/infra/db/postgresql/scada.py @@ -9,7 +9,9 @@ from app.native.wndb.core.database import read_all, try_read _SCADA_VIEW_SELECT = """ SELECT id AS device_id, device_type, node_id, link_id, api_query_id, - transmission_mode, transmission_frequency, reliability, x, y + transmission_mode, transmission_frequency, reliability, x, y, + ST_X(ST_Transform(geom, 4326)) AS longitude, + ST_Y(ST_Transform(geom, 4326)) AS latitude FROM gis.scada_devices """ @@ -60,6 +62,8 @@ def _device(record: dict[str, Any]) -> dict[str, Any]: "reliability": _optional_int(record["reliability"]), "x": _optional_float(record["x"]), "y": _optional_float(record["y"]), + "longitude": _optional_float(record["longitude"]), + "latitude": _optional_float(record["latitude"]), } @@ -76,6 +80,18 @@ class ScadaInfoRepository: return [_device(record) for record in records] + @staticmethod + async def get_scada( + conn: AsyncConnection, device_id: str + ) -> dict[str, Any] | None: + async with conn.cursor() as cur: + await cur.execute( + _SCADA_VIEW_SELECT + " WHERE id = %s", + (device_id,), + ) + record = await cur.fetchone() + return _device(record) if record else None + @staticmethod async def get_existing_device_ids( conn: AsyncConnection, device_ids: list[str] @@ -102,6 +118,8 @@ def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]: "reliability": {"type": "int", "optional": False, "readonly": True}, "x": {"type": "float", "optional": True, "readonly": True}, "y": {"type": "float", "optional": True, "readonly": True}, + "longitude": {"type": "float", "optional": True, "readonly": True}, + "latitude": {"type": "float", "optional": True, "readonly": True}, } diff --git a/contracts/manifest.json b/contracts/manifest.json index 5d6f350..159b90e 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "b6640fa87909c9a8a8747e9003ace35ea6a209f99f6e1dc15cb0bd5c313dc7c3" + "sha256": "f07ecfa4843955bd63a4cdf0d7d336d593f41725a8abc1b0da6cce7ff4fff982" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index eb55f7d..8387923 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -1502,6 +1502,37 @@ "title": "Page[ProjectSummaryResponse]", "type": "object" }, + "Page_ScadaDeviceResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ScadaDeviceResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[ScadaDeviceResponse]", + "type": "object" + }, "Page_SensorPlacementSchemeResponse_": { "properties": { "items": { @@ -2224,6 +2255,123 @@ "title": "RunSimulationManuallyByDateRest", "type": "object" }, + "ScadaDeviceResponse": { + "description": "Project SCADA metadata keyed by the canonical device identifier.", + "properties": { + "api_query_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Query Id" + }, + "device_id": { + "title": "Device Id", + "type": "string" + }, + "device_type": { + "title": "Device Type", + "type": "string" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "link_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Link Id" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "node_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Node Id" + }, + "reliability": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reliability" + }, + "transmission_frequency": { + "title": "Transmission Frequency", + "type": "string" + }, + "transmission_mode": { + "title": "Transmission Mode", + "type": "string" + }, + "x": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Y" + } + }, + "required": [ + "device_id", + "device_type", + "transmission_mode", + "transmission_frequency" + ], + "title": "ScadaDeviceResponse", + "type": "object" + }, "ScadaReadingBatchItem": { "properties": { "cleaned_value": { @@ -26515,7 +26663,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Page_dict_str__Any__" + "$ref": "#/components/schemas/Page_ScadaDeviceResponse_" } } }, @@ -26593,17 +26741,15 @@ ] } }, - "/api/v1/scada-devices/detail": { + "/api/v1/scada-devices/{device_id}": { "get": { - "operationId": "get_scada_devices_detail", + "operationId": "get_scada_devices_device_id", "parameters": [ { - "description": "SCADA 设备 ID", - "in": "query", + "in": "path", "name": "device_id", "required": true, "schema": { - "description": "SCADA 设备 ID", "title": "Device Id", "type": "string" } @@ -26623,8 +26769,7 @@ "content": { "application/json": { "schema": { - "title": "Response Get Scada Devices Detail", - "type": "object" + "$ref": "#/components/schemas/ScadaDeviceResponse" } } }, @@ -26702,104 +26847,6 @@ ] } }, - "/api/v1/scada-info/database-view": { - "get": { - "description": "使用连接池查询所有SCADA信息", - "operationId": "get_scada_info_database_view", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "description": "Successful Response" - }, - "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": "获取SCADA信息", - "tags": [ - "Project Data" - ] - } - }, "/api/v1/scada-properties": { "get": { "description": "获取指定SCADA点的属性信息", diff --git a/tests/integration/test_database_pooling_live.py b/tests/integration/test_database_pooling_live.py index 8a1534b..5542cbd 100644 --- a/tests/integration/test_database_pooling_live.py +++ b/tests/integration/test_database_pooling_live.py @@ -8,6 +8,7 @@ from psycopg import connect from app.core.config import get_pgconn_string from app.infra.db.dynamic_manager import ProjectConnectionManager +from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.timescaledb.sync_pool import timescale_connection from app.native.wndb.commands.api import delete_pattern_cascade from app.native.wndb.core.connection import project_connection, project_transaction @@ -105,6 +106,29 @@ def test_dynamic_pool_replaces_terminated_idle_connection() -> None: asyncio.run(exercise_pool()) +def test_scada_api_repository_reads_device_ids_through_dynamic_pool() -> None: + async def read_scada_devices() -> list[dict]: + manager = ProjectConnectionManager() + try: + async with manager.pg_connection( + uuid4(), "biz_data", get_pgconn_string(db_name=PROJECT), 1, 4 + ) as conn: + return await ScadaInfoRepository.get_scadas(conn) + finally: + await manager.close_all() + + devices = asyncio.run(read_scada_devices()) + + assert devices + device_ids = [device["device_id"] for device in devices] + assert len(device_ids) == len(set(device_ids)) + assert all(device_ids) + assert all( + device["longitude"] is not None and device["latitude"] is not None + for device in devices + ) + + def test_nested_wndb_writes_roll_back_as_one_transaction() -> None: with pytest.raises(RuntimeError, match="force rollback"): with project_transaction(PROJECT) as conn: diff --git a/tests/unit/test_postgres_scada_repository.py b/tests/unit/test_postgres_scada_repository.py index 174a568..bd0ef93 100644 --- a/tests/unit/test_postgres_scada_repository.py +++ b/tests/unit/test_postgres_scada_repository.py @@ -17,8 +17,9 @@ class _FakeCursor: async def __aexit__(self, exc_type, exc, tb): return False - async def execute(self, query): + async def execute(self, query, params=None): self.query = query + self.params = params async def fetchall(self): return [ @@ -33,9 +34,14 @@ class _FakeCursor: "reliability": "95", "x": "117.1", "y": "32.9", + "longitude": "121.5", + "latitude": "30.9", } ] + async def fetchone(self): + return (await self.fetchall())[0] + class _FakeConnection: def __init__(self): @@ -62,11 +68,25 @@ def test_get_scadas_normalizes_id_and_type(): "reliability": 95, "x": 117.1, "y": 32.9, + "longitude": 121.5, + "latitude": 30.9, } ] assert "node_id" in conn.cursor_instance.query assert "link_id" in conn.cursor_instance.query assert "FROM gis.scada_devices" in conn.cursor_instance.query + assert "ST_Transform(geom, 4326)" in conn.cursor_instance.query + + +def test_get_scada_filters_the_project_view_by_device_id(): + conn = _FakeConnection() + + result = asyncio.run(ScadaInfoRepository.get_scada(conn, "25470001")) + + assert result is not None + assert result["device_id"] == "25470001" + assert "WHERE id = %s" in conn.cursor_instance.query + assert conn.cursor_instance.params == ("25470001",) def test_realtime_element_mappings_are_project_local_and_immutable(monkeypatch): diff --git a/tests/unit/test_project_scada_metadata.py b/tests/unit/test_project_scada_metadata.py index bf03748..57650b8 100644 --- a/tests/unit/test_project_scada_metadata.py +++ b/tests/unit/test_project_scada_metadata.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock from uuid import uuid4 -from app.api.v1.endpoints import project_data +from app.api.v1.endpoints import scada as scada_endpoint from app.services import timeseries_analysis as composite_queries @@ -18,6 +18,8 @@ PROJECT_SCADA = { "reliability": 1.0, "x": 117.1, "y": 32.9, + "longitude": 121.5, + "latitude": 30.9, } START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc) END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc) @@ -128,11 +130,11 @@ def test_element_scada_query_uses_current_project_metadata(monkeypatch): def test_scada_info_endpoint_uses_current_project_connection(monkeypatch): monkeypatch.setattr( - project_data.ScadaInfoRepository, + scada_endpoint.ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[PROJECT_SCADA.copy()]), ) - result = asyncio.run(project_data.get_scada_info_with_connection(object())) + result = asyncio.run(scada_endpoint.get_scada_devices(object())) - assert result == {"success": True, "data": [PROJECT_SCADA], "count": 1} + assert result == [PROJECT_SCADA]