feat(scada): serve project devices through pooled API

This commit is contained in:
2026-09-08 18:18:30 +08:00
parent 5966d039de
commit 8942541759
9 changed files with 269 additions and 151 deletions
-19
View File
@@ -3,7 +3,6 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection from psycopg import AsyncConnection
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.postgresql.analysis import AnalysisRepository from app.infra.db.postgresql.analysis import AnalysisRepository
from app.auth.project_dependencies import get_project_pg_connection from app.auth.project_dependencies import get_project_pg_connection
@@ -17,24 +16,6 @@ async def get_database_connection(
yield conn 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="获取分析运行列表") @router.get("/analysis/runs", summary="获取分析运行列表")
async def get_analysis_runs( async def get_analysis_runs(
conn: AsyncConnection = Depends(get_database_connection), conn: AsyncConnection = Depends(get_database_connection),
+27 -19
View File
@@ -1,34 +1,42 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Query from fastapi import APIRouter, Depends, HTTPException
from psycopg import AsyncConnection
from app.services.tjnetwork import ( from app.auth.project_dependencies import get_project_pg_connection
get_all_scada_info, from app.domain.schemas.scada import ScadaDeviceResponse
get_scada_info, from app.infra.db.postgresql.scada import ScadaInfoRepository, get_scada_info_schema
get_scada_info_schema,
)
router = APIRouter() router = APIRouter()
@router.get("/network-schemas/scada-device", summary="获取 SCADA 设备结构") @router.get("/network-schemas/scada-device", summary="获取 SCADA 设备结构")
def get_scada_device_schema( def get_scada_device_schema() -> dict[str, dict[str, Any]]:
network: str = Query(..., description="管网名称(或数据库名称)"), return get_scada_info_schema("")
) -> dict[str, dict[str, Any]]:
return get_scada_info_schema(network)
@router.get("/scada-devices", summary="获取 SCADA 设备列表") @router.get(
def get_scada_devices( "/scada-devices",
network: str = Query(..., description="管网名称(或数据库名称)"), summary="获取 SCADA 设备列表",
response_model=list[ScadaDeviceResponse],
)
async def get_scada_devices(
conn: AsyncConnection = Depends(get_project_pg_connection),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
return get_all_scada_info(network) return await ScadaInfoRepository.get_scadas(conn)
@router.get("/scada-devices/detail", summary="获取 SCADA 设备") @router.get(
def get_scada_device( "/scada-devices/{device_id}",
network: str = Query(..., description="管网名称(或数据库名称)"), summary="获取 SCADA 设备",
device_id: str = Query(..., description="SCADA 设备 ID"), response_model=ScadaDeviceResponse,
)
async def get_scada_device(
device_id: str,
conn: AsyncConnection = Depends(get_project_pg_connection),
) -> dict[str, Any]: ) -> 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
+18
View File
@@ -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
+19 -1
View File
@@ -9,7 +9,9 @@ from app.native.wndb.core.database import read_all, try_read
_SCADA_VIEW_SELECT = """ _SCADA_VIEW_SELECT = """
SELECT id AS device_id, device_type, node_id, link_id, api_query_id, 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 FROM gis.scada_devices
""" """
@@ -60,6 +62,8 @@ def _device(record: dict[str, Any]) -> dict[str, Any]:
"reliability": _optional_int(record["reliability"]), "reliability": _optional_int(record["reliability"]),
"x": _optional_float(record["x"]), "x": _optional_float(record["x"]),
"y": _optional_float(record["y"]), "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] 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 @staticmethod
async def get_existing_device_ids( async def get_existing_device_ids(
conn: AsyncConnection, device_ids: list[str] 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}, "reliability": {"type": "int", "optional": False, "readonly": True},
"x": {"type": "float", "optional": True, "readonly": True}, "x": {"type": "float", "optional": True, "readonly": True},
"y": {"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},
} }
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "b6640fa87909c9a8a8747e9003ace35ea6a209f99f6e1dc15cb0bd5c313dc7c3" "sha256": "f07ecfa4843955bd63a4cdf0d7d336d593f41725a8abc1b0da6cce7ff4fff982"
} }
} }
} }
+153 -106
View File
@@ -1502,6 +1502,37 @@
"title": "Page[ProjectSummaryResponse]", "title": "Page[ProjectSummaryResponse]",
"type": "object" "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_": { "Page_SensorPlacementSchemeResponse_": {
"properties": { "properties": {
"items": { "items": {
@@ -2224,6 +2255,123 @@
"title": "RunSimulationManuallyByDateRest", "title": "RunSimulationManuallyByDateRest",
"type": "object" "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": { "ScadaReadingBatchItem": {
"properties": { "properties": {
"cleaned_value": { "cleaned_value": {
@@ -26515,7 +26663,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "get": {
"operationId": "get_scada_devices_detail", "operationId": "get_scada_devices_device_id",
"parameters": [ "parameters": [
{ {
"description": "SCADA 设备 ID", "in": "path",
"in": "query",
"name": "device_id", "name": "device_id",
"required": true, "required": true,
"schema": { "schema": {
"description": "SCADA 设备 ID",
"title": "Device Id", "title": "Device Id",
"type": "string" "type": "string"
} }
@@ -26623,8 +26769,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"title": "Response Get Scada Devices Detail", "$ref": "#/components/schemas/ScadaDeviceResponse"
"type": "object"
} }
} }
}, },
@@ -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": { "/api/v1/scada-properties": {
"get": { "get": {
"description": "获取指定SCADA点的属性信息", "description": "获取指定SCADA点的属性信息",
@@ -8,6 +8,7 @@ from psycopg import connect
from app.core.config import get_pgconn_string from app.core.config import get_pgconn_string
from app.infra.db.dynamic_manager import ProjectConnectionManager 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.infra.db.timescaledb.sync_pool import timescale_connection
from app.native.wndb.commands.api import delete_pattern_cascade from app.native.wndb.commands.api import delete_pattern_cascade
from app.native.wndb.core.connection import project_connection, project_transaction 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()) 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: def test_nested_wndb_writes_roll_back_as_one_transaction() -> None:
with pytest.raises(RuntimeError, match="force rollback"): with pytest.raises(RuntimeError, match="force rollback"):
with project_transaction(PROJECT) as conn: with project_transaction(PROJECT) as conn:
+21 -1
View File
@@ -17,8 +17,9 @@ class _FakeCursor:
async def __aexit__(self, exc_type, exc, tb): async def __aexit__(self, exc_type, exc, tb):
return False return False
async def execute(self, query): async def execute(self, query, params=None):
self.query = query self.query = query
self.params = params
async def fetchall(self): async def fetchall(self):
return [ return [
@@ -33,9 +34,14 @@ class _FakeCursor:
"reliability": "95", "reliability": "95",
"x": "117.1", "x": "117.1",
"y": "32.9", "y": "32.9",
"longitude": "121.5",
"latitude": "30.9",
} }
] ]
async def fetchone(self):
return (await self.fetchall())[0]
class _FakeConnection: class _FakeConnection:
def __init__(self): def __init__(self):
@@ -62,11 +68,25 @@ def test_get_scadas_normalizes_id_and_type():
"reliability": 95, "reliability": 95,
"x": 117.1, "x": 117.1,
"y": 32.9, "y": 32.9,
"longitude": 121.5,
"latitude": 30.9,
} }
] ]
assert "node_id" in conn.cursor_instance.query assert "node_id" in conn.cursor_instance.query
assert "link_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 "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): def test_realtime_element_mappings_are_project_local_and_immutable(monkeypatch):
+6 -4
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from uuid import uuid4 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 from app.services import timeseries_analysis as composite_queries
@@ -18,6 +18,8 @@ PROJECT_SCADA = {
"reliability": 1.0, "reliability": 1.0,
"x": 117.1, "x": 117.1,
"y": 32.9, "y": 32.9,
"longitude": 121.5,
"latitude": 30.9,
} }
START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc) START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc)
END_TIME = datetime(2026, 6, 2, 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): def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
project_data.ScadaInfoRepository, scada_endpoint.ScadaInfoRepository,
"get_scadas", "get_scadas",
AsyncMock(return_value=[PROJECT_SCADA.copy()]), 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]