Files
next-tjwater-server/samples/peripheral/store.py
T

484 lines
31 KiB
Python

"""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"]}