"""Peripheral persistence and transactional work-order change signals.""" 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 app.infra.db.postgresql.workbench_events import lock_event_transaction_sync 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": []} WORK_ORDER_FIELD_LABELS = { "title": "标题", "summary": "说明", "status": "状态", "priority": "优先级", "assignee": "负责人", "scheduled_at": "预约时间", "location": "位置", "scenario_code": "场景编号", "feedback_requirements": "回执要求", } WORK_ORDER_CREATED_DETAILS = { "general": "预约工单已创建。", "water_quality_complaint": "投诉工单已创建。", "burst_report": "爆管上报工单已创建。", } 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: if "work_order" in key: lock_event_transaction_sync(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 self._active_managed_document(row["document"]) @staticmethod def _active_managed_document(document: dict[str, Any]) -> dict[str, Any]: """Project a current active row without legacy soft-delete metadata.""" active = {key: value for key, value in document.items() if key not in {"deleted_at", "deleted_by"}} if "work_order_id" in active: active.pop("subtasks", None) active.setdefault("feedback_requirements", []) active.setdefault("activity", []) return active 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 = [self._active_managed_document(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))) self._work_order_event(conn, who, document) return document def _work_order_event(self, conn, who: Identity, document: dict[str, Any], *, deleted: bool = False) -> None: # This rollout is restricted to the provisioned tjwater_v2 event log. # Task projections remain owned by the Server's authenticated APIs. if self.settings.db_name != "tjwater_v2": return metadata = { "source": "peripheral_sample", "work_order_id": document["work_order_id"], "change": "deleted" if deleted else "created" if document["revision"] == 1 else "updated", } row = conn.execute( """INSERT INTO workbench.domain_event_log (project_id,event_id,topic,aggregate_type,aggregate_id, aggregate_revision,occurred_at,metadata) VALUES (%s,%s,'workbench.work_order.changed','peripheral_work_order',%s,%s,%s,%s) RETURNING event_seq""", (who.project_id, uuid4(), document["work_order_id"], document["revision"], document["updated_at"], Jsonb(metadata)), ).fetchone() conn.execute("SELECT pg_notify('workbench_events', %s)", (f"{who.project_id}:{row['event_seq']}",)) 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, "activity": [{"sequence": 1, "occurred_at": now, "actor": who.user_id, "kind": "created", "title": "工单创建", "detail": WORK_ORDER_CREATED_DETAILS[command.work_order_type]}], } 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", "clear_location", "clear_scenario_code", "progress_note"}) if command.clear_assignee: changes["assignee"] = None if command.clear_scheduled_at: changes["scheduled_at"] = None if command.clear_location: changes["location"] = None if command.clear_scenario_code: changes["scenario_code"] = None changed_fields = [key for key, value in changes.items() if document.get(key) != value] document.update(changes) now = datetime.now(timezone.utc).isoformat() revision = document["revision"] + 1 if command.progress_note: title, detail, kind = "现场反馈", command.progress_note, "feedback" elif changed_fields: field_labels = WORK_ORDER_FIELD_LABELS | ({"scheduled_at": "发生时间"} if document["work_order_type"] != "general" else {}) title, detail, kind = "工单信息更新", "更新了" + "、".join( field_labels.get(field, field) for field in changed_fields ) + "。", "updated" else: title, detail, kind = "工单信息确认", "工单信息已确认,未记录新的现场进展。", "updated" document["activity"].append({"sequence": revision, "occurred_at": now, "actor": who.user_id, "kind": kind, "title": title, "detail": detail}) document.update(revision=revision, updated_at=now, 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))) self._work_order_event(conn, who, tombstone, deleted=True) 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"]}