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