feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
"""Independent, explicitly simulated peripheral service; not a production workflow engine."""
+137
View File
@@ -0,0 +1,137 @@
"""Independent sample API for synthetic work orders and monitoring data."""
from typing import get_args
from uuid import UUID
from fastapi import Depends, FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .auth import Identity, identity
from .config import SampleSettings
from .errors import SampleFailure
from .store import TRANSITIONS
from .schemas import (
AlertPage, Context, CreateTestDataRecord, CreateWorkOrder,
DeleteResource, DeletedResource, DemoDataset, Observations, PointPage, Receipt,
SampleError, Scenario, ScenarioCommand, ScenarioState, TestDataRecord,
TestDataRecordPage, UpdateTestDataRecord, UpdateWorkOrder, WorkOrder, WorkOrderPage,
)
def store(request: Request):
from .store import PostgresStore
return PostgresStore(request.app.state.settings)
def create_app(settings: SampleSettings | None = None) -> FastAPI:
app = FastAPI(title="TJWater 外围系统模拟接入", version="1.0.0",
description="测试监测与核查反馈样板,不是正式工单平台或设备控制服务。")
app.state.settings = settings or SampleSettings()
app.add_middleware(CORSMiddleware, allow_origins=app.state.settings.cors_origins,
allow_credentials=False, allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Project-Id"])
@app.exception_handler(SampleFailure)
async def failure(request, exc):
return JSONResponse(status_code=exc.status, content=exc.payload())
@app.exception_handler(RequestValidationError)
async def invalid(request, exc):
return JSONResponse(status_code=422, content=SampleFailure("SCHEMA_INVALID", "请求字段不符合样板契约").payload())
@app.get("/health")
def health():
return {"service": "peripheral-sample", "origin": "synthetic", "storage": "postgresql",
"status": "up", "storage_verified": False}
prefix = "/api/sample/v1"
errors = {code: {"model": SampleError} for code in (401, 403, 404, 409, 422, 502, 503)}
@app.get(prefix + "/context", response_model=Context, responses=errors)
def context(request: Request, who: Identity = Depends(identity), db=Depends(store)):
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "synthetic",
"project_id": who.project_id, "user_id": who.user_id, "can_write": who.can_write,
"scenarios": list(get_args(Scenario)) if request.app.state.settings.enable_faults else ["normal"],
"state_transitions": TRANSITIONS, "managed_resources": db.managed_resource_capabilities()}
@app.get(prefix + "/scenario", response_model=ScenarioState, responses=errors)
def scenario(who: Identity = Depends(identity), db=Depends(store)):
return db.scenario(who)
@app.post(prefix + "/scenario", response_model=ScenarioState, responses=errors)
def configure_scenario(command: ScenarioCommand, who: Identity = Depends(identity), db=Depends(store)):
return db.set_scenario(who, command)
@app.get(prefix + "/requests/{request_id}", response_model=Receipt, responses=errors)
def receipt(request_id: UUID, who: Identity = Depends(identity), db=Depends(store)):
return db.receipt(who, request_id)
@app.get(prefix + "/work-orders", response_model=WorkOrderPage, responses=errors)
def work_orders(limit: int = Query(20, ge=1, le=100), cursor: UUID | None = None,
who: Identity = Depends(identity), db=Depends(store)):
return db.list_work_orders(who, limit, cursor)
@app.post(prefix + "/work-orders", response_model=WorkOrder, status_code=201, responses=errors)
def create_work_order(command: CreateWorkOrder, who: Identity = Depends(identity), db=Depends(store)):
return db.create_work_order(who, command)
@app.get(prefix + "/work-orders/{work_order_id}", response_model=WorkOrder, responses=errors)
def work_order(work_order_id: UUID, who: Identity = Depends(identity), db=Depends(store)):
return db.get_work_order(who, work_order_id)
@app.patch(prefix + "/work-orders/{work_order_id}", response_model=WorkOrder, responses=errors)
def update_work_order(work_order_id: UUID, command: UpdateWorkOrder,
who: Identity = Depends(identity), db=Depends(store)):
return db.update_work_order(who, work_order_id, command)
@app.delete(prefix + "/work-orders/{work_order_id}", response_model=DeletedResource, responses=errors)
def delete_work_order(work_order_id: UUID, command: DeleteResource,
who: Identity = Depends(identity), db=Depends(store)):
return db.delete_work_order(who, work_order_id, command)
@app.get(prefix + "/test-data", response_model=TestDataRecordPage, responses=errors)
def test_data(limit: int = Query(20, ge=1, le=100), cursor: UUID | None = None,
who: Identity = Depends(identity), db=Depends(store)):
return db.list_test_data(who, limit, cursor)
@app.post(prefix + "/test-data", response_model=TestDataRecord, status_code=201, responses=errors)
def create_test_data(command: CreateTestDataRecord, who: Identity = Depends(identity), db=Depends(store)):
return db.create_test_data(who, command)
@app.get(prefix + "/test-data/{record_id}", response_model=TestDataRecord, responses=errors)
def test_data_record(record_id: UUID, who: Identity = Depends(identity), db=Depends(store)):
return db.get_test_data(who, record_id)
@app.patch(prefix + "/test-data/{record_id}", response_model=TestDataRecord, responses=errors)
def update_test_data(record_id: UUID, command: UpdateTestDataRecord,
who: Identity = Depends(identity), db=Depends(store)):
return db.update_test_data(who, record_id, command)
@app.delete(prefix + "/test-data/{record_id}", response_model=DeletedResource, responses=errors)
def delete_test_data(record_id: UUID, command: DeleteResource,
who: Identity = Depends(identity), db=Depends(store)):
return db.delete_test_data(who, record_id, command)
@app.get(prefix + "/monitoring/points", response_model=PointPage, responses=errors)
def points(limit: int = Query(100, ge=1, le=500), cursor: str | None = Query(None, max_length=128),
who: Identity = Depends(identity), db=Depends(store)):
return db.points(who, limit, cursor)
@app.get(prefix + "/monitoring/observations", response_model=Observations, responses=errors)
def observations(point_id: str, start: str, end: str, who: Identity = Depends(identity), db=Depends(store)):
return db.observations(who, point_id, start, end)
@app.get(prefix + "/monitoring/alerts", response_model=AlertPage, responses=errors)
def alerts(who: Identity = Depends(identity), db=Depends(store)):
return db.alerts(who)
@app.get(prefix + "/demo-data", response_model=DemoDataset, responses=errors)
def demo_data(who: Identity = Depends(identity), db=Depends(store)):
return db.demo_data(who)
return app
app = create_app()
+54
View File
@@ -0,0 +1,54 @@
"""Reuse Next's product identity; no new user store or client-selected identity."""
from dataclasses import dataclass
from uuid import UUID
import httpx
from fastapi import Request
from .errors import SampleFailure, ensure
@dataclass(frozen=True)
class Identity:
project_id: str
user_id: str
can_write: bool
def next_get(request: Request, path: str, params=None):
settings = request.app.state.settings
try:
with httpx.Client(base_url=settings.next_server_url, timeout=settings.timeout_seconds,
follow_redirects=False, trust_env=False) as client:
response = client.get(path, params=params, headers={
"Authorization": request.headers.get("authorization", ""),
"X-Project-Id": request.headers.get("x-project-id", ""),
})
if response.status_code in {401, 403, 404}:
raise SampleFailure("SOURCE_ACCESS_DENIED", "Next 拒绝身份或来源对象访问", response.status_code)
ensure(response.status_code == 200, "NEXT_UNAVAILABLE", "Next 服务不可用,未确认来源", 503)
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise SampleFailure("NEXT_UNAVAILABLE", "无法核实 Next 身份或来源", 503) from exc
def identity(request: Request) -> Identity:
authorization = request.headers.get("authorization", "")
project = request.headers.get("x-project-id", "")
ensure(authorization.lower().startswith("bearer ") and bool(project),
"AUTHENTICATION_REQUIRED", "需要 Bearer 和 X-Project-Id", 401)
context = next_get(request, "/api/v1/agent-auth-context")
try:
user_id = str(UUID(context["user_id"]))
project_id = str(UUID(context["project_id"]))
ensure(project_id == str(UUID(project)), "PROJECT_MISMATCH", "项目身份不一致", 403)
ensure(context["network"] == request.app.state.settings.project_code,
"PROJECT_NOT_CONFIGURED", "该项目不是本外围样板的数据项目", 403)
permissions = context["permissions"]
ensure(isinstance(permissions, list) and "simulation.view" in permissions,
"PERMISSION_DENIED", "没有样板读取权限", 403)
can_write = "simulation.run" in permissions
ensure(request.method == "GET" or can_write, "PERMISSION_DENIED", "没有样板修改权限", 403)
except (KeyError, TypeError, ValueError) as exc:
raise SampleFailure("INVALID_AUTH_CONTEXT", "Next 身份响应不完整", 502) from exc
return Identity(project_id, user_id, can_write)
+20
View File
@@ -0,0 +1,20 @@
"""Server-only configuration. Reuses BizDB credentials, never browser config."""
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class SampleSettings(BaseSettings):
next_server_url: str = "http://127.0.0.1:8000"
db_name: str = "tjwater_v2"
project_code: str = "tjwater_v2"
cors_origins: list[str] = ["http://127.0.0.1:5173", "http://localhost:5173"]
enable_faults: bool = False
timeout_seconds: float = Field(default=5.0, ge=0.1, le=30)
model_config = SettingsConfigDict(
env_prefix="PERIPHERAL_SAMPLE_",
env_file=Path(__file__).resolve().parents[2] / ".env",
extra="ignore",
)
+82
View File
@@ -0,0 +1,82 @@
"""Pure, versioned generator. No clock, random process state, network or writes."""
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from uuid import NAMESPACE_URL, uuid5
from .schemas import DemoContent
VERSION = "water-demo@2"
DEFAULT_SEED = "tjwater-peripheral-demo-20260917"
DEFAULT_START = datetime(2026, 9, 17, tzinfo=timezone.utc)
def build_demo_content(seed: str = DEFAULT_SEED, start: datetime = DEFAULT_START) -> dict:
"""Same version/seed/UTC start produce identical canonical content and IDs."""
if not isinstance(seed, str) or not 1 <= len(seed) <= 128:
raise ValueError("seed must contain 1 to 128 characters")
if not isinstance(start, datetime) or start.tzinfo is None or start.utcoffset() is None:
raise ValueError("start must include timezone")
start = start.astimezone(timezone.utc)
if start.second or start.microsecond or start.minute % 15:
raise ValueError("start must be aligned to a 15-minute UTC boundary")
prefix = f"{VERSION}/{seed}/{start.isoformat()}"
def record_id(kind: str, key: str) -> str:
return str(uuid5(NAMESPACE_URL, f"{prefix}/{kind}/{key}"))
def at(index: int) -> str:
return (start + timedelta(minutes=15 * index)).isoformat()
points = [
("DEMO-P001", "演示压力测点 1", "m", 24.37),
("DEMO-P002", "演示压力测点 2", "m", 31.26),
("DEMO-P003", "演示压力测点 3", "m", 31.81),
("DEMO-F001", "演示出水流量", "m3/h", 186.43),
("DEMO-L001", "演示水池液位", "m", 4.26),
]
observations = []
for index in range(48):
for point_id, _label, unit, base in points:
noise = int(sha256(f"{prefix}/{point_id}/{index}".encode()).hexdigest()[:8], 16) % 101
value = round(base + (noise - 50) / 100, 2)
quality = "valid"
if point_id == "DEMO-P001" and 12 <= index < 16:
value, quality = None, "missing"
elif point_id == "DEMO-F001" and 24 <= index < 28:
value = 0.0
elif point_id == "DEMO-L001" and 36 <= index < 40:
value = round(1.35 + noise / 1000, 3)
observations.append({"record_id": record_id("observation", f"{point_id}/{index}"),
"point_id": point_id, "observed_at": at(index), "value": value,
"quality": quality, "unit": unit})
states = []
for asset, kind, changes in (
("DEMO-P001", "pressure_sensor", [(0, "online"), (12, "offline"), (16, "online")]),
("DEMO-PUMP-01", "pump", [(0, "running"), (24, "stopped"), (28, "running")]),
("DEMO-VALVE-01", "valve", [(0, "open"), (24, "closed"), (28, "open")]),
):
for index, state in changes:
states.append({"record_id": record_id("state", f"{asset}/{index}"), "asset_id": asset,
"asset_type": kind, "observed_at": at(index), "state": state})
states.sort(key=lambda record: (record["observed_at"], record["asset_id"]))
alerts = [
{"alert_id": record_id("alert", "communication"), "asset_id": "DEMO-P001",
"occurred_at": at(12), "cleared_at": at(16), "code": "communication_lost",
"message": "预置通信中断告警样例,仅为合成情节,不是现场诊断"},
{"alert_id": record_id("alert", "level"), "asset_id": "DEMO-L001",
"occurred_at": at(36), "cleared_at": at(40), "code": "low_level",
"message": "预置低液位告警样例,不采用生产阈值或专业告警算法"},
]
operations = [
{"record_id": record_id("operation", operation), "asset_id": asset, "occurred_at": at(index),
"operation": operation, "actor": "synthetic-generator", "note": "合成运行历史,不是设备指令或实际操作回执"}
for asset, index, operation in (("DEMO-PUMP-01", 24, "pump_stop"),
("DEMO-VALVE-01", 24, "valve_close"), ("DEMO-PUMP-01", 28, "pump_start"),
("DEMO-VALVE-01", 28, "valve_open"))
]
content = {"generator_version": VERSION, "seed": seed, "origin": "synthetic",
"window": {"start": at(0), "end": at(48)},
"points": [{"point_id": point, "label": label, "unit": unit, "status": "unknown", "origin": "synthetic"}
for point, label, unit, _ in points],
"observations": observations, "device_states": states, "alerts": alerts, "operations": operations}
return DemoContent.model_validate(content).model_dump(mode="json")
+18
View File
@@ -0,0 +1,18 @@
from uuid import UUID
class SampleFailure(Exception):
def __init__(self, code: str, message: str, status: int = 422, *,
effect: str = "none", request_id: UUID | str | None = None):
super().__init__(message)
self.code, self.message, self.status = code, message, status
self.effect, self.request_id = effect, str(request_id) if request_id else None
def payload(self):
return {"code": self.code, "message": self.message,
"request_id": self.request_id, "effect": self.effect}
def ensure(condition, code, message, status=422):
if not condition:
raise SampleFailure(code, message, status)
+397
View File
@@ -0,0 +1,397 @@
from __future__ import annotations
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator
from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime
Text = Annotated[str, Field(min_length=1, max_length=4000)]
PositiveInt = Annotated[StrictInt, Field(ge=1)]
TaskStatus = Literal["pending", "processing", "pending_review", "completed"]
Scenario = Literal["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"]
class Model(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
class SourceLink(Model):
system: Literal["next"]
task_id: UUID
object_type: Literal["validation", "artifact"]
object_id: UUID
revision: PositiveInt
class CreateTask(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
summary: Text
source: SourceLink
asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)]
analysis_window: TimeRange
evidence_refs: Annotated[list[ObjectRef], Field(max_length=100)]
requested_check: Text
@model_validator(mode="after")
def unique_assets(self):
if len(set(self.asset_ids)) != len(self.asset_ids):
raise ValueError("asset_ids must be unique")
return self
class Transition(Model):
request_id: UUID
expected_revision: PositiveInt
target_status: TaskStatus
reason: Text
class AddFeedback(Model):
request_id: UUID
expected_revision: PositiveInt
feedback_id: UUID
occurred_at: WireDateTime
applicable_time: TimeRange
asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)]
finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"]
notes: Text
attachment_refs: Annotated[list[Annotated[str, Field(min_length=1, max_length=500)]], Field(max_length=20)]
@model_validator(mode="after")
def aware_observation(self):
if self.occurred_at.tzinfo is None:
raise ValueError("occurred_at must include timezone")
if len(set(self.asset_ids)) != len(self.asset_ids):
raise ValueError("asset_ids must be unique")
return self
class Feedback(Model):
feedback_id: UUID
task_id: UUID
recorded_by: str
recorded_at: WireDateTime
occurred_at: WireDateTime
applicable_time: TimeRange
asset_ids: list[str]
finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"]
notes: str
attachment_refs: list[str]
origin: Literal["synthetic"]
class HistoryEntry(Model):
sequence: PositiveInt
from_status: TaskStatus | None
to_status: TaskStatus
actor: str
at: WireDateTime
reason: str
class Task(Model):
task_id: UUID
project_id: UUID
revision: PositiveInt
title: str
summary: str
source: SourceLink
asset_ids: list[str]
analysis_window: TimeRange
evidence_refs: list[ObjectRef]
requested_check: str
status: TaskStatus
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
history: list[HistoryEntry]
feedback: list[Feedback]
class TaskPage(Model):
items: list[Task]
next_cursor: UUID | None
WorkOrderStatus = Literal["draft", "open", "in_progress", "completed", "cancelled"]
Priority = Literal["low", "normal", "high", "urgent"]
class CreateWorkOrder(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
summary: Text
status: WorkOrderStatus = "draft"
priority: Priority = "normal"
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scheduled_at: WireDateTime | None = None
@model_validator(mode="after")
def aware_schedule(self):
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone")
return self
class UpdateWorkOrder(Model):
request_id: UUID
expected_revision: PositiveInt
title: Annotated[str, Field(min_length=1, max_length=200)] | None = None
summary: Text | None = None
status: WorkOrderStatus | None = None
priority: Priority | None = None
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
clear_assignee: StrictBool = False
scheduled_at: WireDateTime | None = None
clear_scheduled_at: StrictBool = False
@model_validator(mode="after")
def valid_update(self):
changed = set(self.model_fields_set) - {"request_id", "expected_revision"}
if not changed:
raise ValueError("at least one work order field must be updated")
if self.clear_assignee and self.assignee is not None:
raise ValueError("clear_assignee conflicts with assignee")
if self.clear_scheduled_at and self.scheduled_at is not None:
raise ValueError("clear_scheduled_at conflicts with scheduled_at")
if self.scheduled_at is not None and self.scheduled_at.tzinfo is None:
raise ValueError("scheduled_at must include timezone")
return self
class DeleteResource(Model):
request_id: UUID
expected_revision: PositiveInt
class WorkOrder(Model):
work_order_id: UUID
project_id: UUID
revision: PositiveInt
title: str
summary: str
status: WorkOrderStatus
priority: Priority
assignee: str | None
scheduled_at: WireDateTime | None
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
updated_by: str
class WorkOrderPage(Model):
items: list[WorkOrder]
next_cursor: UUID | None
class CreateTestDataRecord(Model):
request_id: UUID
label: Annotated[str, Field(min_length=1, max_length=200)]
point_id: Annotated[str, Field(min_length=1, max_length=128)]
observed_at: WireDateTime
value: StrictFloat | StrictInt | None
unit: Annotated[str, Field(min_length=1, max_length=32)]
quality: Literal["valid", "unknown", "missing"] = "valid"
notes: Annotated[str, Field(max_length=4000)] = ""
@model_validator(mode="after")
def aware_observation(self):
if self.observed_at.tzinfo is None:
raise ValueError("observed_at must include timezone")
return self
class UpdateTestDataRecord(Model):
request_id: UUID
expected_revision: PositiveInt
label: Annotated[str, Field(min_length=1, max_length=200)] | None = None
point_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None
observed_at: WireDateTime | None = None
value: StrictFloat | StrictInt | None = None
clear_value: StrictBool = False
unit: Annotated[str, Field(min_length=1, max_length=32)] | None = None
quality: Literal["valid", "unknown", "missing"] | None = None
notes: Annotated[str, Field(max_length=4000)] | None = None
@model_validator(mode="after")
def valid_update(self):
changed = set(self.model_fields_set) - {"request_id", "expected_revision"}
if not changed:
raise ValueError("at least one test data field must be updated")
if self.clear_value and self.value is not None:
raise ValueError("clear_value conflicts with value")
if self.observed_at is not None and self.observed_at.tzinfo is None:
raise ValueError("observed_at must include timezone")
return self
class TestDataRecord(Model):
record_id: UUID
project_id: UUID
revision: PositiveInt
label: str
point_id: str
observed_at: WireDateTime
value: float | int | None
unit: str
quality: Literal["valid", "unknown", "missing"]
notes: str
origin: Literal["synthetic"]
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
updated_by: str
class TestDataRecordPage(Model):
items: list[TestDataRecord]
next_cursor: UUID | None
class DeletedResource(Model):
resource_type: Literal["work_order", "test_data_record"]
resource_id: UUID
revision: Annotated[StrictInt, Field(ge=2)]
deleted: Literal[True]
class Receipt(Model):
request_id: UUID
task_id: UUID | None
result: Task | ScenarioState | WorkOrder | TestDataRecord | DeletedResource
class ScenarioCommand(Model):
request_id: UUID
scenario: Scenario
class ScenarioState(Model):
scenario: Scenario
revision: PositiveInt
ResourceAvailability = Literal["available", "not_configured"]
class ManagedResourceCapabilities(Model):
work_orders: ResourceAvailability
test_data: ResourceAvailability
class Context(Model):
protocol_version: Literal["tjwater-peripheral-sample@1"]
origin: Literal["synthetic"]
project_id: UUID
user_id: UUID
can_write: StrictBool
scenarios: list[Scenario]
state_transitions: dict[str, list[str]]
managed_resources: ManagedResourceCapabilities
class Point(Model):
point_id: str
label: str
unit: str
status: str
origin: Literal["historical_replay", "synthetic"]
class PointPage(Model):
items: list[Point]
source_id: str
origin: Literal["historical_replay", "synthetic"]
next_cursor: str | None
class Observation(Model):
record_id: str
point_id: str
observed_at: WireDateTime
value: float | None
quality: Literal["valid", "unknown", "missing"]
unit: str
class Observations(Model):
origin: Literal["historical_replay", "synthetic"]
source_id: str
source_revision: str
items: list[Observation]
truncated: StrictBool
class Alert(Model):
alert_id: str
point_id: str
occurred_at: WireDateTime
message: str
origin: Literal["historical_replay", "synthetic"]
class AlertPage(Model):
items: list[Alert]
source_id: str
origin: Literal["historical_replay", "synthetic"]
availability: Literal["available", "not_configured"]
reason: str | None
class SampleError(Model):
code: str
message: str
request_id: UUID | None
effect: Literal["none", "unknown"]
class DemoDeviceState(Model):
record_id: UUID
asset_id: str
asset_type: Literal["pressure_sensor", "pump", "valve"]
observed_at: WireDateTime
state: Literal["online", "offline", "running", "stopped", "open", "closed"]
class DemoAlert(Model):
alert_id: UUID
asset_id: str
occurred_at: WireDateTime
cleared_at: WireDateTime | None
code: Literal["communication_lost", "low_level"]
message: str
class DemoOperation(Model):
record_id: UUID
asset_id: str
occurred_at: WireDateTime
operation: Literal["pump_start", "pump_stop", "valve_open", "valve_close"]
actor: Literal["synthetic-generator"]
note: str
class DemoContent(Model):
generator_version: Literal["water-demo@2"]
seed: str
origin: Literal["synthetic"]
window: TimeRange
points: list[Point]
observations: list[Observation]
device_states: list[DemoDeviceState]
alerts: list[DemoAlert]
operations: list[DemoOperation]
class DemoDataset(DemoContent):
project_id: UUID
source_id: str
source_revision: str
Receipt.model_rebuild()
+483
View File
@@ -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"]}