feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
+2
View File
@@ -49,6 +49,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
settings=getattr(project, "settings", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
@@ -276,6 +277,7 @@ async def create_admin_project(
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
map_config=payload.map_config.model_dump(exclude_unset=True),
settings=payload.settings.model_dump(exclude_unset=True),
status=payload.status,
creator_user_id=current_user.id,
)
+1
View File
@@ -45,6 +45,7 @@ async def get_project_metadata(
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
settings=getattr(project, "settings", {}) or {},
status=project.status,
project_role=ctx.project_role,
)
+1
View File
@@ -320,6 +320,7 @@ async def provision_project(
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
settings=getattr(project, "settings", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
+18 -4
View File
@@ -567,10 +567,24 @@ async def query_operational_task_data(
if not await service.can_read_source(task_id, query.source):
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
snapshot = await read_task_data(
context=ctx, query=query, credential=authorization,
delegated=source_permission not in _task_permissions(ctx),
)
from app.services.scada_replay_queries import replay_snapshot_rows
replay_context = await service.replay_context(task_id)
if replay_context:
if query.source != "scada_readings":
raise HTTPException(403, "回放调查数据接口仅开放本轮 SCADA 快照,请通过 MCP 查询设备目录")
rows = await replay_snapshot_rows(conn, ctx.project_id, replay_context, query.device_ids,
query.start, query.end, 1001)
from app.services.content_digest import digest
from datetime import datetime, timezone
snapshot = TaskDataSnapshot(
source="scada_readings", source_ref=f"scada_replay:{replay_context['replay_id']}:{digest(rows)}",
queried_at=datetime.now(timezone.utc), origin="synthetic", data=rows[:1000], truncated=len(rows) > 1000,
)
else:
snapshot = await read_task_data(
context=ctx, query=query, credential=authorization,
delegated=source_permission not in _task_permissions(ctx),
)
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
except TaskDataError as exc:
+1
View File
@@ -35,6 +35,7 @@ async def get_project_info_endpoint(
gs_workspace=project_detail.gs_workspace,
map_extent=project_detail.map_extent,
map_config=getattr(project_detail, "map_config", {}) or {},
settings=getattr(project_detail, "settings", {}) or {},
status=project_detail.status,
project_role="viewer", # Default role for public access
)
+114
View File
@@ -0,0 +1,114 @@
"""SCADA replay controls. Manual steps are processed by the command request."""
from uuid import UUID
from datetime import datetime
from fastapi import APIRouter, Depends, Query, HTTPException
from app.auth.metadata_dependencies import get_metadata_repository
from app.auth.permissions import SCADA_VIEW, SCADA_CLEAN, require_permission, permissions_for_context
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection, get_project_timescale_connection
from app.domain.schemas.scada_replay import (
ScadaReplayCapabilities, ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot,
ScadaReplayEventsPage, ScadaReplayReadings,
)
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
from app.domain.schemas.project_settings import ProjectSettings
from app.services.scada_replay import ScadaReplayService, snapshot
router = APIRouter(prefix="/scada-replays", tags=["SCADA Replay"])
async def repository(
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
connection=Depends(get_project_pg_connection),
):
return ScadaReplayRepository(connection, ctx.project_id)
async def project_settings(
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
metadata_repo=Depends(get_metadata_repository),
) -> ProjectSettings:
project = await metadata_repo.get_project_by_id(ctx.project_id)
if project is None:
raise HTTPException(404, "当前项目不存在")
return ProjectSettings.model_validate(project.settings or {})
@router.get("/capabilities", response_model=ScadaReplayCapabilities)
async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_permission(SCADA_VIEW)),
settings=Depends(project_settings)):
available = await repo.available()
current = await repo.current() if available else None
monitoring = settings.scada_monitoring
monitoring_ready = bool(
monitoring
and len(monitoring.pressure_device_ids) >= 5
and monitoring.outlet_flow_device_ids
)
if monitoring_ready:
monitoring_reason = None
elif monitoring is None:
monitoring_reason = "项目尚未配置压力与出厂边界流量监测角色"
elif len(monitoring.pressure_device_ids) < 5:
monitoring_reason = "爆管侦测至少需要 5 个压力监测设备"
else:
monitoring_reason = "项目尚未配置出厂边界流量设备"
return ScadaReplayCapabilities(
available=available, can_write=SCADA_CLEAN in permissions_for_context(ctx),
reason=None if available else "需先部署 SCADA 回放存储结构",
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
burst_monitoring_ready=monitoring_ready,
burst_monitoring_reason=monitoring_reason,
current=snapshot(current) if current else None,
)
@router.post("", response_model=ScadaReplaySnapshot, status_code=201)
async def create_replay(command: ScadaReplayCreate, repo=Depends(repository),
ctx=Depends(require_permission(SCADA_CLEAN)), settings=Depends(project_settings)):
return await ScadaReplayService(repo, ctx).create(
command,
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
monitoring_config=settings.scada_monitoring,
)
@router.get("/{replay_id}", response_model=ScadaReplaySnapshot)
async def get_replay(replay_id: UUID, repo=Depends(repository)):
await ScadaReplayService(repo, None).ensure_storage()
return snapshot(await repo.get(replay_id))
@router.post("/{replay_id}/commands", response_model=ScadaReplaySnapshot)
async def replay_command(replay_id: UUID, command: ScadaReplayCommand, repo=Depends(repository),
ctx=Depends(require_permission(SCADA_CLEAN)),
timescale=Depends(get_project_timescale_connection)):
service = ScadaReplayService(repo, ctx, timescale)
result = await service.command(replay_id, command)
if command.action in {"step", "retry"} and result.step_pending:
return await service.process_pending_step(replay_id)
return result
@router.get("/{replay_id}/events", response_model=ScadaReplayEventsPage)
async def replay_events(replay_id: UUID, offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200),
repo=Depends(repository)):
await ScadaReplayService(repo, None).ensure_storage()
await repo.get(replay_id)
items = await repo.events(replay_id, offset, limit + 1)
return ScadaReplayEventsPage(items=items[:limit], next_offset=offset + limit if len(items) > limit else None)
@router.get("/{replay_id}/observations", response_model=ScadaReplayReadings)
async def replay_observations(replay_id: UUID, through: datetime, repo=Depends(repository)):
await ScadaReplayService(repo, None).ensure_storage()
document = await repo.get(replay_id)
if (through.tzinfo is None or not document.get("delivered_until")
or through > datetime.fromisoformat(document["delivered_until"])
or through < datetime.fromisoformat(document["start_time"])):
raise HTTPException(422, "回看仅允许本轮已读取的时间区间")
return ScadaReplayReadings(
items=await repo.latest_outputs(replay_id, through),
through=through,
)
+44 -1
View File
@@ -1,10 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
from typing import List
from datetime import datetime
from uuid import UUID
from psycopg import AsyncConnection
from pydantic import BaseModel, Field, field_validator
from app.domain.schemas.scada import ScadaLatestReadingsResponse
from app.domain.schemas.scada import ScadaLatestReadingsResponse, ScadaRawPage
from app.auth.project_dependencies import get_project_context, ProjectContext
from app.services.scada_raw import read_raw_page, validate_window
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from .dependencies import get_postgres_connection, get_timescale_connection
@@ -13,6 +16,46 @@ router = APIRouter()
SCADA_BATCH_MAX_ITEMS = 10_000
@router.get("/timeseries/scada-readings/raw", response_model=ScadaRawPage,
summary="按时间顺序分页读取 SCADA 原始值")
async def get_scada_raw(
start_time: datetime, end_time: datetime, device_ids: str,
limit: int = Query(500, ge=1, le=500),
cursor: str | None = Query(None, max_length=2048),
replay_id: UUID | None = None,
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
ctx: ProjectContext = Depends(get_project_context),
) -> ScadaRawPage:
try:
validate_window(start_time, end_time)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
ids = list(dict.fromkeys(item.strip() for item in device_ids.split(",") if item.strip()))
if not ids or len(ids) > 500:
raise HTTPException(422, "请选择 1 至 500 个 SCADA 设备")
existing = await ScadaInfoRepository.get_existing_device_ids(postgres_conn, ids)
if set(ids) != existing:
raise HTTPException(422, "查询包含当前项目未配置的 SCADA 设备")
try:
through = None
if replay_id is not None:
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
from app.services.scada_replay import ScadaReplayService
repo = ScadaReplayRepository(postgres_conn, ctx.project_id)
await ScadaReplayService(repo, ctx).ensure_storage()
replay = await repo.get(replay_id)
if not set(ids) <= set(replay["config"]["device_ids"]):
raise HTTPException(403, "设备超出回放范围")
if start_time < datetime.fromisoformat(replay["start_time"]) or end_time > datetime.fromisoformat(replay["end_time"]):
raise HTTPException(422, "查询超出选定回放区间")
through = datetime.fromisoformat(replay["virtual_now"])
return await read_raw_page(conn, project_id=str(ctx.project_id), device_ids=ids,
start=start_time, end=end_time, limit=limit, cursor=cursor, through=through)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
class ScadaReadingBatchItem(BaseModel):
time: datetime
device_id: str = Field(min_length=1)