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 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),
+27 -19
View File
@@ -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
+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 = """
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},
}
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "b6640fa87909c9a8a8747e9003ace35ea6a209f99f6e1dc15cb0bd5c313dc7c3"
"sha256": "f07ecfa4843955bd63a4cdf0d7d336d593f41725a8abc1b0da6cce7ff4fff982"
}
}
}
+153 -106
View File
@@ -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点的属性信息",
@@ -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:
+21 -1
View File
@@ -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):
+6 -4
View File
@@ -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]