feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
"""PostgreSQL persistence isolated to peripheral_sample, never existing business writes."""
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import isfinite
|
||||
from typing import Any, Iterator
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.domain.schemas.shared import TimeRange
|
||||
from app.services.content_digest import digest
|
||||
|
||||
from .errors import SampleFailure, ensure
|
||||
from .auth import Identity
|
||||
from .config import SampleSettings
|
||||
from .schemas import (
|
||||
AddFeedback, CreateTask, CreateTestDataRecord, CreateWorkOrder, DeleteResource,
|
||||
DemoContent, ScenarioCommand, Task, TestDataRecord, Transition,
|
||||
UpdateTestDataRecord, UpdateWorkOrder, WorkOrder,
|
||||
)
|
||||
|
||||
TRANSITIONS = {"pending": ["processing"], "processing": ["pending_review"],
|
||||
"pending_review": ["processing", "completed"], "completed": []}
|
||||
|
||||
|
||||
class PostgresStore:
|
||||
def __init__(self, settings: SampleSettings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
@contextmanager
|
||||
def connection(self, *, readonly: bool = False, timeseries: bool = False) -> Iterator[psycopg.Connection]:
|
||||
from app.core.config import settings as product
|
||||
ensure(not timeseries or readonly, "READ_ONLY_SOURCE", "历史监测源仅允许只读连接", 403)
|
||||
prefix = "TIMESCALEDB_DB_" if timeseries else "DB_"
|
||||
try:
|
||||
with psycopg.connect(host=getattr(product, prefix + "HOST"), port=getattr(product, prefix + "PORT"),
|
||||
user=getattr(product, prefix + "USER"), password=getattr(product, prefix + "PASSWORD"),
|
||||
dbname=self.settings.db_name, connect_timeout=5,
|
||||
options="-c statement_timeout=5000", row_factory=dict_row) as conn:
|
||||
if readonly:
|
||||
conn.execute("SET TRANSACTION READ ONLY")
|
||||
actual = conn.execute("SELECT current_database() AS name").fetchone()["name"]
|
||||
ensure(actual == self.settings.db_name, "DATABASE_MISMATCH", "样板数据库不匹配", 503)
|
||||
yield conn
|
||||
except (psycopg.errors.UndefinedTable, psycopg.errors.InvalidSchemaName) as exc:
|
||||
raise SampleFailure("STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503) from exc
|
||||
except psycopg.Error as exc:
|
||||
# Do not leak connection strings, SQL, passwords or business values.
|
||||
raise SampleFailure("STORAGE_UNAVAILABLE", "样板数据库不可用,请查询请求回执再重试", 503, effect="unknown" if not readonly else "none") from exc
|
||||
|
||||
def _scenario(self, conn, who):
|
||||
row = conn.execute("SELECT revision,scenario FROM peripheral_sample.scenarios WHERE project_id=%s",
|
||||
(who.project_id,)).fetchone()
|
||||
state = dict(row) if row else {"revision": 1, "scenario": "normal"}
|
||||
if not self.settings.enable_faults:
|
||||
state["scenario"] = "normal"
|
||||
return state
|
||||
|
||||
def managed_resource_capabilities(self) -> dict[str, str]:
|
||||
"""Report optional test-resource storage without touching product data."""
|
||||
with self.connection(readonly=True) as conn:
|
||||
row = conn.execute("""SELECT
|
||||
to_regclass('peripheral_sample.requests') IS NOT NULL AS requests,
|
||||
to_regclass('peripheral_sample.scenarios') IS NOT NULL AS scenarios,
|
||||
to_regclass('peripheral_sample.work_orders') IS NOT NULL AS work_orders,
|
||||
to_regclass('peripheral_sample.work_order_revisions') IS NOT NULL AS work_order_revisions,
|
||||
to_regclass('peripheral_sample.test_data_records') IS NOT NULL AS test_data_records,
|
||||
to_regclass('peripheral_sample.test_data_record_revisions') IS NOT NULL AS test_data_record_revisions
|
||||
""").fetchone()
|
||||
common = row["requests"] and row["scenarios"]
|
||||
return {
|
||||
"work_orders": "available" if common and row["work_orders"] and row["work_order_revisions"]
|
||||
else "not_configured",
|
||||
"test_data": "available" if common and row["test_data_records"] and row["test_data_record_revisions"]
|
||||
else "not_configured",
|
||||
}
|
||||
|
||||
def scenario(self, who: Identity) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
return self._scenario(conn, who)
|
||||
|
||||
def _available(self, conn, who):
|
||||
scenario = self._scenario(conn, who)["scenario"]
|
||||
ensure(scenario != "unavailable", "SCENARIO_UNAVAILABLE", "故障演练:外围服务不可用", 503)
|
||||
return scenario
|
||||
|
||||
def _cached(self, conn, who, key, command):
|
||||
payload = command.model_dump(mode="json", exclude={"request_id"})
|
||||
request_digest = digest({"command": key, "payload": payload})
|
||||
row = conn.execute("""SELECT command_key,request_digest,result FROM peripheral_sample.requests
|
||||
WHERE project_id=%s AND actor_id=%s AND request_id=%s""",
|
||||
(who.project_id, who.user_id, command.request_id)).fetchone()
|
||||
if row:
|
||||
ensure(row["command_key"] == key and row["request_digest"] == request_digest,
|
||||
"IDEMPOTENCY_CONFLICT", "同一 request_id 不能用于不同命令或载荷", 409)
|
||||
return row["result"], request_digest
|
||||
return None, request_digest
|
||||
|
||||
def replay(self, who: Identity, key: str, command: CreateTask) -> dict[str, Any] | None:
|
||||
with self.connection(readonly=True) as conn:
|
||||
return self._cached(conn, who, key, command)[0]
|
||||
|
||||
def _mutate(self, who, key, command, operation, *, control=False):
|
||||
ensure(who.can_write, "PERMISSION_DENIED", "没有样板修改权限", 403)
|
||||
lost = False
|
||||
try:
|
||||
with self.connection() as conn:
|
||||
conn.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
|
||||
("peripheral-sample:" + who.project_id,))
|
||||
cached, request_digest = self._cached(conn, who, key, command)
|
||||
if cached is not None:
|
||||
return cached
|
||||
scenario = "normal" if control else self._available(conn, who)
|
||||
result = operation(conn, scenario)
|
||||
task_id = result.get("task_id")
|
||||
conn.execute("""INSERT INTO peripheral_sample.requests
|
||||
(project_id,actor_id,request_id,command_key,request_digest,task_id,result)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(who.project_id, who.user_id, command.request_id, key, request_digest, task_id, Jsonb(result)))
|
||||
lost = scenario == "response_lost" and key == "create_task"
|
||||
except SampleFailure as exc:
|
||||
exc.request_id = str(command.request_id)
|
||||
raise
|
||||
if lost:
|
||||
raise SampleFailure("RESPONSE_LOST", "故障演练:写入已提交但响应未知,请按 request_id 查询回执",
|
||||
503, effect="unknown", request_id=command.request_id)
|
||||
return result
|
||||
|
||||
def set_scenario(self, who: Identity, command: ScenarioCommand) -> dict[str, Any]:
|
||||
ensure(command.scenario == "normal" or self.settings.enable_faults,
|
||||
"FAULTS_DISABLED", "故障注入未由服务端配置启用", 403)
|
||||
def write(conn, _):
|
||||
state = {"revision": self._scenario(conn, who)["revision"] + 1, "scenario": command.scenario}
|
||||
conn.execute("""INSERT INTO peripheral_sample.scenarios(project_id,revision,scenario) VALUES (%s,%s,%s)
|
||||
ON CONFLICT(project_id) DO UPDATE SET revision=excluded.revision,scenario=excluded.scenario""",
|
||||
(who.project_id, state["revision"], state["scenario"]))
|
||||
return state
|
||||
return self._mutate(who, "scenario", command, write, control=True)
|
||||
|
||||
def _task(self, conn, who, task_id):
|
||||
row = conn.execute("SELECT document FROM peripheral_sample.tasks WHERE project_id=%s AND task_id=%s",
|
||||
(who.project_id, task_id)).fetchone()
|
||||
ensure(row is not None, "TASK_NOT_FOUND", "样板核查任务不存在", 404)
|
||||
return row["document"]
|
||||
|
||||
def _save(self, conn, who, document):
|
||||
document = Task.model_validate(document).model_dump(mode="json")
|
||||
conn.execute("""INSERT INTO peripheral_sample.tasks(project_id,task_id,revision,source_task_id,document)
|
||||
VALUES (%s,%s,%s,%s,%s) ON CONFLICT(project_id,task_id)
|
||||
DO UPDATE SET revision=excluded.revision,document=excluded.document""",
|
||||
(who.project_id, document["task_id"], document["revision"], document["source"]["task_id"], Jsonb(document)))
|
||||
conn.execute("""INSERT INTO peripheral_sample.revisions(project_id,task_id,revision,document)
|
||||
VALUES (%s,%s,%s,%s)""", (who.project_id, document["task_id"], document["revision"], Jsonb(document)))
|
||||
return document
|
||||
|
||||
def create_task(self, who: Identity, command: CreateTask) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
document = command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"task_id": str(uuid4()), "project_id": who.project_id, "revision": 1,
|
||||
"status": "pending", "origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": who.user_id, "feedback": [], "history": [{"sequence": 1, "from_status": None,
|
||||
"to_status": "pending", "actor": who.user_id, "at": now, "reason": "提交外围样板核查,不是正式工单"}]}
|
||||
return self._save(conn, who, document)
|
||||
return self._mutate(who, "create_task", command, write)
|
||||
|
||||
def _project(self, conn, who, doc, scenario):
|
||||
if scenario == "late_state" and doc["revision"] > 1:
|
||||
row = conn.execute("""SELECT document FROM peripheral_sample.revisions
|
||||
WHERE project_id=%s AND task_id=%s AND revision=%s""",
|
||||
(who.project_id, doc["task_id"], doc["revision"] - 1)).fetchone()
|
||||
if row:
|
||||
return row["document"]
|
||||
if scenario == "duplicate_feedback" and doc["feedback"]:
|
||||
return {**doc, "feedback": doc["feedback"] + [deepcopy(doc["feedback"][-1])]}
|
||||
return doc
|
||||
|
||||
def get_task(self, who: Identity, task_id: UUID | str) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
scenario = self._available(conn, who)
|
||||
return self._project(conn, who, self._task(conn, who, task_id), scenario)
|
||||
|
||||
def list_tasks(self, who: Identity, limit: int, cursor: UUID | None = None, source_task_id: UUID | None = None) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
scenario = self._available(conn, who)
|
||||
params = [who.project_id]
|
||||
sql = "SELECT document FROM peripheral_sample.tasks WHERE project_id=%s"
|
||||
if cursor is not None:
|
||||
sql += " AND task_id>%s"
|
||||
params.append(cursor)
|
||||
if source_task_id is not None:
|
||||
sql += " AND source_task_id=%s"
|
||||
params.append(source_task_id)
|
||||
rows = conn.execute(sql + " ORDER BY task_id LIMIT %s", params + [limit + 1]).fetchall()
|
||||
docs = [self._project(conn, who, row["document"], scenario) for row in rows[:limit]]
|
||||
return {"items": docs, "next_cursor": docs[-1]["task_id"] if len(rows) > limit else None}
|
||||
|
||||
def receipt(self, who: Identity, request_id: UUID | str) -> dict[str, Any]:
|
||||
# Remains available during faults; no implicit re-submission.
|
||||
with self.connection(readonly=True) as conn:
|
||||
row = conn.execute("""SELECT task_id,result FROM peripheral_sample.requests
|
||||
WHERE project_id=%s AND actor_id=%s AND request_id=%s""",
|
||||
(who.project_id, who.user_id, request_id)).fetchone()
|
||||
ensure(row is not None, "REQUEST_NOT_FOUND", "未找到该主体的请求回执", 404)
|
||||
return {"request_id": str(request_id), "task_id": str(row["task_id"]) if row["task_id"] else None,
|
||||
"result": row["result"]}
|
||||
|
||||
def transition(self, who: Identity, task_id: UUID | str, command: Transition) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
doc = self._task(conn, who, task_id)
|
||||
ensure(doc["revision"] == command.expected_revision, "REVISION_CONFLICT", "任务版本已变化,请重新读取", 409)
|
||||
ensure(command.target_status in TRANSITIONS[doc["status"]], "INVALID_TRANSITION", "不允许跳过处置或审核步骤", 409)
|
||||
ensure(command.target_status != "completed" or bool(doc["feedback"]),
|
||||
"FEEDBACK_REQUIRED", "没有现场反馈,不能审核完成", 409)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc["history"].append({"sequence": len(doc["history"]) + 1, "from_status": doc["status"],
|
||||
"to_status": command.target_status, "actor": who.user_id, "at": now, "reason": command.reason})
|
||||
doc.update(status=command.target_status, revision=doc["revision"] + 1, updated_at=now)
|
||||
return self._save(conn, who, doc)
|
||||
return self._mutate(who, f"transition:{task_id}", command, write)
|
||||
|
||||
def add_feedback(self, who: Identity, task_id: UUID | str, command: AddFeedback) -> dict[str, Any]:
|
||||
def write(conn, scenario):
|
||||
doc = self._task(conn, who, task_id)
|
||||
content = command.model_dump(mode="json", exclude={"request_id", "expected_revision"})
|
||||
content_digest = digest(content)
|
||||
previous = conn.execute("SELECT task_id,digest FROM peripheral_sample.feedback WHERE project_id=%s AND feedback_id=%s",
|
||||
(who.project_id, command.feedback_id)).fetchone()
|
||||
if previous:
|
||||
ensure(str(previous["task_id"]) == str(task_id) and previous["digest"] == content_digest,
|
||||
"FEEDBACK_CONFLICT", "同一反馈编号不能覆盖不同内容", 409)
|
||||
return doc
|
||||
ensure(doc["revision"] == command.expected_revision, "REVISION_CONFLICT", "任务版本已变化,请重新读取", 409)
|
||||
ensure(doc["status"] in {"processing", "pending_review"}, "INVALID_TRANSITION", "仅处理中或待审核任务可记录反馈", 409)
|
||||
ensure(set(command.asset_ids) <= set(doc["asset_ids"]), "FEEDBACK_SCOPE_MISMATCH", "反馈对象不属于本核查任务")
|
||||
window = TimeRange.model_validate(doc["analysis_window"])
|
||||
ensure(command.applicable_time.start >= window.start and command.applicable_time.end <= window.end,
|
||||
"FEEDBACK_SCOPE_MISMATCH", "反馈适用时段超出原分析范围")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
item = {**content, "task_id": str(task_id), "recorded_by": who.user_id, "recorded_at": now, "origin": "synthetic"}
|
||||
items = [(item, content_digest)]
|
||||
if scenario == "conflicting_feedback" and command.finding != "uncertain":
|
||||
opposite = {**item, "feedback_id": str(uuid4()), "recorded_by": "scenario:conflicting_feedback",
|
||||
"finding": "no_instrument_fault" if command.finding == "instrument_fault" else "instrument_fault",
|
||||
"notes": "故障演练自动注入的相反反馈,不是现场事实"}
|
||||
items.append((opposite, digest(opposite)))
|
||||
for item, item_digest in items:
|
||||
conn.execute("INSERT INTO peripheral_sample.feedback(project_id,feedback_id,task_id,digest,document) VALUES (%s,%s,%s,%s,%s)",
|
||||
(who.project_id, item["feedback_id"], task_id, item_digest, Jsonb(item)))
|
||||
doc["feedback"].append(item)
|
||||
doc.update(revision=doc["revision"] + 1, updated_at=now)
|
||||
return self._save(conn, who, doc)
|
||||
return self._mutate(who, f"feedback:{task_id}", command, write)
|
||||
|
||||
def _managed_document(self, conn, who: Identity, table: str, id_column: str,
|
||||
resource_id: UUID | str, missing_code: str, missing_message: str) -> dict[str, Any]:
|
||||
ensure(table in {"work_orders", "test_data_records"} and
|
||||
id_column in {"work_order_id", "record_id"}, "SCHEMA_INVALID", "无效的样板资源")
|
||||
row = conn.execute(
|
||||
f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND {id_column}=%s AND deleted_at IS NULL",
|
||||
(who.project_id, resource_id)).fetchone()
|
||||
ensure(row is not None, missing_code, missing_message, 404)
|
||||
return row["document"]
|
||||
|
||||
def _list_managed(self, who: Identity, table: str, id_column: str,
|
||||
limit: int, cursor: UUID | None) -> dict[str, Any]:
|
||||
ensure(table in {"work_orders", "test_data_records"} and
|
||||
id_column in {"work_order_id", "record_id"}, "SCHEMA_INVALID", "无效的样板资源")
|
||||
with self.connection(readonly=True) as conn:
|
||||
self._available(conn, who)
|
||||
params: list[Any] = [who.project_id]
|
||||
sql = f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND deleted_at IS NULL"
|
||||
if cursor is not None:
|
||||
sql += f" AND {id_column}>%s"
|
||||
params.append(cursor)
|
||||
rows = conn.execute(sql + f" ORDER BY {id_column} LIMIT %s", params + [limit + 1]).fetchall()
|
||||
docs = [row["document"] for row in rows[:limit]]
|
||||
return {"items": docs, "next_cursor": docs[-1][id_column] if len(rows) > limit else None}
|
||||
|
||||
def _save_work_order(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]:
|
||||
document = WorkOrder.model_validate(document).model_dump(mode="json")
|
||||
conn.execute("""INSERT INTO peripheral_sample.work_orders
|
||||
(project_id,work_order_id,revision,document,deleted_at) VALUES (%s,%s,%s,%s,NULL)
|
||||
ON CONFLICT(project_id,work_order_id) DO UPDATE SET
|
||||
revision=excluded.revision,document=excluded.document,deleted_at=NULL""",
|
||||
(who.project_id, document["work_order_id"], document["revision"], Jsonb(document)))
|
||||
conn.execute("""INSERT INTO peripheral_sample.work_order_revisions
|
||||
(project_id,work_order_id,revision,document) VALUES (%s,%s,%s,%s)""",
|
||||
(who.project_id, document["work_order_id"], document["revision"], Jsonb(document)))
|
||||
return document
|
||||
|
||||
def list_work_orders(self, who: Identity, limit: int, cursor: UUID | None = None) -> dict[str, Any]:
|
||||
return self._list_managed(who, "work_orders", "work_order_id", limit, cursor)
|
||||
|
||||
def get_work_order(self, who: Identity, work_order_id: UUID | str) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
self._available(conn, who)
|
||||
return self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id,
|
||||
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
|
||||
|
||||
def create_work_order(self, who: Identity, command: CreateWorkOrder) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
document = command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"work_order_id": str(uuid4()), "project_id": who.project_id, "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": who.user_id, "updated_by": who.user_id,
|
||||
}
|
||||
return self._save_work_order(conn, who, document)
|
||||
return self._mutate(who, "create_work_order", command, write)
|
||||
|
||||
def update_work_order(self, who: Identity, work_order_id: UUID | str,
|
||||
command: UpdateWorkOrder) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
document = self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id,
|
||||
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
|
||||
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
|
||||
changes = command.model_dump(mode="json", exclude_unset=True,
|
||||
exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at"})
|
||||
if command.clear_assignee:
|
||||
changes["assignee"] = None
|
||||
if command.clear_scheduled_at:
|
||||
changes["scheduled_at"] = None
|
||||
document.update(changes)
|
||||
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
updated_by=who.user_id)
|
||||
return self._save_work_order(conn, who, document)
|
||||
return self._mutate(who, f"update_work_order:{work_order_id}", command, write)
|
||||
|
||||
def delete_work_order(self, who: Identity, work_order_id: UUID | str,
|
||||
command: DeleteResource) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
document = self._managed_document(conn, who, "work_orders", "work_order_id", work_order_id,
|
||||
"WORK_ORDER_NOT_FOUND", "测试工单不存在")
|
||||
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409)
|
||||
revision = document["revision"] + 1
|
||||
deleted_at = datetime.now(timezone.utc).isoformat()
|
||||
result = {"resource_type": "work_order", "resource_id": str(work_order_id),
|
||||
"revision": revision, "deleted": True}
|
||||
tombstone = {**document, "revision": revision, "updated_at": deleted_at,
|
||||
"updated_by": who.user_id, "deleted_at": deleted_at, "deleted_by": who.user_id}
|
||||
conn.execute("""UPDATE peripheral_sample.work_orders SET revision=%s,document=%s,deleted_at=%s,deleted_by=%s
|
||||
WHERE project_id=%s AND work_order_id=%s AND deleted_at IS NULL""",
|
||||
(revision, Jsonb(tombstone), deleted_at, who.user_id, who.project_id, work_order_id))
|
||||
conn.execute("""INSERT INTO peripheral_sample.work_order_revisions
|
||||
(project_id,work_order_id,revision,document) VALUES (%s,%s,%s,%s)""",
|
||||
(who.project_id, work_order_id, revision, Jsonb(tombstone)))
|
||||
return result
|
||||
return self._mutate(who, f"delete_work_order:{work_order_id}", command, write)
|
||||
|
||||
def _save_test_data(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]:
|
||||
document = TestDataRecord.model_validate(document).model_dump(mode="json")
|
||||
conn.execute("""INSERT INTO peripheral_sample.test_data_records
|
||||
(project_id,record_id,revision,document,deleted_at) VALUES (%s,%s,%s,%s,NULL)
|
||||
ON CONFLICT(project_id,record_id) DO UPDATE SET
|
||||
revision=excluded.revision,document=excluded.document,deleted_at=NULL""",
|
||||
(who.project_id, document["record_id"], document["revision"], Jsonb(document)))
|
||||
conn.execute("""INSERT INTO peripheral_sample.test_data_record_revisions
|
||||
(project_id,record_id,revision,document) VALUES (%s,%s,%s,%s)""",
|
||||
(who.project_id, document["record_id"], document["revision"], Jsonb(document)))
|
||||
return document
|
||||
|
||||
def list_test_data(self, who: Identity, limit: int, cursor: UUID | None = None) -> dict[str, Any]:
|
||||
return self._list_managed(who, "test_data_records", "record_id", limit, cursor)
|
||||
|
||||
def get_test_data(self, who: Identity, record_id: UUID | str) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
self._available(conn, who)
|
||||
return self._managed_document(conn, who, "test_data_records", "record_id", record_id,
|
||||
"TEST_DATA_NOT_FOUND", "测试数据不存在")
|
||||
|
||||
def create_test_data(self, who: Identity, command: CreateTestDataRecord) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
document = command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"record_id": str(uuid4()), "project_id": who.project_id, "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": who.user_id, "updated_by": who.user_id,
|
||||
}
|
||||
return self._save_test_data(conn, who, document)
|
||||
return self._mutate(who, "create_test_data", command, write)
|
||||
|
||||
def update_test_data(self, who: Identity, record_id: UUID | str,
|
||||
command: UpdateTestDataRecord) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
document = self._managed_document(conn, who, "test_data_records", "record_id", record_id,
|
||||
"TEST_DATA_NOT_FOUND", "测试数据不存在")
|
||||
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "数据版本已变化,请重新读取", 409)
|
||||
changes = command.model_dump(mode="json", exclude_unset=True,
|
||||
exclude={"request_id", "expected_revision", "clear_value"})
|
||||
if command.clear_value:
|
||||
changes["value"] = None
|
||||
document.update(changes)
|
||||
document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
updated_by=who.user_id)
|
||||
return self._save_test_data(conn, who, document)
|
||||
return self._mutate(who, f"update_test_data:{record_id}", command, write)
|
||||
|
||||
def delete_test_data(self, who: Identity, record_id: UUID | str,
|
||||
command: DeleteResource) -> dict[str, Any]:
|
||||
def write(conn, _):
|
||||
document = self._managed_document(conn, who, "test_data_records", "record_id", record_id,
|
||||
"TEST_DATA_NOT_FOUND", "测试数据不存在")
|
||||
ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "数据版本已变化,请重新读取", 409)
|
||||
revision = document["revision"] + 1
|
||||
deleted_at = datetime.now(timezone.utc).isoformat()
|
||||
result = {"resource_type": "test_data_record", "resource_id": str(record_id),
|
||||
"revision": revision, "deleted": True}
|
||||
tombstone = {**document, "revision": revision, "updated_at": deleted_at,
|
||||
"updated_by": who.user_id, "deleted_at": deleted_at, "deleted_by": who.user_id}
|
||||
conn.execute("""UPDATE peripheral_sample.test_data_records SET revision=%s,document=%s,deleted_at=%s,deleted_by=%s
|
||||
WHERE project_id=%s AND record_id=%s AND deleted_at IS NULL""",
|
||||
(revision, Jsonb(tombstone), deleted_at, who.user_id, who.project_id, record_id))
|
||||
conn.execute("""INSERT INTO peripheral_sample.test_data_record_revisions
|
||||
(project_id,record_id,revision,document) VALUES (%s,%s,%s,%s)""",
|
||||
(who.project_id, record_id, revision, Jsonb(tombstone)))
|
||||
return result
|
||||
return self._mutate(who, f"delete_test_data:{record_id}", command, write)
|
||||
|
||||
def points(self, who: Identity, limit: int = 100, cursor: str | None = None) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
self._available(conn, who)
|
||||
# Existing project-owned table; absolutely no DDL/DML on it.
|
||||
exists = conn.execute("SELECT to_regclass('asset.scada_devices') AS table_name").fetchone()["table_name"]
|
||||
ensure(exists is not None, "SOURCE_NOT_CONFIGURED", "项目测点目录尚未映射", 503)
|
||||
rows = conn.execute("""SELECT device_id,device_type,measurement_unit FROM asset.scada_devices
|
||||
WHERE (%s::text IS NULL OR device_id::text>%s) ORDER BY device_id::text LIMIT %s""",
|
||||
(cursor, cursor, limit + 1)).fetchall()
|
||||
return {"source_id": self.settings.db_name + ":asset.scada_devices", "origin": "historical_replay",
|
||||
"items": [{"point_id": str(r["device_id"]).strip(), "label": str(r["device_id"]).strip() + " · " + str(r["device_type"]),
|
||||
"unit": str(r["measurement_unit"]), "status": "unknown", "origin": "historical_replay"} for r in rows[:limit]],
|
||||
"next_cursor": str(rows[limit-1]["device_id"]).strip() if len(rows) > limit else None}
|
||||
|
||||
def observations(self, who: Identity, point_id: str, start: str, end: str) -> dict[str, Any]:
|
||||
try:
|
||||
window = TimeRange.model_validate({"start": start, "end": end})
|
||||
except ValueError as exc:
|
||||
raise SampleFailure("SCHEMA_INVALID", "需要带时区的有效起止时间") from exc
|
||||
ensure(window.end - window.start <= timedelta(days=7), "WINDOW_TOO_LARGE", "样板查询窗口最多 7 天")
|
||||
with self.connection(readonly=True) as conn:
|
||||
scenario = self._available(conn, who)
|
||||
point = conn.execute("SELECT measurement_unit,api_query_id FROM asset.scada_devices WHERE device_id=%s", (point_id,)).fetchone()
|
||||
ensure(point is not None, "POINT_NOT_FOUND", "测点不存在", 404)
|
||||
ensure(point["api_query_id"], "SOURCE_NOT_CONFIGURED", "测点历史查询标识未配置", 503)
|
||||
with self.connection(readonly=True, timeseries=True) as conn:
|
||||
rows = conn.execute("""SELECT time,monitored_value FROM scada.measurements
|
||||
WHERE device_id=%s AND time>=%s AND time<%s ORDER BY time LIMIT 1001""",
|
||||
(point["api_query_id"], window.start, window.end)).fetchall()
|
||||
items = [{"record_id": digest({"point": point_id, "time": r["time"].isoformat()}),
|
||||
"point_id": point_id, "observed_at": r["time"].isoformat(),
|
||||
"value": r["monitored_value"] if r["monitored_value"] is not None and isfinite(r["monitored_value"]) else None,
|
||||
"quality": "unknown" if r["monitored_value"] is not None and isfinite(r["monitored_value"]) else "missing",
|
||||
"unit": point["measurement_unit"]} for r in rows[:1000]]
|
||||
if scenario == "missing_data":
|
||||
for item in items[::2]:
|
||||
item.update(value=None, quality="missing")
|
||||
return {"origin": "synthetic" if scenario == "missing_data" else "historical_replay",
|
||||
"source_id": self.settings.db_name + ":scada.measurements", "source_revision": digest(items),
|
||||
"items": items, "truncated": len(rows) > 1000}
|
||||
|
||||
def alerts(self, who: Identity) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
self._available(conn, who)
|
||||
return {"items": [], "source_id": self.settings.db_name, "origin": "historical_replay",
|
||||
"availability": "not_configured", "reason": "告警来源未映射;不从压力读数猜测告警或阈值"}
|
||||
|
||||
def demo_data(self, who: Identity) -> dict[str, Any]:
|
||||
with self.connection(readonly=True) as conn:
|
||||
self._available(conn, who)
|
||||
row = conn.execute("""SELECT document,digest FROM peripheral_sample.demo_datasets
|
||||
WHERE project_id=%s AND dataset_id='water-demo-v1'""", (who.project_id,)).fetchone()
|
||||
ensure(row is not None, "SOURCE_NOT_CONFIGURED", "当前项目尚未登记合成样本", 503)
|
||||
ensure(digest(row["document"]) == row["digest"], "INTEGRITY_MISMATCH", "合成样本摘要不匹配", 503)
|
||||
try:
|
||||
content = DemoContent.model_validate(row["document"]).model_dump(mode="json")
|
||||
except ValueError as exc:
|
||||
raise SampleFailure("INTEGRITY_MISMATCH", "合成样本格式不匹配", 503) from exc
|
||||
return {**content, "project_id": who.project_id,
|
||||
"source_id": self.settings.db_name + ":peripheral_sample.demo_datasets:water-demo-v1",
|
||||
"source_revision": row["digest"]}
|
||||
Reference in New Issue
Block a user