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
+34
View File
@@ -161,6 +161,14 @@ class OperationalTaskService:
)
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
if command.source.replay_context:
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
replay_context = command.source.replay_context
replay_doc = await ScadaReplayRepository(self.repo.connection, self.context.project_id).get(replay_context.replay_id)
require(replay_doc.get("delivered_until") is not None
and replay_context.knowledge_cutoff <= datetime.fromisoformat(replay_doc["delivered_until"])
and replay_context.batch_sequence <= replay_doc["batch_sequence"],
"REPLAY_SCOPE_INVALID", "回放调查不能使用尚未读取的批次", status=422)
body = _json(command)
request_id = str(command.request_id)
scope = "task:create"
@@ -219,6 +227,27 @@ class OperationalTaskService:
await self._task_event(task_document, now)
return await self.detail(task_id)
async def advance_replay_investigation(self, task_id: UUID, replay_context: dict, request_id: UUID) -> None:
"""A recovery is new evidence, coalesced through the existing run lifecycle."""
scope = f"task:{task_id}:replay-evidence"
async with self.repo.transaction():
if await self._request_replay(scope, request_id, replay_context):
return
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
prior = task["source"].get("replay_context")
require(prior and prior["replay_id"] == replay_context["replay_id"], "REPLAY_SCOPE_INVALID",
"Replay evidence belongs to another investigation", status=409)
require(datetime.fromisoformat(prior["knowledge_cutoff"]) <= datetime.fromisoformat(replay_context["knowledge_cutoff"]),
"REPLAY_SCOPE_INVALID", "Replay evidence cannot move backwards", status=409)
task["source"]["replay_context"] = replay_context
task["source"]["external_revision"] = task["source"].get("external_revision", 1) + 1
now = self._now()
await self._requeue_task(task, now)
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
await self._task_event(task, now)
await self._save_request(scope, request_id, replay_context, {"task_id": str(task_id)})
async def list(
self, *, task_type: str | None, status: str | None, limit: int, offset: int,
peripheral_work_order_id: UUID | None = None,
@@ -336,6 +365,9 @@ class OperationalTaskService:
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
)
async def replay_context(self, task_id: UUID) -> dict | None:
return (await self._task(task_id)).get("source", {}).get("replay_context")
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
task = await self._task(task_id)
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
@@ -1037,6 +1069,8 @@ class OperationalTaskService:
await self.repo.lock(f"run:{command.run_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
require(not task.get("source", {}).get("replay_context"), "REPLAY_ACTION_DISABLED",
"SCADA 回放调查仅生成分析与报告,不执行真实工单或设备动作", status=409)
run = await self._active_run(task)
require(run is not None and str(run["run_id"]) == str(command.run_id)
and run["generation"] == task["execution_generation"],