diff --git a/README.md b/README.md index 0a109a2..4d24012 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ conda activate server ## 常用命令 +外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。 +外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD,并将演示预约工单投影到 `workbench.work_orders`。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。 + ```bash conda run -n server python -m pytest tests -q conda run -n server python scripts/run_server.py @@ -87,6 +90,16 @@ docker compose -f infra/docker/docker-compose.yml config 使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话。 +奉贤项目的原始 GIS 与水质监测点使用独立脚本维护。导入脚本读取两份交付目录并写入项目 BizDB;水质脚本调用现有灵敏度分析服务生成 50 个监测点;发布脚本创建 10 个项目图层并刷新 GeoServer 缓存: + +```bash +conda run -n server python scripts/import_fengxian_gis.py --database tjwater_v2 +conda run -n server python scripts/generate_fengxian_water_quality_points.py --project tjwater_v2 +conda run -n server python scripts/publish_fengxian_map_layers.py --database tjwater_v2 --workspace tjwater_next +``` + +发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。 + ## 测试与发布 提交前根据改动范围运行最小有效测试: diff --git a/app/api/v1/endpoints/project_data.py b/app/api/v1/endpoints/project_data.py index 02fa3ce..ba56231 100644 --- a/app/api/v1/endpoints/project_data.py +++ b/app/api/v1/endpoints/project_data.py @@ -1,47 +1,116 @@ +import logging from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query from psycopg import AsyncConnection -from app.infra.db.postgresql.analysis import AnalysisRepository from app.auth.project_dependencies import get_project_pg_connection +from app.domain.schemas.gis_attributes import ( + ElementGisProperties, + GisElementType, + SourceGisFeatureProperties, +) +from app.infra.db.postgresql.analysis import AnalysisRepository +from app.infra.db.postgresql.gis_attributes import GisAttributeRepository router = APIRouter() +logger = logging.getLogger(__name__) -async def get_database_connection( +@router.get( + "/source-gis/layers/{layer_key}/features/{source_fid}/properties", + response_model=SourceGisFeatureProperties, + summary="获取源 GIS 要素台账属性", +) +async def get_source_gis_feature_properties( + layer_key: str, + source_fid: int, conn: AsyncConnection = Depends(get_project_pg_connection), ): - """获取数据库连接""" - yield conn + normalized_layer_key = layer_key.strip() + if ( + not normalized_layer_key + or len(normalized_layer_key) > 80 + or source_fid < 0 + ): + raise HTTPException(status_code=422, detail="无效的源 GIS 要素编号") + try: + group = await GisAttributeRepository.get_source_feature_group( + conn, + normalized_layer_key, + source_fid, + ) + if group is None: + raise HTTPException(status_code=404, detail="源 GIS 要素不存在") + return SourceGisFeatureProperties( + layer_key=normalized_layer_key, + source_fid=source_fid, + group=group, + ) + except HTTPException: + raise + except Exception as exc: + logger.warning("Source GIS feature properties are unavailable", exc_info=True) + raise HTTPException(status_code=500, detail="查询源 GIS 要素属性失败") from exc + + +@router.get( + "/elements/{element_type}/{element_id}/gis-properties", + response_model=ElementGisProperties, + summary="获取元素 GIS 台账属性", +) +async def get_element_gis_properties( + element_type: GisElementType, + element_id: str, + conn: AsyncConnection = Depends(get_project_pg_connection), +): + """Return source GIS records linked to one current-project map element.""" + normalized_id = element_id.strip() + if not normalized_id or len(normalized_id) > 160: + raise HTTPException(status_code=422, detail="无效的元素编号") + try: + groups = await GisAttributeRepository.list_element_groups( + conn, + element_type, + normalized_id, + ) + return ElementGisProperties( + element_type=element_type, + element_id=normalized_id, + groups=groups, + ) + except Exception as exc: + logger.warning("Element GIS properties are unavailable", exc_info=True) + raise HTTPException(status_code=500, detail="查询 GIS 台账属性失败") from exc @router.get("/analysis/runs", summary="获取分析运行列表") async def get_analysis_runs( - conn: AsyncConnection = Depends(get_database_connection), + limit: int = Query(default=100, ge=1, le=100), + offset: int = Query(default=0, ge=0), + conn: AsyncConnection = Depends(get_project_pg_connection), ): - """ - 获取所有方案信息 - - 返回项目中所有方案的详细信息 - """ + """分页返回当前项目的持久分析运行。""" try: - runs = await AnalysisRepository.list_runs(conn) - return {"success": True, "data": runs, "count": len(runs)} - except Exception as e: - raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}") + runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset) + return { + "success": True, + "data": runs, + "count": len(runs), + "limit": limit, + "offset": offset, + } + except Exception as exc: + logger.warning("Analysis run page is unavailable", exc_info=True) + raise HTTPException(status_code=500, detail="查询分析运行失败") from exc @router.get("/analysis/runs/{run_id}", summary="获取分析运行") async def get_analysis_run( run_id: UUID, - conn: AsyncConnection = Depends(get_database_connection), + conn: AsyncConnection = Depends(get_project_pg_connection), ): - """ - 获取所有爆管定位结果 - - 返回项目中所有的爆管定位分析结果 - """ + """返回当前项目的一条持久分析运行。""" try: run = await AnalysisRepository.get_run(conn, run_id) if run is None: @@ -49,28 +118,31 @@ async def get_analysis_run( return run except HTTPException: raise - except Exception as e: - raise HTTPException( - status_code=500, detail=f"查询分析运行时发生错误: {str(e)}" - ) + except Exception as exc: + logger.warning("Analysis run is unavailable", exc_info=True) + raise HTTPException(status_code=500, detail="查询分析运行失败") from exc @router.get("/analysis/runs/{run_id}/results", summary="获取分析结果") async def get_analysis_results( run_id: UUID, result_type: str | None = Query(default=None, description="结果类型"), - conn: AsyncConnection = Depends(get_database_connection), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + conn: AsyncConnection = Depends(get_project_pg_connection), ): - """ - 根据爆管事件ID查询爆管定位结果 - - 参数: - burst_incident: 爆管事件的唯一标识符 - """ + """分页返回一条分析运行的持久结果。""" try: - return await AnalysisRepository.list_results(conn, run_id, result_type) - except Exception as e: + return await AnalysisRepository.list_results( + conn, + run_id, + result_type, + limit=limit, + offset=offset, + ) + except Exception as exc: + logger.warning("Analysis result page is unavailable", exc_info=True) raise HTTPException( status_code=500, - detail=f"查询分析结果时发生错误: {str(e)}", - ) + detail="查询分析结果失败", + ) from exc diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index c65ef6f..dfc8cf0 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -146,6 +146,7 @@ async def optimize_sensor_placement_scheme( sensor_count=payload.sensor_count, min_diameter=payload.min_diameter, created_by=current_user.username, + sensor_type=payload.sensor_type, ) run = get_sensor_placement_run(network, created["run_id"]) return {**run, "can_edit": True} diff --git a/app/api/v1/endpoints/workbench_runtime.py b/app/api/v1/endpoints/workbench_runtime.py new file mode 100644 index 0000000..0055812 --- /dev/null +++ b/app/api/v1/endpoints/workbench_runtime.py @@ -0,0 +1,244 @@ +"""Read-only workbench runtime aggregation.""" + +import logging +from contextlib import asynccontextmanager +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from psycopg import AsyncConnection +from pydantic import ValidationError + +from app.auth.project_dependencies import ( + ProjectContext, + get_project_context, + get_project_pg_connection, +) +from app.domain.schemas.workbench_runtime import ( + WorkbenchAnalysisHistoryPage, + WorkbenchPersistedAlert, + WorkbenchRuntimeLatestAnalysis, + WorkbenchRuntimeSnapshot, + WorkbenchRuntimeWorkOrder, +) +from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState +from app.infra.db.postgresql.analysis import AnalysisRepository +from app.infra.db.postgresql.workbench_operations import ( + WorkbenchOperationsRepository, +) +from app.infra.db.postgresql.task_alerts import TaskAlertRepository +from app.services.task_alerts import TaskAlertService +from app.domain.domain_errors import DomainError +from app.services.workbench_runtime import ( + build_workbench_runtime_snapshot, + project_analysis_history_item, + project_latest_analysis, + project_sensor_location_map_payload, + sensor_location_ids_for_map_projection, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def _projection_transaction(conn: AsyncConnection): + """Isolate an optional projection so one SQL failure cannot poison others.""" + transaction = getattr(conn, "transaction", None) + if transaction is None: # Lightweight endpoint test doubles. + yield + return + async with transaction(): + yield + + +@router.get( + "/workbench/analysis-runs", + response_model=WorkbenchAnalysisHistoryPage, +) +async def workbench_analysis_runs( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + """Return a bounded, sanitized analysis history page for the workbench.""" + rows = await AnalysisRepository.list_run_summaries( + conn, + limit=limit + 1, + offset=offset, + ) + has_more = len(rows) > limit + items = rows[:limit] + return { + "items": [project_analysis_history_item(run) for run in items], + "limit": limit, + "offset": offset, + "next_offset": offset + limit if has_more else None, + } + + +@router.get( + "/workbench/analysis-runs/{run_id}", + response_model=WorkbenchRuntimeLatestAnalysis, +) +async def workbench_analysis_run( + run_id: UUID, + conn: AsyncConnection = Depends(get_project_pg_connection), +): + """Return one authorized project's analysis run as a typed workbench artifact.""" + run = await AnalysisRepository.get_run(conn, run_id) + if run is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Analysis run not found", + ) + results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100) + try: + referenced_map_payload = await _sensor_location_map_payload(conn, results) + except Exception: + referenced_map_payload = None + logger.warning( + "Workbench analysis spatial references are unavailable", + exc_info=True, + ) + return project_latest_analysis( + run, + results, + referenced_map_payload=referenced_map_payload, + ) + + +@router.post("/workbench/task-alert-events", response_model=TaskAlertState) +async def push_task_alert_event( + command: TaskAlertCommand, + ctx: ProjectContext = Depends(get_project_context), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + event = TaskAlertEvent( + **command.model_dump(), + producer_id=f"principal:{ctx.user_id}", + ) + return await TaskAlertService( + TaskAlertRepository(conn, ctx.project_id), ctx + ).push(event) + except DomainError as exc: + raise HTTPException(status_code=exc.status, detail=exc.message) from exc + + +@router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot) +async def workbench_runtime( + ctx: ProjectContext = Depends(get_project_context), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + analysis_runs: list[dict] | None = None + latest_results: list[dict] | None = None + latest_analysis_map_payload: dict | None = None + analysis_availability = "not_configured" + work_order_storage_configured = False + work_orders: list[dict] | None = None + alert_storage_configured = False + alerts: list[dict] | None = None + schedule_availability = "not_configured" + work_order_availability = "not_configured" + alert_availability = "not_configured" + try: + async with _projection_transaction(conn): + analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100) + if analysis_runs: + latest_results = await AnalysisRepository.list_recent_results( + conn, analysis_runs[0]["run_id"], limit=100 + ) + analysis_availability = "ready" if analysis_runs else "not_configured" + except Exception: + logger.warning("Workbench analysis projection is unavailable", exc_info=True) + analysis_runs = None + latest_results = None + analysis_availability = "degraded" + if analysis_runs and latest_results: + try: + async with _projection_transaction(conn): + latest_analysis_map_payload = await _sensor_location_map_payload( + conn, latest_results + ) + except Exception: + logger.warning( + "Workbench analysis spatial references are unavailable", + exc_info=True, + ) + try: + async with _projection_transaction(conn): + operations_availability = await WorkbenchOperationsRepository.availability(conn) + work_order_storage_configured = operations_availability.work_orders + alert_storage_configured = operations_availability.alerts + except Exception: + logger.warning("Workbench operations storage probe is unavailable", exc_info=True) + schedule_availability = "degraded" + work_order_availability = "degraded" + alert_availability = "degraded" + if work_order_storage_configured: + try: + async with _projection_transaction(conn): + work_order_documents = await WorkbenchOperationsRepository.list_work_orders( + conn, str(ctx.project_id) + ) + work_orders, invalid_work_orders = _validate_documents( + work_order_documents, WorkbenchRuntimeWorkOrder, "work order" + ) + work_order_availability = ( + "degraded" if invalid_work_orders else "ready" + ) + except Exception: + work_orders = None + work_order_availability = "degraded" + logger.warning("Workbench work-order projection is unavailable", exc_info=True) + if alert_storage_configured: + try: + async with _projection_transaction(conn): + alert_documents = await WorkbenchOperationsRepository.list_alerts( + conn, str(ctx.project_id) + ) + alerts, invalid_alerts = _validate_documents( + alert_documents, WorkbenchPersistedAlert, "alert" + ) + alert_availability = "degraded" if invalid_alerts else "ready" + except Exception: + alerts = None + alert_availability = "degraded" + logger.warning("Workbench alert projection is unavailable", exc_info=True) + return await build_workbench_runtime_snapshot( + analysis_runs=analysis_runs, + latest_analysis_results=latest_results, + latest_analysis_map_payload=latest_analysis_map_payload, + work_order_storage_configured=work_order_storage_configured, + work_orders=work_orders, + alert_storage_configured=alert_storage_configured, + persisted_alerts=alerts, + scenario_availability=analysis_availability, + artifact_availability=analysis_availability, + schedule_availability=schedule_availability, + work_order_availability=work_order_availability, + alert_availability=alert_availability, + projection_degraded=False, + ) + + +async def _sensor_location_map_payload( + conn: AsyncConnection, results: list[dict] +) -> dict | None: + node_ids = sensor_location_ids_for_map_projection(results) + if not node_ids: + return None + map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids) + return project_sensor_location_map_payload(node_ids, map_points) + + +def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]: + valid: list[dict] = [] + rejected = 0 + for document in documents: + try: + valid.append(model.model_validate(document).model_dump()) + except ValidationError: + rejected += 1 + logger.warning("Workbench %s document was rejected", label) + return valid, rejected diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 1cd7a6d..e620ba2 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -17,6 +17,7 @@ from app.api.v1.endpoints import ( sensor_placement, simulation, web_search, + workbench_runtime, ) from app.api.v1.endpoints.components import ( controls, @@ -85,6 +86,11 @@ burst_run_access = Depends(require_permission(BURST_RUN)) optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN)) # Core services +api_router.include_router( + workbench_runtime.router, + tags=["Workbench Runtime"], + dependencies=[simulation_access], +) api_router.include_router(access.router, tags=["Access Control"]) api_router.include_router(agent_auth.router, tags=["Agent Auth"]) api_router.include_router( diff --git a/app/core/config.py b/app/core/config.py index 273e612..89e3acc 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -9,7 +9,6 @@ class Settings(BaseSettings): ENVIRONMENT: str = "production" API_V1_STR: str = "/api/v1" NETWORK_NAME: str = "default_network" - # 敏感配置加密密钥 (Fernet) DATABASE_ENCRYPTION_KEY: str = "" diff --git a/app/domain/domain_errors.py b/app/domain/domain_errors.py new file mode 100644 index 0000000..bf19de8 --- /dev/null +++ b/app/domain/domain_errors.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +class DomainError(Exception): + """A domain rejection with a stable API error payload.""" + + def __init__(self, code: str, message: str, *, refs: list | None = None, + recovery: str = "none", status: int = 422): + super().__init__(message) + self.code, self.message, self.status = code, message, status + self.refs, self.recovery = refs or [], recovery + + def payload(self) -> dict: + return {"code": self.code, "message": self.message, + "retryable": self.recovery == "retry_same_request", + "related_refs": self.refs, "recovery_action": self.recovery, + "retry_after_seconds": None} + + +def require(condition: bool, code: str, message: str, **kwargs) -> None: + if not condition: + raise DomainError(code, message, **kwargs) diff --git a/app/domain/schemas/gis_attributes.py b/app/domain/schemas/gis_attributes.py new file mode 100644 index 0000000..c1431c9 --- /dev/null +++ b/app/domain/schemas/gis_attributes.py @@ -0,0 +1,47 @@ +"""Schemas for project-scoped source GIS attributes.""" + +from datetime import date +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +GisElementType = Literal[ + "pipes", + "junctions", + "valves", + "reservoirs", + "pumps", + "tanks", + "scada", +] + + +class GisAttributeGroup(BaseModel): + model_config = ConfigDict(extra="forbid") + + dataset_key: str + dataset_title: str + source_date: date + layer_key: str + layer_name: str + source_fid: int + match_method: Literal["node_pair", "geometry", "source"] + match_distance_m: float | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + +class ElementGisProperties(BaseModel): + model_config = ConfigDict(extra="forbid") + + element_type: GisElementType + element_id: str + groups: list[GisAttributeGroup] = Field(default_factory=list) + + +class SourceGisFeatureProperties(BaseModel): + model_config = ConfigDict(extra="forbid") + + layer_key: str + source_fid: int + group: GisAttributeGroup diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py index 3852811..dd019e3 100644 --- a/app/domain/schemas/sensor_placement.py +++ b/app/domain/schemas/sensor_placement.py @@ -25,7 +25,7 @@ class SensorPlacementOptimizeRequest(BaseModel): pattern=r"^[^/\\\x00]+$", ) run_name: str = Field(..., min_length=1, max_length=64) - sensor_type: Literal["pressure"] + sensor_type: Literal["pressure", "water_quality"] method: Literal["sensitivity", "kmeans"] sensor_count: int = Field(..., gt=0, le=200) min_diameter: int = Field(default=0, ge=0) @@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel): created_by: str created_at: datetime status: str + sensor_type: Literal["pressure", "water_quality"] = "pressure" + method: Literal["sensitivity", "kmeans"] | None = None sensor_locations: list[str] sensor_points: list[SensorPointResponse] can_edit: bool = False diff --git a/app/domain/schemas/shared.py b/app/domain/schemas/shared.py new file mode 100644 index 0000000..be67f76 --- /dev/null +++ b/app/domain/schemas/shared.py @@ -0,0 +1,58 @@ +"""Small strict value types shared by independent backend features.""" + +from datetime import datetime +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, model_validator + +NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] +StrictBool = Annotated[bool, Field(strict=True)] +PositiveInt = Annotated[int, Field(strict=True, ge=1)] +NonNegativeInt = Annotated[int, Field(strict=True, ge=0)] + + +def _datetime_wire_value(value: object) -> datetime | str: + if not isinstance(value, (datetime, str)): + raise ValueError("datetime input must be an RFC3339 string or datetime instance") + return value + + +WireDateTime = Annotated[datetime, BeforeValidator(_datetime_wire_value)] + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + +ObjectType = Literal[ + "analysis_task", + "goal", + "state_snapshot", + "action_contract", + "validation", + "run", + "outcome", + "evidence", + "artifact", + "operation", +] + + +class ObjectRef(StrictModel): + object_type: ObjectType + object_id: UUID + revision: PositiveInt + + +class TimeRange(StrictModel): + start: WireDateTime + end: WireDateTime + + @model_validator(mode="after") + def validate_range(self) -> "TimeRange": + if self.start.tzinfo is None or self.end.tzinfo is None: + raise ValueError("timestamps must include a timezone") + if self.start >= self.end: + raise ValueError("start must be before end") + return self diff --git a/app/domain/schemas/task_alert.py b/app/domain/schemas/task_alert.py new file mode 100644 index 0000000..3b8a5b4 --- /dev/null +++ b/app/domain/schemas/task_alert.py @@ -0,0 +1,38 @@ +"""Idempotent alert events emitted by scheduled and other backend tasks.""" +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel +from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime + + +class TaskAlertCommand(StrictModel): + event_id: UUID + alert_id: NonEmptyStr + event_type: Literal["raised", "acknowledged", "resolved"] + occurred_at: WorkbenchDateTime + condition_id: NonEmptyStr | None + source: WorkbenchAlertSource + title: NonEmptyStr + description: NonEmptyStr + severity: Literal["warning", "critical"] + + +class TaskAlertEvent(TaskAlertCommand): + producer_id: NonEmptyStr + + +class TaskAlertState(StrictModel): + alert_id: NonEmptyStr + revision: PositiveInt + producer_id: NonEmptyStr + condition_id: NonEmptyStr | None + source: WorkbenchAlertSource + title: NonEmptyStr + description: NonEmptyStr + occurred_at: WorkbenchDateTime + severity: Literal["warning", "critical"] + status: Literal["active", "acknowledged", "resolved"] + updated_at: WorkbenchDateTime diff --git a/app/domain/schemas/workbench_runtime.py b/app/domain/schemas/workbench_runtime.py new file mode 100644 index 0000000..3b084e5 --- /dev/null +++ b/app/domain/schemas/workbench_runtime.py @@ -0,0 +1,209 @@ +"""Typed read-only projections consumed by the supply workbench.""" + +from typing import Annotated, Literal + +from pydantic import AwareDatetime, BeforeValidator, JsonValue, model_validator + +from app.domain.schemas.shared import ( + NonEmptyStr, + NonNegativeInt, + PositiveInt, + StrictBool, + StrictModel, +) + + +def _workbench_datetime_wire_value(value: object): + from datetime import datetime + + if not isinstance(value, (datetime, str)): + raise ValueError("datetime input must be an RFC3339 string or datetime instance") + return value + + +WorkbenchDateTime = Annotated[ + AwareDatetime, BeforeValidator(_workbench_datetime_wire_value) +] +ProjectionAvailability = Literal["ready", "not_configured", "degraded"] + + +class WorkbenchRuntimeCondition(StrictModel): + id: NonEmptyStr + task_id: Literal["backend-schedule"] + session_id: NonEmptyStr + title: NonEmptyStr + summary: NonEmptyStr + status: Literal["pending", "running", "completed", "warning", "error"] + risk_level: Literal["normal", "attention", "critical"] + scheduled_at: WorkbenchDateTime + updated_at: WorkbenchDateTime + detail: NonEmptyStr + recommendation: NonEmptyStr | None + evidence: list[NonEmptyStr] + model_name: Literal["TJWater scheduler"] + + +class WorkbenchAlertSource(StrictModel): + task_type: Literal["scheduled_task", "analysis_task", "other_task"] + task_id: NonEmptyStr + run_id: NonEmptyStr + + +class WorkbenchRuntimeAlert(StrictModel): + id: NonEmptyStr + condition_id: NonEmptyStr | None + source: WorkbenchAlertSource + title: NonEmptyStr + description: NonEmptyStr + occurred_at: WorkbenchDateTime + severity: Literal["warning", "critical"] + + +class WorkbenchPersistedAlert(StrictModel): + alert_id: NonEmptyStr + condition_id: NonEmptyStr | None + source: WorkbenchAlertSource + title: NonEmptyStr + description: NonEmptyStr + occurred_at: WorkbenchDateTime + severity: Literal["warning", "critical"] + status: Literal["active", "acknowledged", "resolved"] + producer_id: NonEmptyStr | None = None + updated_at: WorkbenchDateTime | None = None + + +class WorkbenchRuntimeScenario(StrictModel): + id: NonEmptyStr + run_id: NonEmptyStr + name: NonEmptyStr + description: NonEmptyStr + status: Literal["draft", "active", "review"] + run_type: NonEmptyStr + run_type_label: NonEmptyStr + run_status: NonEmptyStr + created_at: WorkbenchDateTime + + +class WorkbenchRuntimeAnalysisFact(StrictModel): + label: NonEmptyStr + value: NonEmptyStr + + +class WorkbenchRuntimeAnalysisResult(StrictModel): + result_type: NonEmptyStr + created_at: WorkbenchDateTime + facts: list[WorkbenchRuntimeAnalysisFact] + + +class WorkbenchRuntimeLatestAnalysis(StrictModel): + run_id: NonEmptyStr + name: NonEmptyStr + run_type: NonEmptyStr + run_type_label: NonEmptyStr + status: NonEmptyStr + status_label: NonEmptyStr + created_by: NonEmptyStr + created_at: WorkbenchDateTime + started_at: WorkbenchDateTime + results: list[WorkbenchRuntimeAnalysisResult] + map_payload: dict[str, JsonValue] | None + + +class WorkbenchAnalysisHistoryItem(StrictModel): + run_id: NonEmptyStr + name: NonEmptyStr + run_type: NonEmptyStr + run_type_label: NonEmptyStr + status: NonEmptyStr + status_label: NonEmptyStr + created_by: NonEmptyStr + created_at: WorkbenchDateTime + started_at: WorkbenchDateTime + has_results: StrictBool + + +class WorkbenchAnalysisHistoryPage(StrictModel): + items: list[WorkbenchAnalysisHistoryItem] + limit: PositiveInt + offset: NonNegativeInt + next_offset: NonNegativeInt | None + + +class WorkbenchRuntimeWorkOrderStage(StrictModel): + id: NonEmptyStr + sequence: PositiveInt + title: NonEmptyStr + status: Literal["done", "current", "pending", "blocked", "skipped"] + owner: NonEmptyStr + time_label: NonEmptyStr + description: NonEmptyStr + + +class WorkbenchRuntimeWorkOrderSubtask(StrictModel): + id: NonEmptyStr + title: NonEmptyStr + summary: NonEmptyStr + status: Literal["pending", "running", "blocked", "completed", "cancelled"] + scheduled_at: WorkbenchDateTime + location: NonEmptyStr + assignee: NonEmptyStr + duration_minutes: PositiveInt + reply_window_minutes: PositiveInt + stages: list[WorkbenchRuntimeWorkOrderStage] + reply_requirements: list[NonEmptyStr] + + @model_validator(mode="after") + def validate_stages(self): + if not self.stages: + raise ValueError("subtask stages must not be empty") + if [stage.sequence for stage in self.stages] != list( + range(1, len(self.stages) + 1) + ): + raise ValueError("subtask stages must use contiguous sequence numbers") + if len({stage.id for stage in self.stages}) != len(self.stages): + raise ValueError("subtask stage ids must be unique") + if sum(stage.status == "current" for stage in self.stages) > 1: + raise ValueError("subtask stages may contain at most one current stage") + return self + + +class WorkbenchRuntimeWorkOrder(StrictModel): + id: NonEmptyStr + code: NonEmptyStr + scheduled_at: WorkbenchDateTime + title: NonEmptyStr + summary: NonEmptyStr + status: Literal["pending", "running", "awaiting_reply", "completed"] + risk_level: Literal["normal", "attention", "critical"] + updated_at: WorkbenchDateTime + detail: NonEmptyStr + recommendation: NonEmptyStr | None + duration_minutes: PositiveInt + source: NonEmptyStr + dispatcher: NonEmptyStr + priority: Literal["normal", "urgent"] + subtasks: list[WorkbenchRuntimeWorkOrderSubtask] + + @model_validator(mode="after") + def validate_subtasks(self): + if not self.subtasks: + raise ValueError("work order subtasks must not be empty") + if len({subtask.id for subtask in self.subtasks}) != len(self.subtasks): + raise ValueError("work order subtask ids must be unique") + return self + + +class WorkbenchRuntimeSnapshot(StrictModel): + availability: ProjectionAvailability + source: Literal["none", "scheduled_tasks"] + scenario_availability: ProjectionAvailability + artifact_availability: ProjectionAvailability + schedule_availability: ProjectionAvailability + work_order_availability: ProjectionAvailability + alert_availability: ProjectionAvailability + generated_at: WorkbenchDateTime + conditions: list[WorkbenchRuntimeCondition] + alerts: list[WorkbenchRuntimeAlert] + scenarios: list[WorkbenchRuntimeScenario] + latest_analysis: WorkbenchRuntimeLatestAnalysis | None + work_orders: list[WorkbenchRuntimeWorkOrder] diff --git a/app/infra/db/postgresql/analysis.py b/app/infra/db/postgresql/analysis.py index 03da960..682805f 100644 --- a/app/infra/db/postgresql/analysis.py +++ b/app/infra/db/postgresql/analysis.py @@ -101,7 +101,11 @@ class AnalysisRepository: return cur.fetchone() @staticmethod - async def list_runs(conn: AsyncConnection) -> list[dict]: + async def list_runs( + conn: AsyncConnection, *, limit: int = 100, offset: int = 0 + ) -> list[dict]: + if not 1 <= limit <= 100 or offset < 0: + raise ValueError("analysis run page is out of range") async with conn.cursor() as cur: await cur.execute( """ @@ -109,7 +113,52 @@ class AnalysisRepository: started_at, status, parameters FROM analysis.runs ORDER BY created_at DESC, run_id + LIMIT %s OFFSET %s + """, + (limit, offset), + ) + return await cur.fetchall() + + @staticmethod + async def list_recent_runs( + conn: AsyncConnection, *, limit: int = 100 + ) -> list[dict]: + if not 1 <= limit <= 100: + raise ValueError("recent analysis run limit must be between 1 and 100") + async with conn.cursor() as cur: + await cur.execute( """ + SELECT run_id, name, run_type, created_by, created_at, + started_at, status, parameters + FROM analysis.runs + ORDER BY created_at DESC, run_id + LIMIT %s + """, + (limit,), + ) + return await cur.fetchall() + + @staticmethod + async def list_run_summaries( + conn: AsyncConnection, *, limit: int = 21, offset: int = 0 + ) -> list[dict]: + if not 1 <= limit <= 101 or offset < 0: + raise ValueError("analysis run summary page is out of range") + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT r.run_id, r.name, r.run_type, r.created_by, + r.created_at, r.started_at, r.status, + EXISTS ( + SELECT 1 + FROM analysis.results AS result + WHERE result.run_id = r.run_id + ) AS has_results + FROM analysis.runs AS r + ORDER BY r.created_at DESC, r.run_id + LIMIT %s OFFSET %s + """, + (limit, offset), ) return await cur.fetchall() @@ -129,20 +178,75 @@ class AnalysisRepository: @staticmethod async def list_results( - conn: AsyncConnection, run_id: UUID, result_type: str | None = None + conn: AsyncConnection, + run_id: UUID, + result_type: str | None = None, + *, + limit: int = 100, + offset: int = 0, ) -> list[dict]: + if not 1 <= limit <= 500 or offset < 0: + raise ValueError("analysis result page is out of range") query = """ SELECT result_id, run_id, result_type, node_id, link_id, payload, created_at FROM analysis.results WHERE run_id = %s """ - params: tuple[UUID] | tuple[UUID, str] = (run_id,) + params: tuple = (run_id,) if result_type is not None: query += " AND result_type = %s" params = (run_id, result_type) - query += " ORDER BY created_at, result_id" + query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s" + params += (limit, offset) async with conn.cursor() as cur: await cur.execute(query, params) return await cur.fetchall() + + @staticmethod + async def list_recent_results( + conn: AsyncConnection, run_id: UUID, *, limit: int = 100 + ) -> list[dict]: + if not 1 <= limit <= 100: + raise ValueError("recent analysis result limit must be between 1 and 100") + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT result_id, run_id, result_type, node_id, link_id, + payload, created_at + FROM ( + SELECT result_id, run_id, result_type, node_id, link_id, + payload, created_at + FROM analysis.results + WHERE run_id = %s + ORDER BY created_at DESC, result_id DESC + LIMIT %s + ) AS recent_results + ORDER BY created_at, result_id + """, + (run_id, limit), + ) + return await cur.fetchall() + + @staticmethod + async def list_junction_map_points( + conn: AsyncConnection, node_ids: list[str] + ) -> list[dict]: + if not node_ids: + return [] + if len(node_ids) > 500: + raise ValueError("analysis map projection supports at most 500 junctions") + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT g.id AS node_id, + ST_X(ST_Transform(g.geom, 4326)) AS longitude, + ST_Y(ST_Transform(g.geom, 4326)) AS latitude + FROM gis.junctions AS g + WHERE g.id = ANY(%s) + ORDER BY g.id + """, + (node_ids,), + ) + return await cur.fetchall() diff --git a/app/infra/db/postgresql/gis_attributes.py b/app/infra/db/postgresql/gis_attributes.py new file mode 100644 index 0000000..5fe1a5a --- /dev/null +++ b/app/infra/db/postgresql/gis_attributes.py @@ -0,0 +1,80 @@ +"""Read-only access to imported source GIS attributes.""" + +from typing import Any + +from psycopg import AsyncConnection + + +class GisAttributeRepository: + @staticmethod + async def list_element_groups( + conn: AsyncConnection, + element_type: str, + element_id: str, + ) -> list[dict[str, Any]]: + async with conn.cursor() as cur: + await cur.execute( + "SELECT to_regclass('source_gis.element_links') AS relation" + ) + availability = await cur.fetchone() + if not availability or availability["relation"] is None: + return [] + await cur.execute( + """ + SELECT + dataset.dataset_key, + dataset.title AS dataset_title, + dataset.source_date, + layer.layer_key, + layer.display_name AS layer_name, + feature.source_fid, + link.match_method, + link.distance_m AS match_distance_m, + feature.attributes + FROM source_gis.element_links AS link + JOIN source_gis.features AS feature + ON feature.layer_key = link.layer_key + AND feature.source_fid = link.source_fid + JOIN source_gis.layers AS layer + ON layer.layer_key = feature.layer_key + JOIN source_gis.datasets AS dataset + ON dataset.dataset_key = layer.dataset_key + WHERE link.element_type = %s + AND link.element_id = %s + ORDER BY dataset.source_date DESC, layer.display_name, feature.source_fid + """, + (element_type, element_id), + ) + return [dict(row) for row in await cur.fetchall()] + + @staticmethod + async def get_source_feature_group( + conn: AsyncConnection, + layer_key: str, + source_fid: int, + ) -> dict[str, Any] | None: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT + dataset.dataset_key, + dataset.title AS dataset_title, + dataset.source_date, + layer.layer_key, + layer.display_name AS layer_name, + feature.source_fid, + 'source' AS match_method, + NULL::double precision AS match_distance_m, + feature.attributes + FROM source_gis.features AS feature + JOIN source_gis.layers AS layer + ON layer.layer_key = feature.layer_key + JOIN source_gis.datasets AS dataset + ON dataset.dataset_key = layer.dataset_key + WHERE feature.layer_key = %s + AND feature.source_fid = %s + """, + (layer_key, source_fid), + ) + row = await cur.fetchone() + return dict(row) if row else None diff --git a/app/infra/db/postgresql/sensor_placement.py b/app/infra/db/postgresql/sensor_placement.py index 31903ae..9aa704f 100644 --- a/app/infra/db/postgresql/sensor_placement.py +++ b/app/infra/db/postgresql/sensor_placement.py @@ -24,6 +24,8 @@ def _placement_row(row: dict[str, Any]) -> dict[str, Any]: "created_by": row["created_by"], "created_at": row["created_at"], "status": row["status"], + "sensor_type": payload.get("sensor_type", "pressure"), + "method": payload.get("method"), "sensor_locations": locations, } @@ -57,11 +59,15 @@ def create_sensor_placement( min_diameter: int, created_by: str, sensor_locations: list[str], + sensor_type: str = "pressure", + method: str | None = None, ) -> dict[str, Any]: run_id = uuid4() payload = { "sensor_number": len(sensor_locations), "minimum_diameter": min_diameter, + "sensor_type": sensor_type, + "method": method, "sensor_locations": sensor_locations, } with project_connection(name) as conn, conn.transaction(): diff --git a/app/infra/db/postgresql/task_alerts.py b/app/infra/db/postgresql/task_alerts.py new file mode 100644 index 0000000..40aa438 --- /dev/null +++ b/app/infra/db/postgresql/task_alerts.py @@ -0,0 +1,88 @@ +"""PostgreSQL persistence for task-produced alert events and current state.""" +from __future__ import annotations + +from contextlib import asynccontextmanager + +from psycopg import AsyncConnection +from psycopg.errors import UndefinedTable +from psycopg.types.json import Jsonb + +from app.domain.domain_errors import DomainError + + +class TaskAlertRepository: + def __init__(self, connection: AsyncConnection, project_id): + self.connection = connection + self.project_id = str(project_id) + + @asynccontextmanager + async def transaction(self): + try: + async with self.connection.transaction(): + yield self + except UndefinedTable as exc: + raise DomainError( + "DEPENDENCY_UNAVAILABLE", + "Project alert storage is not provisioned", + status=503, + ) from exc + + async def lock_alert(self, alert_id: str) -> None: + await self.connection.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (f"tjwater-alert:{self.project_id}:{alert_id}",), + ) + + async def get_event(self, producer_id: str, event_id: str) -> dict | None: + cursor = await self.connection.execute( + """SELECT document FROM workbench.alert_events + WHERE project_id=%s AND producer_id=%s AND event_id=%s""", + (self.project_id, producer_id, event_id), + ) + row = await cursor.fetchone() + return row["document"] if row else None + + async def get_alert(self, alert_id: str) -> tuple[int, dict] | None: + cursor = await self.connection.execute( + """SELECT revision,document FROM workbench.alerts + WHERE project_id=%s AND alert_id=%s""", + (self.project_id, alert_id), + ) + row = await cursor.fetchone() + return (row["revision"], row["document"]) if row else None + + async def save_event(self, event: dict, resulting_state: dict) -> None: + document = event | {"resulting_state": resulting_state} + await self.connection.execute( + """INSERT INTO workbench.alert_events + (project_id,producer_id,event_id,alert_id,event_type,occurred_at,document) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + ( + self.project_id, + event["producer_id"], + event["event_id"], + event["alert_id"], + event["event_type"], + event["occurred_at"], + Jsonb(document), + ), + ) + + async def save_alert(self, state: dict) -> None: + await self.connection.execute( + """INSERT INTO workbench.alerts + (project_id,alert_id,revision,status,occurred_at,updated_at,document) + VALUES (%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT(project_id,alert_id) DO UPDATE SET + revision=excluded.revision,status=excluded.status, + updated_at=excluded.updated_at,document=excluded.document""", + ( + self.project_id, + state["alert_id"], + state["revision"], + state["status"], + state["occurred_at"], + state["updated_at"], + Jsonb({key: value for key, value in state.items() if key != "revision"}), + ), + ) diff --git a/app/infra/db/postgresql/workbench_operations.py b/app/infra/db/postgresql/workbench_operations.py new file mode 100644 index 0000000..aeb1ac2 --- /dev/null +++ b/app/infra/db/postgresql/workbench_operations.py @@ -0,0 +1,70 @@ +"""Optional project-local schedule, work-order and alert projections.""" + +from dataclasses import dataclass +from typing import Any + +from psycopg import AsyncConnection + + +@dataclass(frozen=True) +class WorkbenchOperationsAvailability: + work_orders: bool + alerts: bool + schedules: bool = False + + +class WorkbenchOperationsRepository: + """Read provisioned workbench records without creating or mutating storage.""" + + @staticmethod + async def availability( + conn: AsyncConnection, + ) -> WorkbenchOperationsAvailability: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT + to_regclass('workbench.work_orders') IS NOT NULL AS work_orders, + to_regclass('workbench.alerts') IS NOT NULL AS alerts + """ + ) + row = await cur.fetchone() + return WorkbenchOperationsAvailability( + work_orders=bool(row and row["work_orders"]), + alerts=bool(row and row["alerts"]), + ) + + @staticmethod + async def list_work_orders( + conn: AsyncConnection, project_id: str, *, limit: int = 100 + ) -> list[dict[str, Any]]: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT document + FROM workbench.work_orders + WHERE project_id = %s + ORDER BY updated_at DESC, work_order_id + LIMIT %s + """, + (project_id, limit), + ) + return [row["document"] for row in await cur.fetchall()] + + @staticmethod + async def list_alerts( + conn: AsyncConnection, project_id: str, *, limit: int = 100 + ) -> list[dict[str, Any]]: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT document + FROM workbench.alerts + WHERE project_id = %s + AND status = 'active' + ORDER BY occurred_at DESC, alert_id + LIMIT %s + """, + (project_id, limit), + ) + return [row["document"] for row in await cur.fetchall()] diff --git a/app/infra/geoserver/client.py b/app/infra/geoserver/client.py index 0efaff6..6438edb 100644 --- a/app/infra/geoserver/client.py +++ b/app/infra/geoserver/client.py @@ -94,6 +94,89 @@ class GeoServerAdminClient: ) return True + def feature_type_exists( + self, + *, + workspace: str, + datastore: str, + layer: str, + ) -> bool: + response = self._client.get( + f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}.json" + ) + if response.status_code == 404: + return False + if response.is_error: + raise GeoServerProvisioningError( + f"GeoServer feature type check failed with HTTP {response.status_code}" + ) + return True + + def publish_feature_type( + self, + *, + workspace: str, + datastore: str, + layer: str, + title: str, + map_bbox: tuple[float, float, float, float], + ) -> None: + minx, miny, maxx, maxy = map_bbox + payload = { + "featureType": { + "name": layer, + "nativeName": layer, + "title": title, + "srs": "EPSG:3857", + "projectionPolicy": "FORCE_DECLARED", + "enabled": True, + "advertised": True, + "nativeBoundingBox": { + "minx": minx, + "miny": miny, + "maxx": maxx, + "maxy": maxy, + "crs": "EPSG:3857", + }, + "latLonBoundingBox": { + "minx": _longitude(minx), + "miny": _latitude(miny), + "maxx": _longitude(maxx), + "maxy": _latitude(maxy), + "crs": "EPSG:4326", + }, + } + } + if self.feature_type_exists( + workspace=workspace, + datastore=datastore, + layer=layer, + ): + self._request( + "PUT", + f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}", + json=payload, + ) + else: + self._request( + "POST", + f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes", + json=payload, + ) + self._set_client_cache(workspace, layer) + + def truncate_layer_cache(self, workspace: str, layer: str) -> None: + self._request( + "POST", + "/gwc/rest/masstruncate", + content=( + "" + f"{workspace}:{layer}" + "" + ), + headers={"Content-Type": "text/xml"}, + ) + def create_project_workspace( self, *, diff --git a/app/main.py b/app/main.py index a71bd87..1b31c0b 100644 --- a/app/main.py +++ b/app/main.py @@ -28,13 +28,14 @@ async def lifespan(app: FastAPI): logger.info("TJWater CloudService is starting...") logger.info("**********************************************************") - yield - # 清理资源 - await project_connection_manager.close_all() - close_all_timescale_pools() - close_all_project_pools() - await close_metadata_engine() - logger.info("Database connections closed") + try: + yield + finally: + await project_connection_manager.close_all() + close_all_timescale_pools() + close_all_project_pools() + await close_metadata_engine() + logger.info("Database connections closed") # 根据环境配置决定是否启用文档 @@ -55,7 +56,7 @@ app = FastAPI( # Include Routers app.include_router(api_router, prefix="/api/v1") install_problem_details_handlers(app) -# Legcy Routers without version prefix +# Legacy routers without version prefix # app.include_router(api_router) # 配置中间件 diff --git a/app/services/content_digest.py b/app/services/content_digest.py new file mode 100644 index 0000000..1893b05 --- /dev/null +++ b/app/services/content_digest.py @@ -0,0 +1,18 @@ +"""RFC 8785 canonical JSON hashing shared by persisted records.""" + +from hashlib import sha256 + +import rfc8785 + +from app.domain.domain_errors import DomainError + + +def canonical(value: object) -> bytes: + try: + return rfc8785.dumps(value) + except rfc8785.CanonicalizationError as exc: + raise DomainError("SCHEMA_INVALID", "Content is not canonicalizable JSON") from exc + + +def digest(value: object) -> str: + return "sha256:" + sha256(canonical(value)).hexdigest() diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py index 9472431..74397a5 100644 --- a/app/services/sensor_placement.py +++ b/app/services/sensor_placement.py @@ -80,6 +80,8 @@ def _create_validated_placement( min_diameter: int, created_by: str, sensor_locations: list[str], + sensor_type: str = "pressure", + method: str | None = None, ) -> dict[str, Any]: validate_sensor_placement_nodes(project_code, sensor_locations) return sensor_placement_repository.create_sensor_placement( @@ -88,6 +90,8 @@ def _create_validated_placement( min_diameter=min_diameter, created_by=created_by, sensor_locations=sensor_locations, + sensor_type=sensor_type, + method=method, ) @@ -97,6 +101,7 @@ def optimize_sensor_placement_by_sensitivity( sensor_count: int, min_diameter: int, created_by: str, + sensor_type: str = "pressure", ) -> dict[str, Any]: """Run sensitivity placement and persist the validated result.""" @@ -112,6 +117,8 @@ def optimize_sensor_placement_by_sensitivity( min_diameter=min_diameter, created_by=created_by, sensor_locations=sensor_locations, + sensor_type=sensor_type, + method="sensitivity", ) @@ -121,6 +128,7 @@ def optimize_sensor_placement_by_kmeans( sensor_count: int, min_diameter: int, created_by: str, + sensor_type: str = "pressure", ) -> dict[str, Any]: """Export the model, run K-means placement, and persist the result.""" @@ -136,6 +144,8 @@ def optimize_sensor_placement_by_kmeans( min_diameter=min_diameter, created_by=created_by, sensor_locations=sensor_locations, + sensor_type=sensor_type, + method="kmeans", ) diff --git a/app/services/task_alerts.py b/app/services/task_alerts.py new file mode 100644 index 0000000..f1ccb27 --- /dev/null +++ b/app/services/task_alerts.py @@ -0,0 +1,124 @@ +"""Monotonic, idempotent state machine for task-produced alerts.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.domain.schemas.task_alert import TaskAlertEvent, TaskAlertState +from app.domain.domain_errors import require +from app.services.content_digest import digest + + +class TaskAlertService: + def __init__( + self, + repository, + context, + *, + clock=None, + internal_producer_id: str | None = None, + ): + self.repo = repository + self.context = context + self.clock = clock or (lambda: datetime.now(timezone.utc)) + self.internal_producer_id = internal_producer_id + + async def push(self, command: TaskAlertEvent) -> TaskAlertState: + if self.internal_producer_id is None: + require( + self.context is not None and self.context.project_role == "member", + "PERMISSION_DENIED", + "Alert events require project write permission", + status=403, + ) + require( + command.producer_id == f"principal:{self.context.user_id}", + "PERMISSION_DENIED", + "Alert producer must match the authenticated project principal", + status=403, + ) + else: + require( + command.producer_id == self.internal_producer_id, + "PERMISSION_DENIED", + "Internal alert producer identity does not match", + status=403, + ) + event = command.model_dump(mode="json") + async with self.repo.transaction(): + await self.repo.lock_alert(command.alert_id) + prior_event = await self.repo.get_event( + command.producer_id, str(command.event_id) + ) + if prior_event is not None: + prior_command = { + key: value for key, value in prior_event.items() + if key != "resulting_state" + } + require( + digest(prior_command) == digest(event), + "IDEMPOTENCY_CONFLICT", + "event_id was used with a different alert event", + status=409, + ) + return TaskAlertState.model_validate(prior_event["resulting_state"]) + + current = await self.repo.get_alert(command.alert_id) + state = self._transition(command, current) + state_document = state.model_dump(mode="json") + await self.repo.save_event(event, state_document) + await self.repo.save_alert(state_document) + return state + + def _transition( + self, command: TaskAlertEvent, current: tuple[int, dict] | None + ) -> TaskAlertState: + now = self.clock() + require(now.tzinfo is not None, "SCHEMA_INVALID", "Alert clock must be timezone-aware") + if current is None: + require(command.event_type == "raised", "INVALID_TRANSITION", + "An alert must be raised before it can change state", status=409) + revision = 1 + status = "active" + else: + revision, document = current + require(document.get("producer_id") == command.producer_id, + "PERMISSION_DENIED", "Only the originating producer can update an alert", status=403) + require(document.get("source") == command.source.model_dump(mode="json"), + "SCOPE_MISMATCH", "Alert source cannot change", status=409) + require( + all( + document.get(field) == event_value + for field, event_value in { + "condition_id": command.condition_id, + "title": command.title, + "description": command.description, + "occurred_at": command.model_dump(mode="json")["occurred_at"], + "severity": command.severity, + }.items() + ), + "IDEMPOTENCY_CONFLICT", + "Alert facts cannot change after the alert is raised", + status=409, + ) + allowed = { + ("active", "acknowledged"): "acknowledged", + ("active", "resolved"): "resolved", + ("acknowledged", "resolved"): "resolved", + } + status = allowed.get((document["status"], command.event_type)) + require(status is not None, "INVALID_TRANSITION", + "Alert state transition is not permitted", status=409) + revision += 1 + return TaskAlertState( + alert_id=command.alert_id, + revision=revision, + producer_id=command.producer_id, + condition_id=command.condition_id, + source=command.source, + title=command.title, + description=command.description, + occurred_at=command.occurred_at, + severity=command.severity, + status=status, + updated_at=now, + ) diff --git a/app/services/workbench_runtime.py b/app/services/workbench_runtime.py new file mode 100644 index 0000000..0b166a6 --- /dev/null +++ b/app/services/workbench_runtime.py @@ -0,0 +1,390 @@ +"""Project persisted runtime state into the read-only supply workbench feed.""" + +from collections.abc import Callable +from datetime import datetime, timezone +from math import isfinite +from typing import Any + +from app.domain.schemas.workbench_runtime import ( + WorkbenchPersistedAlert, + WorkbenchRuntimeWorkOrder, +) + +_ANALYSIS_TYPE_LABELS = { + "burst_analysis": "爆管分析", + "burst_detection": "爆管检测", + "burst_location": "爆管定位", + "contaminant_analysis": "污染物分析", + "contaminant_simulation": "污染物模拟", + "dma_leak_identification": "DMA 漏损识别", + "flushing_analysis": "冲洗分析", + "pressure_regulation": "压力调控", + "sensor_placement": "传感器布点", + "valve_close_analysis": "关阀分析", +} +_ANALYSIS_STATUS_LABELS = { + "queued": "排队中", + "pending": "等待中", + "running": "运行中", + "completed": "已完成", + "succeeded": "已成功", + "failed": "已失败", + "error": "异常", + "cancelled": "已取消", +} + + +async def build_workbench_runtime_snapshot( + *, + analysis_runs: list[dict[str, Any]] | None = None, + latest_analysis_results: list[dict[str, Any]] | None = None, + latest_analysis_map_payload: dict[str, Any] | None = None, + work_order_storage_configured: bool = False, + work_orders: list[dict[str, Any]] | None = None, + alert_storage_configured: bool = False, + persisted_alerts: list[dict[str, Any]] | None = None, + scenario_availability: str | None = None, + artifact_availability: str | None = None, + schedule_availability: str | None = None, + work_order_availability: str | None = None, + alert_availability: str | None = None, + projection_degraded: bool = False, + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), +) -> dict[str, Any]: + persisted_analysis_runs = (analysis_runs or [])[:100] + scenarios = [project_analysis_scenario(run) for run in persisted_analysis_runs[:20]] + latest_analysis = ( + project_latest_analysis( + persisted_analysis_runs[0], + latest_analysis_results or [], + referenced_map_payload=latest_analysis_map_payload, + ) + if persisted_analysis_runs + else None + ) + persisted_work_orders = [ + WorkbenchRuntimeWorkOrder.model_validate(item).model_dump() + for item in ( + (work_orders or [])[:100] + if work_order_storage_configured + else [] + ) + ] + stored_alerts = [ + WorkbenchPersistedAlert.model_validate(item) + for item in ( + (persisted_alerts or [])[:100] + if alert_storage_configured + else [] + ) + ] + active_stored_alerts = [ + { + "id": f"backend-alert-{alert.alert_id}", + "condition_id": alert.condition_id, + "source": alert.source.model_dump(), + "title": alert.title, + "description": alert.description, + "occurred_at": alert.occurred_at, + "severity": alert.severity, + } + for alert in stored_alerts + if alert.status == "active" + ] + projected_alerts = active_stored_alerts + projected_alerts.sort( + key=lambda alert: _timestamp(alert["occurred_at"]), + reverse=True, + ) + source = "none" + resolved_scenario_availability = scenario_availability or ( + "ready" if scenarios else "not_configured" + ) + resolved_artifact_availability = artifact_availability or ( + "ready" if latest_analysis else "not_configured" + ) + resolved_schedule_availability = schedule_availability or "not_configured" + resolved_work_order_availability = work_order_availability or ( + "ready" if work_order_storage_configured else "not_configured" + ) + resolved_alert_availability = alert_availability or ( + "ready" if alert_storage_configured else "not_configured" + ) + section_availabilities = ( + resolved_scenario_availability, + resolved_artifact_availability, + resolved_schedule_availability, + resolved_work_order_availability, + resolved_alert_availability, + ) + return { + "availability": ( + "degraded" + if projection_degraded or "degraded" in section_availabilities + else + "ready" + if ( + source != "none" + or work_order_storage_configured + or alert_storage_configured + ) + else "not_configured" + ), + "source": source, + "scenario_availability": resolved_scenario_availability, + "artifact_availability": resolved_artifact_availability, + "schedule_availability": resolved_schedule_availability, + "work_order_availability": resolved_work_order_availability, + "alert_availability": resolved_alert_availability, + "generated_at": clock(), + "conditions": [], + "alerts": projected_alerts, + "scenarios": scenarios, + "latest_analysis": latest_analysis, + "work_orders": persisted_work_orders, + } + + +def project_analysis_scenario(run: dict[str, Any]) -> dict[str, Any]: + run_id = str(run["run_id"]) + run_type = str(run.get("run_type") or "analysis") + run_status = str(run.get("status") or "unknown").lower() + type_label = _analysis_type_label(run_type) + return { + "id": f"analysis-run-{run_id}", + "run_id": run_id, + "name": str(run.get("name") or type_label), + "description": ( + f"{type_label} · {_analysis_status_label(run_status)} · " + f"创建人 {run.get('created_by') or 'unknown'}" + ), + "status": _analysis_scenario_state(run_status), + "run_type": run_type, + "run_type_label": type_label, + "run_status": run_status, + "created_at": run["created_at"], + } + + +def project_analysis_history_item(run: dict[str, Any]) -> dict[str, Any]: + run_type = str(run.get("run_type") or "analysis") + run_status = str(run.get("status") or "unknown").lower() + created_at = run["created_at"] + return { + "run_id": str(run["run_id"]), + "name": str(run.get("name") or _analysis_type_label(run_type)), + "run_type": run_type, + "run_type_label": _analysis_type_label(run_type), + "status": run_status, + "status_label": _analysis_status_label(run_status), + "created_by": str(run.get("created_by") or "unknown"), + "created_at": created_at, + "started_at": run.get("started_at") or created_at, + "has_results": bool(run.get("has_results")), + } + + +def project_latest_analysis( + run: dict[str, Any], + results: list[dict[str, Any]], + *, + referenced_map_payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + run_type = str(run.get("run_type") or "analysis") + run_status = str(run.get("status") or "unknown").lower() + created_at = run["created_at"] + return { + "run_id": str(run["run_id"]), + "name": str(run.get("name") or _analysis_type_label(run_type)), + "run_type": run_type, + "run_type_label": _analysis_type_label(run_type), + "status": run_status, + "status_label": _analysis_status_label(run_status), + "created_by": str(run.get("created_by") or "unknown"), + "created_at": created_at, + "started_at": run.get("started_at") or created_at, + "results": [ + { + "result_type": str(result.get("result_type") or "analysis_result"), + "created_at": result.get("created_at") or created_at, + "facts": _analysis_result_facts(result), + } + for result in results + ], + "map_payload": _analysis_map_payload(results) or referenced_map_payload, + } + + +def sensor_location_ids_for_map_projection( + results: list[dict[str, Any]], +) -> list[str]: + if _analysis_map_payload(results) is not None: + return [] + for result in reversed(results): + if result.get("result_type") != "sensor_placement": + continue + payload = result.get("payload") + if not isinstance(payload, dict): + continue + locations = _non_empty_text_list(payload.get("sensor_locations")) + if locations: + return list(dict.fromkeys(locations))[:500] + return [] + + +def project_sensor_location_map_payload( + node_ids: list[str], map_points: list[dict[str, Any]] +) -> dict[str, Any] | None: + points_by_id = { + str(point.get("node_id")): point + for point in map_points + if point.get("node_id") is not None + } + features: list[dict[str, Any]] = [] + for position, node_id in enumerate(node_ids, start=1): + point = points_by_id.get(node_id) + if point is None: + continue + longitude = point.get("longitude") + latitude = point.get("latitude") + if ( + isinstance(longitude, bool) + or isinstance(latitude, bool) + or not isinstance(longitude, (int, float)) + or not isinstance(latitude, (int, float)) + or not isfinite(float(longitude)) + or not isfinite(float(latitude)) + ): + continue + features.append({ + "type": "Feature", + "id": node_id, + "properties": { + "label": f"监测点 {position} · {node_id}", + "node_id": node_id, + "result_type": "sensor_placement", + "position": position, + }, + "geometry": { + "type": "Point", + "coordinates": [float(longitude), float(latitude)], + }, + }) + if not features: + return None + return {"type": "FeatureCollection", "features": features} + + +def _analysis_result_facts(result: dict[str, Any]) -> list[dict[str, str]]: + facts: list[dict[str, str]] = [] + node_id = _non_empty_text(result.get("node_id")) + link_id = _non_empty_text(result.get("link_id")) + if node_id: + facts.append({"label": "关联节点", "value": node_id}) + if link_id: + facts.append({"label": "关联管段", "value": link_id}) + + payload = result.get("payload") + if not isinstance(payload, dict): + return facts + result_type = str(result.get("result_type") or "") + if result_type == "sensor_placement": + locations = _non_empty_text_list(payload.get("sensor_locations")) + sensor_count = _finite_number_text(payload.get("sensor_number")) + if sensor_count is None and locations: + sensor_count = str(len(locations)) + if sensor_count is not None: + facts.append({"label": "测点数量", "value": sensor_count}) + minimum_diameter = _finite_number_text(payload.get("minimum_diameter")) + if minimum_diameter is not None: + facts.append({"label": "最小管径参数", "value": minimum_diameter}) + if locations: + facts.append({ + "label": "测点位置", + "value": _summarize_identifiers(locations), + }) + elif result_type == "leakage_identification": + run_status = _non_empty_text(payload.get("run_status")) + if run_status: + facts.append({"label": "结果状态", "value": run_status}) + sensor_nodes = _non_empty_text_list(payload.get("sensor_nodes")) + if sensor_nodes: + facts.append({"label": "传感器节点数", "value": str(len(sensor_nodes))}) + rows = payload.get("rows") + if isinstance(rows, list): + facts.append({"label": "结果行数", "value": str(len(rows))}) + areas = payload.get("areas") + if isinstance(areas, list): + facts.append({"label": "分区数量", "value": str(len(areas))}) + return facts + + +def _non_empty_text(value: object) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def _non_empty_text_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [text for item in value if (text := _non_empty_text(item)) is not None] + + +def _finite_number_text(value: object) -> str | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + if isinstance(value, float) and not isfinite(value): + return None + return str(value) + + +def _summarize_identifiers(values: list[str], limit: int = 12) -> str: + visible = values[:limit] + suffix = f" 等 {len(values)} 个" if len(values) > limit else "" + return "、".join(visible) + suffix + + +def _analysis_map_payload( + results: list[dict[str, Any]], +) -> dict[str, Any] | None: + for result in reversed(results): + payload = result.get("payload") + if not isinstance(payload, dict): + continue + candidate = payload.get("drawing_payload") + if not isinstance(candidate, dict): + candidate = payload + if ( + candidate.get("type") == "FeatureCollection" + and isinstance(candidate.get("features"), list) + and candidate["features"] + ): + return candidate + return None + + +def _analysis_type_label(run_type: str) -> str: + return _ANALYSIS_TYPE_LABELS.get(run_type, run_type) + + +def _analysis_status_label(status: str) -> str: + return _ANALYSIS_STATUS_LABELS.get(status, status) + + +def _analysis_scenario_state(status: str) -> str: + if status == "running": + return "active" + if status in {"completed", "succeeded", "failed", "error"}: + return "review" + return "draft" + + +def _timestamp(value: datetime | str) -> float: + parsed = ( + value + if isinstance(value, datetime) + else datetime.fromisoformat(value.replace("Z", "+00:00")) + ) + return parsed.timestamp() diff --git a/docs/peripheral-generated-data.md b/docs/peripheral-generated-data.md new file mode 100644 index 0000000..143a813 --- /dev/null +++ b/docs/peripheral-generated-data.md @@ -0,0 +1,64 @@ +# 外围子系统合成样本 + +仅供演示,所有数据标为 `synthetic`,所有对象使用 `DEMO-` 前缀。它们不映射真实资产,不写入 `asset/network/scada`,不生成正式工单,不控制设备。 + +## 内容与复现 + +纯函数 `samples/peripheral/demo_data.py::build_demo_content` 接收种子和带时区的固定起点,返回结构化数据。相同生成器版本、种子、归一化后的 UTC 起点产生相同记录、UUID 和 JCS SHA-256;不依赖当前时间、进程随机状态或数据库内容。生成器升级必须换版本,不能原地悄改 v1 的行为。 + +默认版本 `water-demo@2`,种子 `tjwater-peripheral-demo-20260917`;北京时间 2026-09-17 08:00 至 20:00,15 分钟一条,共 48 个时点。 + +| 内容 | 数量及情节 | +| --- | --- | +| 测点 | 3 个压力点、1 个流量点、1 个液位点 | +| 读数 | 240 条;含 4 条缺测 null、4 条真实零值,保留各自质量标记 | +| 设备状态 | 9 条变化;通信离线与恢复、泵运行/停止、阀门开/关 | +| 告警 | 2 个预置事件,含发生与解除时间;不是从真实数据推断出的故障 | +| 运行记录 | 4 条泵阀历史事件;不是可执行命令或真实设备回执 | + +目录 `status=unknown` 不代表设备当前在线。历史状态必须按 `device_states.observed_at` 解读,不能将固定演示时点当成当前现场状态。 + +本数据压力单位为 m,不自动转换或送入专业分析。 + +## 独立 PG 存储 + +用户已授权外围样板使用独立 schema。仅在业务端 `tjwater_v2` 的已有 `peripheral_sample` 中新增一张表,不改其他表。由外部供给流程在事务中执行一次;应用没有建表或生成接口,也没有自动 seed。 + +```sql +CREATE TABLE peripheral_sample.demo_datasets ( + project_id uuid NOT NULL, + dataset_id text NOT NULL, + digest text NOT NULL CHECK (digest ~ '^sha256:[0-9a-f]{64}$'), + document jsonb NOT NULL, + PRIMARY KEY (project_id,dataset_id), + CHECK ((jsonb_typeof(document) = 'object' AND document->>'origin' = 'synthetic' + AND document->>'generator_version' = 'water-demo@2') IS TRUE) +); +``` + +登记 `dataset_id=water-demo-v1` 时,先生成并验证 DemoContent、计算规范化摘要。已有同编号记录时只允许核对相同摘要,不覆盖;改变种子或生成器应使用新版本/新编号并另行调整读取配置。样本本身永久保存,读取不会重新生成或写入。 + +经明确授权需要为某个开发项目填充外围样板时,可运行: + +```bash +conda run -n server python scripts/seed_peripheral_sample.py --project-id +``` + +脚本使用固定 UUIDv5 请求号和专用 synthetic actor,重复执行会读取原幂等回执,不重复创建。它核验或补充 `water-demo-v1`,并创建 3 张测试工单和 12 条带 `DEMO-` 前缀的可编辑测试数据;不创建缺少合法 Action 来源的外围核查任务,不写正式工单、资产或监测表。 + +## 演示历史窗口配置 + +外围面板的“历史监测回放”读取现有项目历史监测源,与上述 `water-demo-v1` 合成数据集是两个独立区域。前端可通过 `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo` 默认使用已知有数据的固定窗口,并用 `TJWATER_PERIPHERAL_SAMPLE_DEMO_START`、`TJWATER_PERIPHERAL_SAMPLE_DEMO_END` 配置开始和结束时间;切到 `recent` 时按 `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` 查询最近时段。界面仍允许在运行时切换演示、最近和自定义窗口。 + +本地默认演示窗口为 UTC 2026-04-30 08:00 至 16:00。它只控制历史监测查询,不改变 `water-demo-v1` 自身的 UTC 2026-09-17 00:00 至 12:00 固定内容。 + +## 接口与页面 + +`GET /api/sample/v1/demo-data` 复用 Next 项目身份和读取权限,按项目查 PG 后核验摘要与模型,返回 project_id、source_id、source_revision、固定时间窗和样本。无记录返回 SOURCE_NOT_CONFIGURED,不回退到浏览器造数据;服务不可用演练仍按既有场景拒绝读取。 + +原历史监测接口不变,真实告警源仍然是 `not_configured`;合成告警只出现在单独的合成样本区域,不能宣称真实告警接口已经接通。 + +前端在“外围样板”中单独读取合成样本,展示版本、种子和摘要,以及读数、设备状态、告警和运行历史。加载失败不会清空或阻止既有核查任务及历史回放。 + +核查任务、反馈仍需用户依据已保存来源显式提交。生成器不会伪造 Next 来源、现场反馈或业务审批。 +已审核反馈现在可经人工确认[导入独立新证据](field-feedback-import.md),再另行确认建立[人工复核草稿修订](feedback-review-revisions.md)。专业重分析不在本次已确认的样板反馈范围内;生成器不会自动导入或修订成果。 diff --git a/docs/peripheral-sample.md b/docs/peripheral-sample.md new file mode 100644 index 0000000..08eb49c --- /dev/null +++ b/docs/peripheral-sample.md @@ -0,0 +1,15 @@ +# 外围样板服务 + +该独立 FastAPI 服务提供历史监测回放、固定合成数据,以及 synthetic 测试工单和测试数据的版本化 CRUD。管理入口位于供水工作台“外围样板”。它不控制设备,也不修改正式业务资产。 + +浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。 + +主要接口包括: + +- `/api/sample/v1/context` 与 `/scenario` +- `/api/sample/v1/work-orders` +- `/api/sample/v1/test-data` +- `/api/sample/v1/monitoring/points`、`observations`、`alerts` +- `/api/sample/v1/demo-data` + +演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。 diff --git a/docs/workbench-operations-storage.md b/docs/workbench-operations-storage.md new file mode 100644 index 0000000..1172f16 --- /dev/null +++ b/docs/workbench-operations-storage.md @@ -0,0 +1,20 @@ +# 工作台工单与告警存储契约 + +工作台只聚合预约工单与任务主动推送的告警。浏览器不推导告警,也不写正式工单。 + +## 任务告警 + +DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /api/v1/workbench/task-alert-events` 推送 `raised`、`acknowledged` 或 `resolved` 事件。来源包含 `task_type`、`task_id` 和 `run_id`,生产者由认证项目身份绑定。`event_id` 在项目和生产者范围内幂等,告警事实在首次 raised 后不可改写。 + +## 预约工单 + +`workbench.work_orders` 的 DDL 位于 `resources/sql/011_workbench_work_orders.sql`。每行保存一个只读工单投影,文档必须满足 `WorkbenchRuntimeWorkOrder`: + +- 一张主工单包含一个或多个 `subtasks`。 +- 每个子任务保存自己的位置、班组、时间窗和复令要求。 +- 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。 +- 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。 + +演示数据可以先运行 `scripts/seed_peripheral_sample.py`,再运行 `scripts/seed_workbench_work_orders.py` 投影。工作台会显示一张主工单,三项外围核查分别作为子任务展示。 + +工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。 diff --git a/docs/workbench-runtime.md b/docs/workbench-runtime.md new file mode 100644 index 0000000..56f6058 --- /dev/null +++ b/docs/workbench-runtime.md @@ -0,0 +1,10 @@ +# 工作台运行态 + +`GET /api/v1/workbench/runtime` 按当前项目读取 BizDB 中的分析成果、预约工单和任务告警。工况任务继续由前端演示数据提供,不从后端运行记录或分析结果生成。 + +- `analysis.runs/results` 只用于分析成果、历史成果和兼容场景字段。 +- `workbench.work_orders` 是预约工单的后端持久化投影。主工单包含多个子任务,每个子任务包含自己的有序阶段。表存在时,空列表同样是权威结果,不回退到 mock 工单。 +- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。 +- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。 + +演示工单由 `scripts/seed_peripheral_sample.py` 写入 `peripheral_sample`,再由 `scripts/seed_workbench_work_orders.py` 将三项外围核查组合成一张主工单和三个子任务,写入 `workbench.work_orders`。当前固定演示日期为 2026-09-17。 diff --git a/requirements.txt b/requirements.txt index 95bf401..0f0fd59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -126,6 +126,7 @@ pyzmq==26.2.1 reactivex==4.0.4 referencing==0.36.2 requests==2.32.3 +rfc8785==0.1.4 rich==14.2.0 rich-rst==1.3.2 rpds-py==0.30.0 diff --git a/resources/sql/008_workbench_alert_events.sql b/resources/sql/008_workbench_alert_events.sql new file mode 100644 index 0000000..347cda5 --- /dev/null +++ b/resources/sql/008_workbench_alert_events.sql @@ -0,0 +1,49 @@ +-- Append-only task alert events plus a monotonic current-state projection. + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS workbench; + +CREATE TABLE IF NOT EXISTS workbench.alert_events ( + project_id uuid NOT NULL, + producer_id text NOT NULL CHECK (length(producer_id) > 0), + event_id uuid NOT NULL, + alert_id text NOT NULL CHECK (length(alert_id) > 0), + event_type text NOT NULL CHECK (event_type IN ('raised','acknowledged','resolved')), + occurred_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now(), + document jsonb NOT NULL, + PRIMARY KEY (project_id,producer_id,event_id), + CHECK ((jsonb_typeof(document)='object' + AND document->>'producer_id'=producer_id + AND document->>'event_id'=event_id::text + AND document->>'alert_id'=alert_id + AND document->>'event_type'=event_type + AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE) +); + +CREATE INDEX IF NOT EXISTS alert_events_alert_idx + ON workbench.alert_events(project_id,alert_id,occurred_at,event_id); + +CREATE TABLE IF NOT EXISTS workbench.alerts ( + project_id uuid NOT NULL, + alert_id text NOT NULL CHECK (length(alert_id) > 0), + revision bigint NOT NULL CHECK (revision >= 1), + status text NOT NULL CHECK (status IN ('active','acknowledged','resolved')), + occurred_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + document jsonb NOT NULL, + PRIMARY KEY (project_id,alert_id), + CHECK ((jsonb_typeof(document)='object' + AND document->>'alert_id'=alert_id + AND document->>'status'=status + AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE) +); + +CREATE INDEX IF NOT EXISTS alerts_active_recent_idx + ON workbench.alerts(project_id,occurred_at DESC,alert_id) + WHERE status='active'; + +REVOKE ALL ON ALL TABLES IN SCHEMA workbench FROM PUBLIC; + +COMMIT; diff --git a/resources/sql/009_analysis_runtime_query_indexes.sql b/resources/sql/009_analysis_runtime_query_indexes.sql new file mode 100644 index 0000000..5033091 --- /dev/null +++ b/resources/sql/009_analysis_runtime_query_indexes.sql @@ -0,0 +1,12 @@ +-- Bound and index the persisted analysis reads used by the workbench and API. +-- Safe to re-run after analysis.runs and analysis.results are provisioned. + +BEGIN; + +CREATE INDEX IF NOT EXISTS runs_created_at_run_id_idx + ON analysis.runs(created_at DESC, run_id); + +CREATE INDEX IF NOT EXISTS results_run_created_at_result_id_idx + ON analysis.results(run_id, created_at DESC, result_id DESC); + +COMMIT; diff --git a/resources/sql/010_source_gis_inventory.sql b/resources/sql/010_source_gis_inventory.sql new file mode 100644 index 0000000..a6578da --- /dev/null +++ b/resources/sql/010_source_gis_inventory.sql @@ -0,0 +1,74 @@ +CREATE SCHEMA IF NOT EXISTS source_gis; + +CREATE TABLE IF NOT EXISTS source_gis.datasets ( + dataset_key TEXT PRIMARY KEY, + title TEXT NOT NULL, + source_date DATE NOT NULL, + declared_srid INTEGER, + effective_srid INTEGER NOT NULL, + coordinate_note TEXT NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS source_gis.layers ( + layer_key TEXT PRIMARY KEY, + dataset_key TEXT NOT NULL REFERENCES source_gis.datasets(dataset_key) ON DELETE CASCADE, + display_name TEXT NOT NULL, + source_file TEXT NOT NULL, + feature_kind TEXT NOT NULL, + geometry_type TEXT NOT NULL, + feature_count BIGINT NOT NULL CHECK (feature_count >= 0), + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS source_gis.features ( + layer_key TEXT NOT NULL REFERENCES source_gis.layers(layer_key) ON DELETE CASCADE, + source_fid BIGINT NOT NULL, + source_object_id TEXT, + source_keyword TEXT, + source_asset_id TEXT, + source_diameter DOUBLE PRECISION, + from_node_id TEXT, + to_node_id TEXT, + attributes JSONB NOT NULL CHECK (jsonb_typeof(attributes) = 'object'), + geom geometry(Geometry, 900914) NOT NULL, + PRIMARY KEY (layer_key, source_fid) +); + +ALTER TABLE source_gis.features + ADD COLUMN IF NOT EXISTS source_diameter DOUBLE PRECISION; + +CREATE TABLE IF NOT EXISTS source_gis.element_links ( + element_type TEXT NOT NULL, + element_id TEXT NOT NULL, + layer_key TEXT NOT NULL, + source_fid BIGINT NOT NULL, + match_method TEXT NOT NULL, + distance_m DOUBLE PRECISION, + PRIMARY KEY (element_type, element_id, layer_key), + FOREIGN KEY (layer_key, source_fid) + REFERENCES source_gis.features(layer_key, source_fid) + ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_source_gis_layers_dataset + ON source_gis.layers(dataset_key); +CREATE INDEX IF NOT EXISTS idx_source_gis_features_object_id + ON source_gis.features(layer_key, source_object_id); +CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes + ON source_gis.features(layer_key, from_node_id, to_node_id); +CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes_reverse + ON source_gis.features(layer_key, to_node_id, from_node_id); +CREATE INDEX IF NOT EXISTS idx_source_gis_features_geom + ON source_gis.features USING GIST (geom); +CREATE INDEX IF NOT EXISTS idx_source_gis_element_links_lookup + ON source_gis.element_links(element_type, element_id); + +COMMENT ON SCHEMA source_gis IS + 'Imported source GIS inventories retained separately from the hydraulic model.'; +COMMENT ON COLUMN source_gis.datasets.declared_srid IS + 'SRID declared by the source file, which may be incorrect.'; +COMMENT ON COLUMN source_gis.datasets.effective_srid IS + 'SRID assigned after validating source coordinates against the project model.'; +COMMENT ON COLUMN source_gis.features.attributes IS + 'Original non-geometry GIS fields, including null values, preserved as JSONB.'; diff --git a/resources/sql/011_workbench_work_orders.sql b/resources/sql/011_workbench_work_orders.sql new file mode 100644 index 0000000..ced42ca --- /dev/null +++ b/resources/sql/011_workbench_work_orders.sql @@ -0,0 +1,28 @@ +-- Read-only work-order projection synchronized by an external work-order source. + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS workbench; + +CREATE TABLE IF NOT EXISTS workbench.work_orders ( + project_id uuid NOT NULL, + work_order_id text NOT NULL CHECK (length(work_order_id) > 0), + scheduled_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + document jsonb NOT NULL, + PRIMARY KEY (project_id, work_order_id), + CHECK ((jsonb_typeof(document) = 'object' + AND document->>'id' = work_order_id + AND (document->>'scheduled_at')::timestamptz = scheduled_at + AND (document->>'updated_at')::timestamptz = updated_at) IS TRUE) +); + +CREATE INDEX IF NOT EXISTS work_orders_project_schedule_idx + ON workbench.work_orders(project_id, scheduled_at, work_order_id); + +CREATE INDEX IF NOT EXISTS work_orders_project_updated_idx + ON workbench.work_orders(project_id, updated_at DESC, work_order_id); + +REVOKE ALL ON workbench.work_orders FROM PUBLIC; + +COMMIT; diff --git a/resources/sql/012_peripheral_demo_v2.sql b/resources/sql/012_peripheral_demo_v2.sql new file mode 100644 index 0000000..366039b --- /dev/null +++ b/resources/sql/012_peripheral_demo_v2.sql @@ -0,0 +1,13 @@ +BEGIN; + +ALTER TABLE peripheral_sample.demo_datasets + DROP CONSTRAINT IF EXISTS demo_datasets_document_check; + +ALTER TABLE peripheral_sample.demo_datasets + ADD CONSTRAINT demo_datasets_document_check CHECK (( + jsonb_typeof(document) = 'object' + AND document->>'origin' = 'synthetic' + AND document->>'generator_version' IN ('water-demo@1', 'water-demo@2') + ) IS TRUE); + +COMMIT; diff --git a/resources/sql/013_fengxian_map_layers.sql b/resources/sql/013_fengxian_map_layers.sql new file mode 100644 index 0000000..2da2664 --- /dev/null +++ b/resources/sql/013_fengxian_map_layers.sql @@ -0,0 +1,122 @@ +DO $$ +DECLARE + layer RECORD; +BEGIN + FOR layer IN + SELECT * + FROM (VALUES + ('fx_20200423_drain_valves', 'fx_20200423_drain_valves', 'Point'), + ('fx_20200423_service_connections', 'fx_20200423_service_connections', 'Point'), + ('fx_20200423_flow_meters', 'fx_20200423_flow_meters', 'Point'), + ('fx_20200423_hydrants', 'fx_20200423_hydrants', 'Point'), + ('fx_20200423_customer_meters', 'fx_20200423_customer_meters', 'Point'), + ('fx_20200423_pipes', 'fx_20200423_pipes', 'LineString'), + ('fx_20200423_valves', 'fx_20200423_valves', 'Point'), + ('fx_20200423_non_residential_meters', 'fx_20200423_non_residential_meters', 'Point'), + ('fx_20200811_dn100_pipes', 'fx_20200811_dn100_pipes', 'LineString') + ) AS configured(view_name, layer_key, geometry_type) + LOOP + IF to_regclass(format('gis.%I', layer.view_name)) IS NULL THEN + EXECUTE format( + $view$ + CREATE MATERIALIZED VIEW gis.%I AS + SELECT + feature.source_fid::text AS id, + feature.source_fid, + feature.source_object_id, + feature.source_diameter AS diameter, + CASE + WHEN GeometryType(feature.geom) IN ('LINESTRING', 'MULTILINESTRING') + THEN ST_Length(feature.geom) + ELSE NULL + END AS length, + dataset.dataset_key, + dataset.title AS dataset_title, + dataset.source_date, + source_layer.layer_key AS source_layer_key, + source_layer.display_name AS source_layer_name, + source_layer.feature_kind, + ST_Transform(feature.geom, 3857)::geometry(%s, 3857) AS geom + FROM source_gis.features AS feature + JOIN source_gis.layers AS source_layer + ON source_layer.layer_key = feature.layer_key + JOIN source_gis.datasets AS dataset + ON dataset.dataset_key = source_layer.dataset_key + WHERE feature.layer_key = %L + AND feature.geom IS NOT NULL + WITH DATA + $view$, + layer.view_name, + layer.geometry_type, + layer.layer_key + ); + END IF; + + EXECUTE format( + 'CREATE UNIQUE INDEX IF NOT EXISTS %I ON gis.%I (id)', + 'ux_' || layer.view_name || '_id', + layer.view_name + ); + EXECUTE format( + 'CREATE INDEX IF NOT EXISTS %I ON gis.%I USING GIST (geom)', + 'ix_' || layer.view_name || '_geom', + layer.view_name + ); + END LOOP; +END +$$; + +DROP VIEW IF EXISTS gis.water_quality_monitoring_points; + +CREATE VIEW gis.water_quality_monitoring_points AS +WITH latest_result AS ( + SELECT + run.run_id, + run.name AS source_analysis, + run.created_at, + result.payload + FROM analysis.runs AS run + JOIN LATERAL ( + SELECT stored.payload + FROM analysis.results AS stored + WHERE stored.run_id = run.run_id + AND stored.result_type = 'sensor_placement' + ORDER BY stored.created_at DESC, stored.result_id DESC + LIMIT 1 + ) AS result ON true + WHERE run.run_type = 'sensor_placement' + AND run.status = 'completed' + AND result.payload->>'sensor_type' = 'water_quality' + AND jsonb_typeof(result.payload->'sensor_locations') = 'array' + ORDER BY run.created_at DESC, run.run_id DESC + LIMIT 1 +), ranked_locations AS ( + SELECT + latest.run_id, + latest.source_analysis, + latest.created_at, + latest.payload, + location.node_id, + location.rank + FROM latest_result AS latest + CROSS JOIN LATERAL jsonb_array_elements_text( + latest.payload->'sensor_locations' + ) WITH ORDINALITY AS location(node_id, rank) +) +SELECT + ranked.node_id AS id, + ranked.node_id, + ranked.rank::integer AS monitoring_point_no, + ranked.run_id, + ranked.source_analysis, + ranked.created_at, + ranked.payload->>'method' AS selection_method, + (ranked.payload->>'minimum_diameter')::integer AS minimum_diameter, + junction.elevation, + junction.geom +FROM ranked_locations AS ranked +JOIN gis.junctions AS junction + ON junction.id = ranked.node_id; + +COMMENT ON VIEW gis.water_quality_monitoring_points IS + 'Current water-quality monitoring points selected by the sensitivity service.'; diff --git a/samples/peripheral/__init__.py b/samples/peripheral/__init__.py new file mode 100644 index 0000000..becebc3 --- /dev/null +++ b/samples/peripheral/__init__.py @@ -0,0 +1 @@ +"""Independent, explicitly simulated peripheral service; not a production workflow engine.""" diff --git a/samples/peripheral/api.py b/samples/peripheral/api.py new file mode 100644 index 0000000..175d681 --- /dev/null +++ b/samples/peripheral/api.py @@ -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() diff --git a/samples/peripheral/auth.py b/samples/peripheral/auth.py new file mode 100644 index 0000000..0f1fd67 --- /dev/null +++ b/samples/peripheral/auth.py @@ -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) diff --git a/samples/peripheral/config.py b/samples/peripheral/config.py new file mode 100644 index 0000000..65c6e32 --- /dev/null +++ b/samples/peripheral/config.py @@ -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", + ) diff --git a/samples/peripheral/demo_data.py b/samples/peripheral/demo_data.py new file mode 100644 index 0000000..74f9014 --- /dev/null +++ b/samples/peripheral/demo_data.py @@ -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") diff --git a/samples/peripheral/errors.py b/samples/peripheral/errors.py new file mode 100644 index 0000000..85e8c25 --- /dev/null +++ b/samples/peripheral/errors.py @@ -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) diff --git a/samples/peripheral/schemas.py b/samples/peripheral/schemas.py new file mode 100644 index 0000000..a21f4cc --- /dev/null +++ b/samples/peripheral/schemas.py @@ -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() diff --git a/samples/peripheral/store.py b/samples/peripheral/store.py new file mode 100644 index 0000000..fd685f4 --- /dev/null +++ b/samples/peripheral/store.py @@ -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"]} diff --git a/scripts/check_test_resource_storage.py b/scripts/check_test_resource_storage.py new file mode 100644 index 0000000..58d7e61 --- /dev/null +++ b/scripts/check_test_resource_storage.py @@ -0,0 +1,253 @@ +"""Read-only readiness check for workbench query indexes and test resources.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import psycopg +from psycopg.rows import dict_row + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.core.config import settings +from samples.peripheral.config import SampleSettings + + +RESOURCES = { + "analysis_runtime_queries": ( + "analysis.runs", + "analysis.runs_created_at_run_id_idx", + "analysis.results", + "analysis.results_run_created_at_result_id_idx", + ), + "task_alerts": ( + "workbench.alert_events", + "workbench.alert_events_alert_idx", + "workbench.alerts", + "workbench.alerts_active_recent_idx", + ), + "peripheral_work_orders": ( + "peripheral_sample.requests", + "peripheral_sample.scenarios", + "peripheral_sample.work_orders", + "peripheral_sample.work_order_revisions", + ), + "peripheral_test_data": ( + "peripheral_sample.test_data_records", + "peripheral_sample.test_data_record_revisions", + ), +} + +COLUMN_REQUIREMENTS = { + "analysis_runtime_queries": { + "analysis.runs": {"run_id", "created_at"}, + "analysis.results": {"result_id", "run_id", "created_at"}, + }, + "task_alerts": { + "workbench.alert_events": { + "project_id", "producer_id", "event_id", "alert_id", "event_type", + "occurred_at", "ingested_at", "document", + }, + "workbench.alerts": { + "project_id", "alert_id", "revision", "status", "occurred_at", + "updated_at", "document", + }, + }, +} + +INDEX_REQUIREMENTS = { + "analysis.runs_created_at_run_id_idx": ( + "on analysis.runs", + "(created_at desc, run_id)", + ), + "analysis.results_run_created_at_result_id_idx": ( + "on analysis.results", + "(run_id, created_at desc, result_id desc)", + ), + "workbench.alert_events_alert_idx": ( + "(project_id, alert_id, occurred_at, event_id)", + ), + "workbench.alerts_active_recent_idx": ( + "(project_id, occurred_at desc, alert_id)", + "status = 'active'", + ), +} + +RUNTIME_PRIVILEGES = { + "analysis.runs": ("SELECT",), + "analysis.results": ("SELECT",), + "workbench.alert_events": ("SELECT", "INSERT"), + "workbench.alerts": ("SELECT", "INSERT", "UPDATE"), + "peripheral_sample.requests": ("SELECT", "INSERT"), + "peripheral_sample.scenarios": ("SELECT", "INSERT", "UPDATE"), + "peripheral_sample.tasks": ("SELECT", "INSERT", "UPDATE"), + "peripheral_sample.revisions": ("SELECT", "INSERT"), + "peripheral_sample.feedback": ("SELECT", "INSERT"), + "peripheral_sample.demo_datasets": ("SELECT",), + "peripheral_sample.work_orders": ("SELECT", "INSERT", "UPDATE"), + "peripheral_sample.work_order_revisions": ("SELECT", "INSERT"), + "peripheral_sample.test_data_records": ("SELECT", "INSERT", "UPDATE"), + "peripheral_sample.test_data_record_revisions": ("SELECT", "INSERT"), +} + +PROHIBITED_PRIVILEGES = { + relation: ("DELETE", "TRUNCATE") for relation in RUNTIME_PRIVILEGES +} | { + "workbench.alert_events": ("UPDATE", "DELETE", "TRUNCATE"), +} + + +def main() -> int: + sample_settings = SampleSettings() + with psycopg.connect( + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + dbname=sample_settings.db_name, + connect_timeout=5, + options="-c statement_timeout=5000", + row_factory=dict_row, + ) as connection: + connection.execute("SET TRANSACTION READ ONLY") + database = connection.execute("SELECT current_database() AS name").fetchone()["name"] + availability: dict[str, dict[str, object]] = {} + for resource, relations in RESOURCES.items(): + rows = connection.execute( + "SELECT value,to_regclass(value)::text AS relation FROM unnest(%s::text[]) value", + (list(relations),), + ).fetchall() + missing = [row["value"] for row in rows if row["relation"] is None] + missing_columns: dict[str, list[str]] = {} + for relation, required in COLUMN_REQUIREMENTS.get(resource, {}).items(): + schema_name, table_name = relation.split(".", 1) + columns = connection.execute( + """SELECT column_name FROM information_schema.columns + WHERE table_schema=%s AND table_name=%s""", + (schema_name, table_name), + ).fetchall() + present = {row["column_name"] for row in columns} + absent = sorted(required - present) + if absent: + missing_columns[relation] = absent + invalid_indexes: dict[str, list[str]] = {} + for relation in relations: + required_fragments = INDEX_REQUIREMENTS.get(relation) + if required_fragments is None or relation in missing: + continue + definition_row = connection.execute( + "SELECT pg_get_indexdef(to_regclass(%s)) AS definition", + (relation,), + ).fetchone() + definition = " ".join( + str(definition_row["definition"] or "").lower().split() + ) + absent_fragments = [ + fragment + for fragment in required_fragments + if fragment not in definition + ] + if absent_fragments: + invalid_indexes[relation] = absent_fragments + availability[resource] = { + "status": ( + "available" + if not missing and not missing_columns and not invalid_indexes + else "not_configured" + ), + "missing": missing, + "missing_columns": missing_columns, + "invalid_indexes": invalid_indexes, + } + + role = connection.execute( + """SELECT current_user AS name,rolsuper,rolcreatedb,rolcreaterole + FROM pg_roles WHERE rolname=current_user""" + ).fetchone() + database_owner = connection.execute( + """SELECT pg_get_userbyid(datdba) AS name + FROM pg_database WHERE datname=current_database()""" + ).fetchone()["name"] + owned_relations = [ + f"{row['schema_name']}.{row['relation_name']}" + for row in connection.execute( + """SELECT namespace.nspname AS schema_name, + relation.relname AS relation_name + FROM pg_class relation + JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace + WHERE pg_get_userbyid(relation.relowner)=current_user + AND namespace.nspname=ANY(%s::text[]) + AND relation.relkind IN ('r','p') + ORDER BY namespace.nspname,relation.relname""", + (["analysis", "peripheral_sample", "workbench"],), + ).fetchall() + ] + missing_runtime_privileges: list[str] = [] + prohibited_runtime_privileges: list[str] = [] + for relation, privileges in RUNTIME_PRIVILEGES.items(): + exists = connection.execute( + "SELECT to_regclass(%s) IS NOT NULL AS available", (relation,) + ).fetchone()["available"] + if not exists: + missing_runtime_privileges.extend( + f"{relation}:{privilege}" for privilege in privileges + ) + continue + for privilege in privileges: + allowed = connection.execute( + "SELECT has_table_privilege(current_user,%s,%s) AS allowed", + (relation, privilege), + ).fetchone()["allowed"] + if not allowed: + missing_runtime_privileges.append(f"{relation}:{privilege}") + for relation, privileges in PROHIBITED_PRIVILEGES.items(): + exists = connection.execute( + "SELECT to_regclass(%s) IS NOT NULL AS available", (relation,) + ).fetchone()["available"] + if not exists: + continue + for privilege in privileges: + allowed = connection.execute( + "SELECT has_table_privilege(current_user,%s,%s) AS allowed", + (relation, privilege), + ).fetchone()["allowed"] + if allowed: + prohibited_runtime_privileges.append( + f"{relation}:{privilege}" + ) + production_safe_role = bool( + role + and not role["rolsuper"] + and not role["rolcreatedb"] + and not role["rolcreaterole"] + and role["name"] != database_owner + and not owned_relations + and not missing_runtime_privileges + and not prohibited_runtime_privileges + ) + + ready = all(item["status"] == "available" for item in availability.values()) + print(json.dumps({ + "database": database, + "ready": ready, + "production_ready": ready and production_safe_role, + "connection_role": { + "name": role["name"] if role else None, + "superuser": role["rolsuper"] if role else None, + "create_database": role["rolcreatedb"] if role else None, + "create_role": role["rolcreaterole"] if role else None, + "owns_database": role["name"] == database_owner if role else None, + "owned_relations": owned_relations, + "missing_runtime_privileges": missing_runtime_privileges, + "prohibited_runtime_privileges": prohibited_runtime_privileges, + "production_safe": production_safe_role, + }, + "resources": availability, + }, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if ready else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_peripheral_sample.py b/scripts/export_peripheral_sample.py new file mode 100644 index 0000000..3093127 --- /dev/null +++ b/scripts/export_peripheral_sample.py @@ -0,0 +1,15 @@ +"""Generate the independent peripheral sample contract without opening a DB.""" +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from samples.peripheral.api import app + + +if __name__ == "__main__": + target = ROOT / "contracts/peripheral-sample.openapi.json" + target.write_text(json.dumps(app.openapi(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"Exported {len(app.openapi()['paths'])} sample paths") diff --git a/scripts/generate_fengxian_water_quality_points.py b/scripts/generate_fengxian_water_quality_points.py new file mode 100644 index 0000000..c1e549c --- /dev/null +++ b/scripts/generate_fengxian_water_quality_points.py @@ -0,0 +1,82 @@ +"""Generate one reproducible Fengxian water-quality monitoring-point dataset.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.native.wndb.core.connection import project_connection +from app.services.sensor_placement import optimize_sensor_placement_by_sensitivity + + +def find_existing_run( + project: str, + *, + sensor_count: int, + min_diameter: int, +) -> dict[str, object] | None: + with project_connection(project) as conn: + return conn.execute( + """ + SELECT run.run_id::text AS run_id, run.name, run.created_at, + result.payload + FROM analysis.runs AS run + JOIN LATERAL ( + SELECT stored.payload + FROM analysis.results AS stored + WHERE stored.run_id = run.run_id + AND stored.result_type = 'sensor_placement' + ORDER BY stored.created_at DESC, stored.result_id DESC + LIMIT 1 + ) AS result ON true + WHERE run.run_type = 'sensor_placement' + AND run.status = 'completed' + AND result.payload->>'sensor_type' = 'water_quality' + AND result.payload->>'method' = 'sensitivity' + AND (result.payload->>'sensor_number')::integer = %s + AND (result.payload->>'minimum_diameter')::integer = %s + ORDER BY run.created_at DESC, run.run_id DESC + LIMIT 1 + """, + (sensor_count, min_diameter), + ).fetchone() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", default="tjwater_v2") + parser.add_argument("--sensor-count", type=int, default=50) + parser.add_argument("--min-diameter", type=int, default=100) + parser.add_argument("--run-name", default="奉贤水质监测点灵敏度分析(50点)") + parser.add_argument("--created-by", default="tjwater") + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + + existing = find_existing_run( + args.project, + sensor_count=args.sensor_count, + min_diameter=args.min_diameter, + ) + if existing is not None and not args.force: + print(json.dumps(dict(existing), ensure_ascii=False, default=str, indent=2)) + return + + created = optimize_sensor_placement_by_sensitivity( + project_code=args.project, + run_name=args.run_name, + sensor_count=args.sensor_count, + min_diameter=args.min_diameter, + created_by=args.created_by, + sensor_type="water_quality", + ) + print(json.dumps(created, ensure_ascii=False, default=str, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/import_fengxian_gis.py b/scripts/import_fengxian_gis.py new file mode 100644 index 0000000..56dd0a2 --- /dev/null +++ b/scripts/import_fengxian_gis.py @@ -0,0 +1,475 @@ +"""Import the two Fengxian GIS deliveries into one project BizDB. + +The source PRJ files declare EPSG:21481, while their coordinate values match the +project's Shanghai local CRS (SRID 900914). The importer records both facts and +assigns the validated local CRS without changing coordinate values. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +from psycopg import sql +from psycopg.conninfo import conninfo_to_dict + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +from app.infra.db.project_routing import get_project_pgconn_string +from app.native.wndb.core.connection import project_connection, project_transaction + + +SCHEMA_SQL = PROJECT_ROOT / "resources/sql/010_source_gis_inventory.sql" +DEFAULT_APRIL_ROOT = Path( + "/mnt/c/Users/admin/Desktop/data/奉贤/20200423全网除头桥数据导出" +) +DEFAULT_AUGUST_ROOT = Path( + "/mnt/c/Users/admin/Desktop/data/奉贤/20200811奉贤100口径以上(含台账)(1)" +) + + +@dataclass(frozen=True) +class DatasetSpec: + key: str + title: str + source_date: date + + +@dataclass(frozen=True) +class LayerSpec: + key: str + dataset: DatasetSpec + display_name: str + source_file: Path + feature_kind: str + asset_id_candidates: tuple[str, ...] = () + + +APRIL_DATASET = DatasetSpec( + key="fx_water_assets_20200423", + title="奉贤全网除头桥 GIS 数据导出", + source_date=date(2020, 4, 23), +) +AUGUST_DATASET = DatasetSpec( + key="fx_pipe_inventory_20200811", + title="奉贤 DN100 及以上管网台账", + source_date=date(2020, 8, 11), +) + + +def layer_specs(april_root: Path, august_root: Path) -> tuple[LayerSpec, ...]: + return ( + LayerSpec("fx_20200423_drain_valves", APRIL_DATASET, "排水阀", april_root / "排水阀.shp", "drain_valve", ("排水阀ID编", "排水阀ID编码")), + LayerSpec("fx_20200423_service_connections", APRIL_DATASET, "接水点", april_root / "接水点.shp", "service_connection", ("接水点ID编", "接水点ID编码")), + LayerSpec("fx_20200423_flow_meters", APRIL_DATASET, "流量仪", april_root / "流量仪.shp", "flow_meter", ("流量仪ID编", "流量仪ID编码")), + LayerSpec("fx_20200423_hydrants", APRIL_DATASET, "消火栓", april_root / "消火栓.shp", "hydrant", ("消火栓ID编", "消火栓ID编码")), + LayerSpec("fx_20200423_customer_meters", APRIL_DATASET, "用户水表", april_root / "用户水表.shp", "customer_meter", ("水表ID编码", "用户水表ID")), + LayerSpec("fx_20200423_pipes", APRIL_DATASET, "管线", april_root / "管线.shp", "pipe", ("管线ID编码",)), + LayerSpec("fx_20200423_valves", APRIL_DATASET, "阀门", april_root / "阀门.shp", "valve", ("阀门ID编码",)), + LayerSpec("fx_20200423_non_residential_meters", APRIL_DATASET, "非居民用户水表", april_root / "非居民用户水表.shp", "non_residential_meter", ("水表ID编码", "用户水表ID")), + LayerSpec( + "fx_20200811_dn100_pipes", + AUGUST_DATASET, + "DN100 及以上管线台账", + august_root / "20200811奉贤管网100及以上(含台账).shp", + "pipe", + ("PIPE_ID",), + ), + ) + + +def _postgres_environment(database_name: str) -> dict[str, str]: + parts = conninfo_to_dict(get_project_pgconn_string(db_name=database_name)) + env = os.environ.copy() + for source, target in ( + ("host", "PGHOST"), + ("port", "PGPORT"), + ("user", "PGUSER"), + ("password", "PGPASSWORD"), + ("dbname", "PGDATABASE"), + ): + value = parts.get(source) + if value: + env[target] = value + return env + + +def _load_staging_table(spec: LayerSpec, database_name: str) -> str: + stage_name = f"_stage_{spec.key}" + with project_connection(database_name) as conn: + conn.execute( + sql.SQL("DROP TABLE IF EXISTS {}.{} CASCADE").format( + sql.Identifier("source_gis"), sql.Identifier(stage_name) + ) + ) + command = [ + "ogr2ogr", + "--config", + "PG_USE_COPY", + "YES", + "--config", + "OSR_USE_NON_DEPRECATED", + "NO", + "-f", + "PostgreSQL", + "PG:", + str(spec.source_file), + "-overwrite", + "-preserve_fid", + "-dim", + "XY", + "-mapFieldType", + "Date=String", + "-nln", + stage_name, + "-lco", + "SCHEMA=source_gis", + "-lco", + "FID=source_fid", + "-lco", + "GEOMETRY_NAME=geom", + "-lco", + "LAUNDER=NO", + ] + subprocess.run( + command, + env=_postgres_environment(database_name), + check=True, + text=True, + ) + return stage_name + + +def _first_column(columns: set[str], candidates: tuple[str, ...]) -> str | None: + return next((candidate for candidate in candidates if candidate in columns), None) + + +def _column_as_text(column: str | None) -> sql.Composable: + if column is None: + return sql.SQL("NULL::text") + return sql.SQL("NULLIF(BTRIM({}::text), '')").format(sql.Identifier(column)) + + +def _column_as_float(column: str | None) -> sql.Composable: + if column is None: + return sql.SQL("NULL::double precision") + return sql.SQL("{}::double precision").format(sql.Identifier(column)) + + +def _store_layer(database_name: str, spec: LayerSpec, stage_name: str) -> int: + with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur: + cur.execute( + """ + SELECT a.attname + FROM pg_attribute a + WHERE a.attrelid = to_regclass(%s) + AND a.attnum > 0 + AND NOT a.attisdropped + """, + (f"source_gis.{stage_name}",), + ) + columns = {row["attname"] for row in cur.fetchall()} + if not {"source_fid", "geom"}.issubset(columns): + raise RuntimeError(f"Staging table for {spec.key} is incomplete") + + cur.execute( + """ + INSERT INTO source_gis.datasets ( + dataset_key, title, source_date, declared_srid, effective_srid, + coordinate_note, imported_at + ) VALUES (%s, %s, %s, 21481, 900914, %s, NOW()) + ON CONFLICT (dataset_key) DO UPDATE SET + title = EXCLUDED.title, + source_date = EXCLUDED.source_date, + declared_srid = EXCLUDED.declared_srid, + effective_srid = EXCLUDED.effective_srid, + coordinate_note = EXCLUDED.coordinate_note, + imported_at = NOW() + """, + ( + spec.dataset.key, + spec.dataset.title, + spec.dataset.source_date, + "源 PRJ 声明 EPSG:21481,但坐标值与项目 SRID 900914 一致;原坐标未重投影。", + ), + ) + cur.execute( + sql.SQL("SELECT COUNT(*) AS count, GeometryType(geom) AS geometry_type FROM {}.{} GROUP BY GeometryType(geom)").format( + sql.Identifier("source_gis"), sql.Identifier(stage_name) + ) + ) + geometry_rows = cur.fetchall() + count = sum(int(row["count"]) for row in geometry_rows) + geometry_type = ",".join(sorted({row["geometry_type"] for row in geometry_rows})) + cur.execute( + """ + INSERT INTO source_gis.layers ( + layer_key, dataset_key, display_name, source_file, feature_kind, + geometry_type, feature_count, imported_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (layer_key) DO UPDATE SET + dataset_key = EXCLUDED.dataset_key, + display_name = EXCLUDED.display_name, + source_file = EXCLUDED.source_file, + feature_kind = EXCLUDED.feature_kind, + geometry_type = EXCLUDED.geometry_type, + feature_count = EXCLUDED.feature_count, + imported_at = NOW() + """, + ( + spec.key, + spec.dataset.key, + spec.display_name, + spec.source_file.name, + spec.feature_kind, + geometry_type, + count, + ), + ) + cur.execute("DELETE FROM source_gis.features WHERE layer_key = %s", (spec.key,)) + + object_id = _first_column(columns, ("OBJECTID", "ObjectID", "objectid")) + keyword = _first_column(columns, ("GJZ", "关键字")) + asset_id = _first_column(columns, spec.asset_id_candidates) + diameter = _first_column(columns, ("PIPE_D", "DIAMETER", "直径")) + from_node = _first_column(columns, ("FNODE", "起点编号", "起点ID")) + to_node = _first_column(columns, ("TNODE", "终点编号", "终点ID")) + insert_query = sql.SQL( + """ + INSERT INTO source_gis.features ( + layer_key, source_fid, source_object_id, source_keyword, + source_asset_id, source_diameter, from_node_id, to_node_id, + attributes, geom + ) + SELECT %s, source_fid, {}, {}, {}, {}, {}, {}, + to_jsonb(stage) - 'source_fid' - 'geom', + ST_Force2D(ST_SetSRID(geom, 900914)) + FROM {}.{} AS stage + """ + ).format( + _column_as_text(object_id), + _column_as_text(keyword), + _column_as_text(asset_id), + _column_as_float(diameter), + _column_as_text(from_node), + _column_as_text(to_node), + sql.Identifier("source_gis"), + sql.Identifier(stage_name), + ) + cur.execute(insert_query, (spec.key,)) + cur.execute( + sql.SQL("DROP TABLE {}.{}").format( + sql.Identifier("source_gis"), sql.Identifier(stage_name) + ) + ) + return count + + +def _rebuild_element_links(database_name: str) -> dict[str, int]: + with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur: + cur.execute( + """ + UPDATE source_gis.features AS april + SET source_diameter = august.source_diameter + FROM source_gis.features AS august + WHERE april.layer_key = 'fx_20200423_pipes' + AND august.layer_key = 'fx_20200811_dn100_pipes' + AND april.source_object_id = august.source_object_id + AND ST_Equals(april.geom, august.geom) + AND april.source_diameter IS NULL + """ + ) + cur.execute("DELETE FROM source_gis.element_links") + cur.execute( + """ + INSERT INTO source_gis.element_links ( + element_type, element_id, layer_key, source_fid, + match_method, distance_m + ) + SELECT 'pipes', pipe.id, layer.layer_key, match.source_fid, + 'node_pair', 0 + FROM gis.pipes AS pipe + CROSS JOIN source_gis.layers AS layer + CROSS JOIN LATERAL ( + SELECT feature.source_fid + FROM source_gis.features AS feature + WHERE feature.layer_key = layer.layer_key + AND ( + (feature.from_node_id = pipe.start_node_id AND feature.to_node_id = pipe.end_node_id) + OR + (feature.from_node_id = pipe.end_node_id AND feature.to_node_id = pipe.start_node_id) + ) + ORDER BY feature.source_fid + LIMIT 1 + ) AS match + WHERE layer.feature_kind = 'pipe' + """ + ) + node_pair_count = cur.rowcount + + cur.execute( + """ + INSERT INTO source_gis.element_links ( + element_type, element_id, layer_key, source_fid, + match_method, distance_m + ) + SELECT 'pipes', model.element_id, model.layer_key, match.source_fid, + 'geometry', match.distance_m + FROM ( + SELECT pipe.id AS element_id, layer.layer_key, + pipe.diameter, + ST_Transform(pipe.geom, 900914) AS geom + FROM gis.pipes AS pipe + CROSS JOIN source_gis.layers AS layer + WHERE layer.feature_kind = 'pipe' + AND NOT EXISTS ( + SELECT 1 FROM source_gis.element_links AS existing + WHERE existing.element_type = 'pipes' + AND existing.element_id = pipe.id + AND existing.layer_key = layer.layer_key + ) + AND ST_Length(ST_Transform(pipe.geom, 900914)) >= 0.1 + ) AS model + CROSS JOIN LATERAL ( + SELECT candidate.source_fid, candidate.distance_m + FROM ( + SELECT feature.source_fid, + ST_HausdorffDistance(feature.geom, model.geom) AS distance_m + FROM source_gis.features AS feature + WHERE feature.layer_key = model.layer_key + AND feature.geom && ST_Expand(model.geom, 1.0) + AND ( + feature.source_diameter IS NULL + OR model.diameter IS NULL + OR ABS(feature.source_diameter - model.diameter) + <= GREATEST(1.0, model.diameter * 0.02) + ) + ORDER BY feature.geom <-> model.geom + LIMIT 8 + ) AS candidate + ORDER BY candidate.distance_m, candidate.source_fid + LIMIT 1 + ) AS match + WHERE match.distance_m <= 1.0 + ON CONFLICT (element_type, element_id, layer_key) DO NOTHING + """ + ) + geometry_count = cur.rowcount + + cur.execute( + """ + WITH point_matches AS ( + SELECT feature.layer_key, feature.source_fid, + nearest.node_id AS element_id, + nearest.distance_m, + ROW_NUMBER() OVER ( + PARTITION BY nearest.node_id, feature.layer_key + ORDER BY nearest.distance_m, feature.source_fid + ) AS match_rank + FROM source_gis.features AS feature + JOIN source_gis.layers AS layer + ON layer.layer_key = feature.layer_key + CROSS JOIN LATERAL ( + SELECT node.node_id, + ST_Distance(node.geom, feature.geom) AS distance_m + FROM gis.node_geometries AS node + ORDER BY node.geom <-> feature.geom + LIMIT 1 + ) AS nearest + JOIN gis.junctions AS junction + ON junction.id = nearest.node_id + WHERE layer.feature_kind <> 'pipe' + AND nearest.distance_m <= 0.1 + ) + INSERT INTO source_gis.element_links ( + element_type, element_id, layer_key, source_fid, + match_method, distance_m + ) + SELECT 'junctions', element_id, layer_key, source_fid, + 'geometry', distance_m + FROM point_matches + WHERE match_rank = 1 + """ + ) + junction_count = cur.rowcount + + cur.execute( + """ + INSERT INTO source_gis.element_links ( + element_type, element_id, layer_key, source_fid, + match_method, distance_m + ) + SELECT 'valves', valve.id, layer.layer_key, match.source_fid, + 'geometry', match.distance_m + FROM gis.valves AS valve + CROSS JOIN source_gis.layers AS layer + CROSS JOIN LATERAL ( + SELECT feature.source_fid, + ST_Distance(feature.geom, ST_Transform(valve.geom, 900914)) AS distance_m + FROM source_gis.features AS feature + WHERE feature.layer_key = layer.layer_key + ORDER BY feature.geom <-> ST_Transform(valve.geom, 900914) + LIMIT 1 + ) AS match + WHERE layer.feature_kind = 'valve' + AND match.distance_m <= 2.0 + """ + ) + valve_count = cur.rowcount + return { + "node_pair": node_pair_count, + "pipe_geometry": geometry_count, + "junction_geometry": junction_count, + "valve_geometry": valve_count, + } + + +def import_sources(database_name: str, specs: tuple[LayerSpec, ...]) -> None: + missing = [str(spec.source_file) for spec in specs if not spec.source_file.is_file()] + if missing: + raise FileNotFoundError("Missing GIS source files: " + ", ".join(missing)) + with project_connection(database_name) as conn: + conn.execute(SCHEMA_SQL.read_text(encoding="utf-8")) + + staged_layers: list[tuple[LayerSpec, str]] = [] + for spec in specs: + print(f"Loading {spec.display_name} ({spec.key})") + staged_layers.append((spec, _load_staging_table(spec, database_name))) + + total = 0 + with project_transaction(database_name) as conn: + conn.execute( + "TRUNCATE TABLE source_gis.element_links, source_gis.features" + ) + for spec, stage_name in staged_layers: + count = _store_layer(database_name, spec, stage_name) + total += count + print(f" stored {count:,} features") + links = _rebuild_element_links(database_name) + print(f"Imported {total:,} features into {database_name}") + print( + "Element links: " + f"{links['node_pair']:,} node-pair, " + f"{links['pipe_geometry']:,} pipe geometry, " + f"{links['junction_geometry']:,} junction geometry, " + f"{links['valve_geometry']:,} valve geometry" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", default="tjwater_v2") + parser.add_argument("--april-root", type=Path, default=DEFAULT_APRIL_ROOT) + parser.add_argument("--august-root", type=Path, default=DEFAULT_AUGUST_ROOT) + args = parser.parse_args() + import_sources(args.database, layer_specs(args.april_root, args.august_root)) + + +if __name__ == "__main__": + main() diff --git a/scripts/publish_fengxian_map_layers.py b/scripts/publish_fengxian_map_layers.py new file mode 100644 index 0000000..4fcf08b --- /dev/null +++ b/scripts/publish_fengxian_map_layers.py @@ -0,0 +1,85 @@ +"""Create and publish Fengxian source GIS and water-quality monitoring layers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from psycopg import sql + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.infra.db.postgresql.project_spatial import get_project_map_bbox +from app.infra.geoserver.client import GeoServerAdminClient +from app.native.wndb.core.connection import project_connection + + +SCHEMA_SQL = ROOT / "resources/sql/013_fengxian_map_layers.sql" +SOURCE_MATERIALIZED_LAYERS = ( + ("fx_20200423_drain_valves", "奉贤排水阀"), + ("fx_20200423_service_connections", "奉贤接水点"), + ("fx_20200423_flow_meters", "奉贤流量仪"), + ("fx_20200423_hydrants", "奉贤消火栓"), + ("fx_20200423_customer_meters", "奉贤用户水表"), + ("fx_20200423_pipes", "奉贤全网管线"), + ("fx_20200423_valves", "奉贤阀门"), + ("fx_20200423_non_residential_meters", "奉贤非居民用户水表"), + ("fx_20200811_dn100_pipes", "奉贤 DN100 及以上管线"), +) +PUBLISHED_LAYERS = SOURCE_MATERIALIZED_LAYERS + ( + ("water_quality_monitoring_points", "奉贤水质监测点"), +) + + +def prepare_layers(database: str) -> dict[str, int]: + with project_connection(database) as conn, conn.transaction(): + conn.execute(SCHEMA_SQL.read_text(encoding="utf-8")) + for layer, _title in SOURCE_MATERIALIZED_LAYERS: + conn.execute( + sql.SQL("REFRESH MATERIALIZED VIEW gis.{}").format( + sql.Identifier(layer) + ) + ) + counts: dict[str, int] = {} + for layer, _title in PUBLISHED_LAYERS: + row = conn.execute( + sql.SQL("SELECT count(*) AS count FROM gis.{}").format( + sql.Identifier(layer) + ) + ).fetchone() + counts[layer] = int(row["count"]) + return counts + + +def publish_layers(database: str, workspace: str) -> dict[str, int]: + counts = prepare_layers(database) + map_bbox = get_project_map_bbox(database) + with GeoServerAdminClient() as geoserver: + geoserver.check_ready() + for layer, title in PUBLISHED_LAYERS: + geoserver.publish_feature_type( + workspace=workspace, + datastore=workspace, + layer=layer, + title=title, + map_bbox=map_bbox, + ) + geoserver.truncate_layer_cache(workspace, layer) + return counts + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", default="tjwater_v2") + parser.add_argument("--workspace", default="tjwater_next") + args = parser.parse_args() + counts = publish_layers(args.database, args.workspace) + for layer, count in counts.items(): + print(f"{layer}: {count:,}") + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_peripheral_sample.py b/scripts/seed_peripheral_sample.py new file mode 100644 index 0000000..808c2fc --- /dev/null +++ b/scripts/seed_peripheral_sample.py @@ -0,0 +1,153 @@ +"""Idempotently seed synthetic peripheral demo resources for one explicit project.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from uuid import NAMESPACE_URL, UUID, uuid5 + +from psycopg.types.json import Jsonb + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.services.content_digest import digest +from samples.peripheral.auth import Identity +from samples.peripheral.config import SampleSettings +from samples.peripheral.demo_data import build_demo_content +from samples.peripheral.schemas import CreateTestDataRecord, CreateWorkOrder +from samples.peripheral.store import PostgresStore + + +SEED_NAMESPACE = uuid5(NAMESPACE_URL, "tjwater/peripheral-sample/seed/v1") +ACTOR_ID = uuid5(SEED_NAMESPACE, "actor") + + +def request_id(kind: str) -> UUID: + return uuid5(SEED_NAMESPACE, kind) + + +WORK_ORDERS = ( + { + "key": "work-order/demo-communication", + "title": "合成通信核查", + "summary": "检查 DEMO-P001 的预置通信中断情节,仅用于外围样板联调。", + "status": "open", + "priority": "high", + "assignee": "外围样板班组", + "scheduled_at": "2026-09-17T03:00:00Z", + }, + { + "key": "work-order/demo-pressure", + "title": "合成压力偏差复核", + "summary": "复核 DEMO-P001、DEMO-P002 相邻测试点的固定合成读数,不代表现场异常。", + "status": "in_progress", + "priority": "urgent", + "assignee": "演示核查班组", + "scheduled_at": "2026-09-17T04:00:00Z", + }, + { + "key": "work-order/flow-zero", + "title": "合成零流量样本确认", + "summary": "确认 DEMO-F001 中零值与缺测值可被界面明确区分。", + "status": "completed", + "priority": "normal", + "assignee": "外围样板班组", + "scheduled_at": "2026-09-17T06:00:00Z", + }, +) + + +TEST_DATA = ( + ("DEMO-P001", "合成压力 1 · 00:00", "2026-09-17T00:00:00Z", 24.37, "m", "valid"), + ("DEMO-P001", "合成压力 1 · 00:15", "2026-09-17T00:15:00Z", 24.18, "m", "valid"), + ("DEMO-P001", "合成压力 1 · 00:30 缺测", "2026-09-17T00:30:00Z", None, "m", "missing"), + ("DEMO-P001", "合成压力 1 · 00:45", "2026-09-17T00:45:00Z", 24.42, "m", "valid"), + ("DEMO-P002", "合成压力 2 · 00:00", "2026-09-17T00:00:00Z", 31.26, "m", "valid"), + ("DEMO-P002", "合成压力 2 · 00:15", "2026-09-17T00:15:00Z", 31.31, "m", "valid"), + ("DEMO-P002", "合成压力 2 · 00:30", "2026-09-17T00:30:00Z", 31.18, "m", "valid"), + ("DEMO-P002", "合成压力 2 · 00:45", "2026-09-17T00:45:00Z", 31.36, "m", "valid"), + ("DEMO-F001", "合成流量 00:00", "2026-09-17T00:00:00Z", 186.43, "m3/h", "valid"), + ("DEMO-F001", "合成流量 00:15", "2026-09-17T00:15:00Z", 185.90, "m3/h", "valid"), + ("DEMO-F001", "合成零流量 00:30", "2026-09-17T00:30:00Z", 0.0, "m3/h", "valid"), + ("DEMO-F001", "合成流量 00:45", "2026-09-17T00:45:00Z", 187.12, "m3/h", "valid"), +) + + +def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str: + content = build_demo_content() + content_digest = digest(content) + with store.connection() as connection: + row = connection.execute( + """SELECT digest FROM peripheral_sample.demo_datasets + WHERE project_id=%s AND dataset_id='water-demo-v1'""", + (project_id,), + ).fetchone() + if row is None: + connection.execute( + """INSERT INTO peripheral_sample.demo_datasets + (project_id,dataset_id,digest,document) + VALUES (%s,'water-demo-v1',%s,%s)""", + (project_id, content_digest, Jsonb(content)), + ) + elif row["digest"] != content_digest: + connection.execute( + """UPDATE peripheral_sample.demo_datasets + SET digest=%s, document=%s + WHERE project_id=%s AND dataset_id='water-demo-v1'""", + (content_digest, Jsonb(content), project_id), + ) + return content_digest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--project-id", required=True, type=UUID) + args = parser.parse_args() + project_id = str(args.project_id) + store = PostgresStore(SampleSettings(db_name="tjwater_v2", enable_faults=False)) + who = Identity(project_id, str(ACTOR_ID), True) + + dataset_digest = ensure_demo_dataset(store, project_id) + order_ids = [] + for item in WORK_ORDERS: + command = CreateWorkOrder( + request_id=request_id(item["key"]), + title=item["title"], + summary=item["summary"], + status=item["status"], + priority=item["priority"], + assignee=item["assignee"], + scheduled_at=item["scheduled_at"], + ) + order_ids.append(store.create_work_order(who, command)["work_order_id"]) + + record_ids = [] + for point_id, label, observed_at, value, unit, quality in TEST_DATA: + command = CreateTestDataRecord( + request_id=request_id(f"test-data/{point_id}/{observed_at}"), + label=label, + point_id=point_id, + observed_at=observed_at, + value=value, + unit=unit, + quality=quality, + notes="外围样板固定合成数据,不是现场监测记录。", + ) + record_ids.append(store.create_test_data(who, command)["record_id"]) + + result = { + "project_id": project_id, + "actor_id": str(ACTOR_ID), + "origin": "synthetic", + "demo_dataset_digest": dataset_digest, + "work_orders": {"count": len(order_ids), "ids": order_ids}, + "test_data_records": {"count": len(record_ids), "ids": record_ids}, + } + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/seed_workbench_work_orders.py b/scripts/seed_workbench_work_orders.py new file mode 100644 index 0000000..97cfb9f --- /dev/null +++ b/scripts/seed_workbench_work_orders.py @@ -0,0 +1,254 @@ +"""Project peripheral demo orders into the backend workbench order store.""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from uuid import UUID +from zoneinfo import ZoneInfo + +from psycopg.types.json import Jsonb + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrder +from app.native.wndb.core.connection import project_connection + + +SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql" +SHANGHAI = ZoneInfo("Asia/Shanghai") +PROJECTION_SOURCE = "合成数据预约工单" +DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection" + +ORDER_DETAILS = { + "合成通信核查": { + "location": "DEMO-P001", + "duration_minutes": 30, + "reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"], + }, + "合成压力偏差复核": { + "location": "DEMO-P001 / DEMO-P002", + "duration_minutes": 45, + "reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"], + }, + "合成零流量样本确认": { + "location": "DEMO-F001", + "duration_minutes": 20, + "reply_requirements": ["确认零值显示", "确认缺测值显示"], + }, +} + + +def _parse_timestamp(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _time_label(value: datetime) -> str: + return value.astimezone(SHANGHAI).strftime("%H:%M") + + +def _time_range(start: datetime, end: datetime) -> str: + return f"{_time_label(start)}-{_time_label(end)}" + + +def _subtask_status(status: str) -> str: + mapped = { + "open": "pending", + "in_progress": "running", + "completed": "completed", + }.get(status) + if mapped is None: + raise ValueError(f"unsupported peripheral work-order status: {status}") + return mapped + + +def _stage_statuses(status: str) -> tuple[str, str, str]: + if status == "completed": + return "done", "done", "done" + if status == "running": + return "done", "current", "pending" + return "current", "pending", "pending" + + +def project_peripheral_subtask(document: dict) -> dict: + status = _subtask_status(document.get("status")) + scheduled_at = _parse_timestamp(document["scheduled_at"]) + details = ORDER_DETAILS.get(document["title"], {}) + duration_minutes = int(details.get("duration_minutes", 30)) + execution_end = scheduled_at + timedelta(minutes=duration_minutes) + reply_end = execution_end + timedelta(minutes=15) + dispatch_status, execution_status, reply_status = _stage_statuses(status) + assignee = document.get("assignee") or "待分派班组" + + return { + "id": document["work_order_id"], + "title": document["title"], + "summary": document["summary"], + "status": status, + "scheduled_at": document["scheduled_at"], + "location": details.get("location", "演示点位"), + "assignee": assignee, + "duration_minutes": duration_minutes, + "reply_window_minutes": 15, + "stages": [ + { + "id": "dispatch", + "sequence": 1, + "title": "派发", + "status": dispatch_status, + "owner": "演示调度", + "time_label": _time_label(scheduled_at - timedelta(minutes=15)), + "description": "预约工单子任务已进入演示调度流程。", + }, + { + "id": "execution", + "sequence": 2, + "title": "执行", + "status": execution_status, + "owner": assignee, + "time_label": _time_range(scheduled_at, execution_end), + "description": "按外围合成场景执行本项核查。", + }, + { + "id": "reply", + "sequence": 3, + "title": "复令", + "status": reply_status, + "owner": assignee, + "time_label": _time_range(execution_end, reply_end), + "description": "回传本项核查结果并完成子任务闭环。", + }, + ], + "reply_requirements": details.get( + "reply_requirements", ["回传演示核查结果"] + ), + } + + +def project_peripheral_work_order(source_documents: list[dict]) -> dict: + if not source_documents: + raise ValueError("source documents must not be empty") + + subtasks = [project_peripheral_subtask(document) for document in source_documents] + scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents] + updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents] + end_values = [ + scheduled + timedelta(minutes=subtask["duration_minutes"]) + for scheduled, subtask in zip(scheduled_values, subtasks, strict=True) + ] + statuses = {subtask["status"] for subtask in subtasks} + if statuses == {"completed"}: + status = "completed" + elif statuses == {"pending"}: + status = "pending" + else: + status = "running" + priority = "urgent" if any( + document.get("priority") in {"high", "urgent"} + for document in source_documents + ) else "normal" + + projected = { + "id": DEMO_WORK_ORDER_ID, + "code": "DEMO-WO-001", + "scheduled_at": min(scheduled_values), + "title": "外围合成数据核查", + "summary": "依次核查合成通信、压力偏差和零流量样本。", + "status": status, + "risk_level": "attention" if priority == "urgent" else "normal", + "updated_at": max(updated_values), + "detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。", + "recommendation": "按子任务逐项核查并分别完成阶段回传。", + "duration_minutes": max( + 1, + int((max(end_values) - min(scheduled_values)).total_seconds() // 60), + ), + "source": PROJECTION_SOURCE, + "dispatcher": "演示调度", + "priority": priority, + "subtasks": subtasks, + } + return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--database-name", default="tjwater_v2") + parser.add_argument("--project-id", required=True, type=UUID) + args = parser.parse_args() + + with project_connection(args.database_name) as conn, conn.transaction(): + conn.execute(SCHEMA_SQL.read_text(encoding="utf-8")) + source_rows = conn.execute( + """ + SELECT document + FROM peripheral_sample.work_orders + WHERE project_id = %s + AND deleted_at IS NULL + AND document->>'origin' = 'synthetic' + AND document->>'scheduled_at' IS NOT NULL + AND document->>'status' IN ('open', 'in_progress', 'completed') + ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id + """, + (args.project_id,), + ).fetchall() + source_documents = [row["document"] for row in source_rows] + if not source_documents: + raise RuntimeError("no eligible peripheral demo work orders found") + documents = [project_peripheral_work_order(source_documents)] + + conn.execute( + """ + DELETE FROM workbench.work_orders + WHERE project_id = %s + AND ( + work_order_id LIKE 'demo-work-order-%%' + OR document->>'source' = %s + OR document->>'source' = '外围样板预约工单' + ) + """, + (args.project_id, PROJECTION_SOURCE), + ) + for document in documents: + conn.execute( + """ + INSERT INTO workbench.work_orders + (project_id, work_order_id, scheduled_at, updated_at, document) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (project_id, work_order_id) DO UPDATE + SET scheduled_at = EXCLUDED.scheduled_at, + updated_at = EXCLUDED.updated_at, + document = EXCLUDED.document + """, + ( + args.project_id, + document["id"], + document["scheduled_at"], + document["updated_at"], + Jsonb(document), + ), + ) + + print(json.dumps({ + "database": args.database_name, + "project_id": str(args.project_id), + "source": PROJECTION_SOURCE, + "work_orders": [ + { + "id": document["id"], + "code": document["code"], + "title": document["title"], + "subtask_count": len(document["subtasks"]), + } + for document in documents + ], + }, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/api/test_project_data_endpoints.py b/tests/api/test_project_data_endpoints.py new file mode 100644 index 0000000..9f5f578 --- /dev/null +++ b/tests/api/test_project_data_endpoints.py @@ -0,0 +1,135 @@ +import asyncio +from datetime import date + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints import project_data + + +def test_element_gis_properties_endpoint_returns_typed_groups(monkeypatch): + async def list_element_groups(conn, element_type, element_id): + assert conn is connection + assert element_type == "pipes" + assert element_id == "DEMO-P001" + return [ + { + "dataset_key": "fx_pipe_inventory_20200811", + "dataset_title": "奉贤 DN100 及以上管网台账", + "source_date": date(2020, 8, 11), + "layer_key": "fx_20200811_dn100_pipes", + "layer_name": "DN100 及以上管线台账", + "source_fid": 117, + "match_method": "node_pair", + "match_distance_m": 0, + "attributes": {"PIPE_D": 300}, + } + ] + + connection = object() + monkeypatch.setattr( + project_data.GisAttributeRepository, + "list_element_groups", + list_element_groups, + ) + + result = asyncio.run( + project_data.get_element_gis_properties( + element_type="pipes", + element_id=" DEMO-P001 ", + conn=connection, + ) + ) + + assert result.element_id == "DEMO-P001" + assert result.groups[0].attributes == {"PIPE_D": 300} + + +def test_element_gis_properties_endpoint_rejects_blank_id(): + with pytest.raises(HTTPException) as failure: + asyncio.run( + project_data.get_element_gis_properties( + element_type="pipes", + element_id=" ", + conn=object(), + ) + ) + + assert failure.value.status_code == 422 + + +def test_source_gis_feature_endpoint_returns_direct_source_group(monkeypatch): + async def get_source_feature_group(conn, layer_key, source_fid): + assert conn is connection + assert layer_key == "fx_20200423_flow_meters" + assert source_fid == 17 + return { + "dataset_key": "fx_water_assets_20200423", + "dataset_title": "奉贤全网除头桥 GIS 数据导出", + "source_date": date(2020, 4, 23), + "layer_key": layer_key, + "layer_name": "流量仪", + "source_fid": source_fid, + "match_method": "source", + "match_distance_m": None, + "attributes": {"流量仪ID编码": "FM-17"}, + } + + connection = object() + monkeypatch.setattr( + project_data.GisAttributeRepository, + "get_source_feature_group", + get_source_feature_group, + ) + + result = asyncio.run( + project_data.get_source_gis_feature_properties( + layer_key=" fx_20200423_flow_meters ", + source_fid=17, + conn=connection, + ) + ) + + assert result.layer_key == "fx_20200423_flow_meters" + assert result.group.match_method == "source" + assert result.group.attributes == {"流量仪ID编码": "FM-17"} + + +def test_analysis_run_endpoint_forwards_page_bounds(monkeypatch): + captured = {} + + async def list_runs(conn, *, limit, offset): + captured.update(conn=conn, limit=limit, offset=offset) + return [{"run_id": "run-1"}] + + connection = object() + monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", list_runs) + + result = asyncio.run( + project_data.get_analysis_runs(limit=25, offset=50, conn=connection) + ) + + assert captured == {"conn": connection, "limit": 25, "offset": 50} + assert result == { + "success": True, + "data": [{"run_id": "run-1"}], + "count": 1, + "limit": 25, + "offset": 50, + } + + +def test_analysis_endpoints_do_not_return_database_exception_text(monkeypatch): + async def unavailable(*_args, **_kwargs): + raise RuntimeError("postgresql://user:secret@database/internal") + + monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", unavailable) + + with pytest.raises(HTTPException) as failure: + asyncio.run( + project_data.get_analysis_runs(limit=100, offset=0, conn=object()) + ) + + assert failure.value.status_code == 500 + assert failure.value.detail == "查询分析运行失败" + assert "secret" not in failure.value.detail diff --git a/tests/api/test_workbench_runtime_endpoints.py b/tests/api/test_workbench_runtime_endpoints.py new file mode 100644 index 0000000..0cf14df --- /dev/null +++ b/tests/api/test_workbench_runtime_endpoints.py @@ -0,0 +1,508 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import workbench_runtime +from app.auth.project_dependencies import ProjectContext +from app.infra.db.postgresql.workbench_operations import ( + WorkbenchOperationsAvailability, +) +from tests.conftest import build_test_app + + +PROJECT_CONTEXT = ProjectContext( + project_id=uuid4(), + project_code="test-project", + user_id=uuid4(), + project_role="viewer", +) + + +def configure_dependencies(app): + app.dependency_overrides[workbench_runtime.get_project_context] = lambda: PROJECT_CONTEXT + app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object() + + +def test_alert_ingress_binds_producer_to_authenticated_project_principal(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + member = ProjectContext( + project_id=uuid4(), + project_code="test-project", + user_id=uuid4(), + project_role="member", + ) + app.dependency_overrides[workbench_runtime.get_project_context] = lambda: member + app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object() + captured = {} + + class CapturingAlertService: + def __init__(self, _repository, context): + assert context == member + + async def push(self, event): + captured["event"] = event + return event.model_dump(exclude={"event_id", "event_type"}) | { + "revision": 1, + "status": "active", + "updated_at": datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc), + } + + monkeypatch.setattr(workbench_runtime, "TaskAlertService", CapturingAlertService) + body = { + "event_id": str(uuid4()), + "alert_id": "schedule-1:occurrence-1", + "event_type": "raised", + "occurred_at": "2026-09-18T02:30:00Z", + "condition_id": None, + "source": { + "task_type": "scheduled_task", + "task_id": "schedule-1", + "run_id": "occurrence-1", + }, + "title": "定时任务失败", + "description": "任务显式推送的告警。", + "severity": "warning", + } + + response = TestClient(app).post("/api/v1/workbench/task-alert-events", json=body) + + assert response.status_code == 200 + assert response.json()["producer_id"] == f"principal:{member.user_id}" + assert captured["event"].producer_id == f"principal:{member.user_id}" + spoofed = TestClient(app).post( + "/api/v1/workbench/task-alert-events", + json=body | {"producer_id": "investigation-scheduler"}, + ) + assert spoofed.status_code == 422 + + +def test_runtime_endpoint_returns_typed_empty_projection(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + + async def empty_runs(_conn, *, limit): + assert limit == 100 + return [] + + async def no_operations(_conn): + return WorkbenchOperationsAvailability( + schedules=False, + work_orders=False, + alerts=False, + ) + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs) + monkeypatch.setattr( + workbench_runtime.WorkbenchOperationsRepository, + "availability", + no_operations, + ) + + response = TestClient(app).get("/api/v1/workbench/runtime") + + assert response.status_code == 200 + assert response.json() == { + "availability": "not_configured", + "source": "none", + "scenario_availability": "not_configured", + "artifact_availability": "not_configured", + "schedule_availability": "not_configured", + "work_order_availability": "not_configured", + "alert_availability": "not_configured", + "generated_at": response.json()["generated_at"], + "conditions": [], + "alerts": [], + "scenarios": [], + "latest_analysis": None, + "work_orders": [], + } + assert datetime.fromisoformat(response.json()["generated_at"]).tzinfo is timezone.utc + + +def test_analysis_history_endpoint_returns_a_sanitized_bounded_page(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) + run_ids = [uuid4(), uuid4(), uuid4()] + + async def analysis_runs(_conn, *, limit, offset): + assert (limit, offset) == (3, 20) + return [ + { + "run_id": run_ids[index], + "name": f"分析运行 {index + 1}", + "run_type": "sensor_placement", + "created_by": "alice", + "created_at": now, + "started_at": now, + "status": "completed", + "has_results": index != 1, + "parameters": {"internal_secret": "must-not-leak"}, + } + for index in range(3) + ] + + monkeypatch.setattr( + workbench_runtime.AnalysisRepository, + "list_run_summaries", + analysis_runs, + ) + + response = TestClient(app).get( + "/api/v1/workbench/analysis-runs?limit=2&offset=20" + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["next_offset"] == 22 + assert [item["run_id"] for item in payload["items"]] == [ + str(run_ids[0]), + str(run_ids[1]), + ] + assert payload["items"][0]["run_type_label"] == "传感器布点" + assert payload["items"][1]["has_results"] is False + assert "internal_secret" not in response.text + + +def test_runtime_endpoint_keeps_analysis_projection_out_of_conditions(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + run_id = uuid4() + now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) + + async def analysis_runs(_conn, *, limit): + assert limit == 100 + return [{ + "run_id": run_id, + "name": "北区爆管定位", + "run_type": "burst_location", + "created_by": "alice", + "created_at": now, + "started_at": now, + "status": "completed", + "parameters": {}, + }] + + async def analysis_results(_conn, requested_run_id, *, limit): + assert requested_run_id == run_id + assert limit == 100 + return [{"result_type": "burst_location", "created_at": now}] + + async def no_operations(_conn): + return WorkbenchOperationsAvailability( + schedules=False, + work_orders=False, + alerts=False, + ) + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", analysis_runs) + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_results", analysis_results) + monkeypatch.setattr( + workbench_runtime.WorkbenchOperationsRepository, + "availability", + no_operations, + ) + + response = TestClient(app).get("/api/v1/workbench/runtime") + + assert response.status_code == 200 + payload = response.json() + assert payload["availability"] == "not_configured" + assert payload["source"] == "none" + assert payload["conditions"] == [] + assert payload["scenarios"][0]["run_id"] == str(run_id) + assert payload["latest_analysis"]["results"] == [{ + "result_type": "burst_location", + "created_at": "2026-09-17T02:30:00Z", + "facts": [], + }] + assert payload["latest_analysis"]["map_payload"] is None + + +def test_analysis_run_endpoint_returns_the_requested_typed_projection(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + run_id = uuid4() + now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) + + async def analysis_run(_conn, requested_run_id): + assert requested_run_id == run_id + return { + "run_id": run_id, + "name": "历史爆管定位", + "run_type": "burst_location", + "created_by": "alice", + "created_at": now, + "started_at": now, + "status": "completed", + "parameters": {}, + } + + async def analysis_results(_conn, requested_run_id, *, limit): + assert requested_run_id == run_id + assert limit == 100 + return [{ + "result_type": "burst_location", + "link_id": "P-42", + "created_at": now, + "payload": { + "drawing_payload": { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "properties": {"label": "历史运行几何"}, + "geometry": { + "type": "Point", + "coordinates": [121.4, 30.8], + }, + }], + }, + "internal_debug": "must-not-leak", + }, + }] + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run) + monkeypatch.setattr( + workbench_runtime.AnalysisRepository, + "list_recent_results", + analysis_results, + ) + + response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}") + + assert response.status_code == 200 + payload = response.json() + assert payload["run_id"] == str(run_id) + assert payload["name"] == "历史爆管定位" + assert payload["results"][0]["facts"] == [ + {"label": "关联管段", "value": "P-42"} + ] + assert payload["map_payload"]["features"][0]["properties"] == { + "label": "历史运行几何" + } + assert "must-not-leak" not in response.text + + +def test_analysis_run_endpoint_projects_sensor_locations_as_map_points(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + run_id = uuid4() + now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) + + async def analysis_run(_conn, requested_run_id): + assert requested_run_id == run_id + return { + "run_id": run_id, + "name": "北区传感器布点", + "run_type": "sensor_placement", + "created_by": "alice", + "created_at": now, + "started_at": now, + "status": "completed", + "parameters": {}, + } + + async def analysis_results(_conn, requested_run_id, *, limit): + assert requested_run_id == run_id + assert limit == 100 + return [{ + "result_type": "sensor_placement", + "created_at": now, + "payload": { + "sensor_number": 2, + "minimum_diameter": 300, + "sensor_locations": ["J-2", "J-1"], + "internal_debug": "must-not-leak", + }, + }] + + async def junction_points(_conn, node_ids): + assert node_ids == ["J-2", "J-1"] + return [ + {"node_id": "J-1", "longitude": 121.4, "latitude": 30.8}, + {"node_id": "J-2", "longitude": 121.5, "latitude": 30.9}, + ] + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run) + monkeypatch.setattr( + workbench_runtime.AnalysisRepository, + "list_recent_results", + analysis_results, + ) + monkeypatch.setattr( + workbench_runtime.AnalysisRepository, + "list_junction_map_points", + junction_points, + ) + + response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}") + + assert response.status_code == 200 + payload = response.json() + assert [ + feature["properties"]["node_id"] + for feature in payload["map_payload"]["features"] + ] == ["J-2", "J-1"] + assert payload["map_payload"]["features"][0] == { + "type": "Feature", + "id": "J-2", + "properties": { + "label": "监测点 1 · J-2", + "node_id": "J-2", + "result_type": "sensor_placement", + "position": 1, + }, + "geometry": {"type": "Point", "coordinates": [121.5, 30.9]}, + } + assert "must-not-leak" not in response.text + + +def test_analysis_run_endpoint_returns_404_for_an_unknown_run(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + + async def missing_run(_conn, _run_id): + return None + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", missing_run) + + response = TestClient(app).get( + f"/api/v1/workbench/analysis-runs/{uuid4()}" + ) + + assert response.status_code == 404 + + +def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) + + async def empty_runs(_conn, *, limit): + assert limit == 100 + return [] + + async def operations_available(_conn): + return WorkbenchOperationsAvailability( + schedules=False, + work_orders=True, + alerts=True, + ) + + async def work_orders(_conn, project_id): + assert project_id == str(PROJECT_CONTEXT.project_id) + return [{ + "id": "wo-1", + "code": "WO-1", + "scheduled_at": now, + "title": "阀门复核", + "summary": "现场复核阀门。", + "status": "completed", + "risk_level": "normal", + "updated_at": now, + "detail": "正式工单同步记录。", + "recommendation": None, + "duration_minutes": 30, + "source": "生产工单系统", + "dispatcher": "调度中心", + "priority": "normal", + "subtasks": [{ + "id": "inspect-valve", + "title": "阀门复核", + "summary": "现场复核阀门。", + "status": "completed", + "scheduled_at": now, + "location": "V-1", + "assignee": "现场班组", + "duration_minutes": 30, + "reply_window_minutes": 15, + "stages": [ + {"id": "dispatch", "sequence": 1, "title": "派发", "status": "done", "owner": "调度中心", "time_label": "10:30", "description": "已派发。"}, + {"id": "execution", "sequence": 2, "title": "执行", "status": "done", "owner": "现场班组", "time_label": "10:30—11:00", "description": "已执行。"}, + {"id": "reply", "sequence": 3, "title": "复令", "status": "done", "owner": "现场班组", "time_label": "11:00—11:15", "description": "已复令。"}, + ], + "reply_requirements": ["回传结果"], + }], + }] + + async def alerts(_conn, project_id): + assert project_id == str(PROJECT_CONTEXT.project_id) + return [{ + "alert_id": "alarm-1", + "condition_id": None, + "source": { + "task_type": "scheduled_task", + "task_id": "diagnosis-5m", + "run_id": "trigger-1", + }, + "title": "泵站通信告警", + "description": "由诊断任务明确推送。", + "occurred_at": now, + "severity": "warning", + "status": "active", + }] + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs) + monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "availability", operations_available) + monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_work_orders", work_orders) + monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_alerts", alerts) + + response = TestClient(app).get("/api/v1/workbench/runtime") + + assert response.status_code == 200 + payload = response.json() + assert payload["source"] == "none" + assert payload["schedule_availability"] == "not_configured" + assert payload["conditions"] == [] + assert payload["work_order_availability"] == "ready" + assert payload["work_orders"][0]["code"] == "WO-1" + assert payload["work_orders"][0]["status"] == "completed" + assert payload["alert_availability"] == "ready" + assert payload["alerts"][0]["title"] == "泵站通信告警" + assert payload["alerts"][0]["source"] == { + "task_type": "scheduled_task", + "task_id": "diagnosis-5m", + "run_id": "trigger-1", + } + + +def test_runtime_endpoint_isolates_invalid_work_order_documents(monkeypatch): + app = build_test_app(workbench_runtime.router, "/api/v1") + configure_dependencies(app) + + async def empty_runs(_conn, *, limit): + assert limit == 100 + return [] + + async def operations_available(_conn): + return WorkbenchOperationsAvailability( + schedules=False, + work_orders=True, + alerts=False, + ) + + async def invalid_work_orders(_conn, _project_id): + return [{"id": "wo-invalid", "subtasks": []}] + + monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs) + monkeypatch.setattr( + workbench_runtime.WorkbenchOperationsRepository, + "availability", + operations_available, + ) + monkeypatch.setattr( + workbench_runtime.WorkbenchOperationsRepository, + "list_work_orders", + invalid_work_orders, + ) + + response = TestClient(app).get("/api/v1/workbench/runtime") + + assert response.status_code == 200 + payload = response.json() + assert payload["source"] == "none" + assert payload["schedule_availability"] == "not_configured" + assert payload["work_order_availability"] == "degraded" + assert payload["availability"] == "degraded" + assert payload["work_orders"] == [] diff --git a/tests/integration/test_peripheral_demo_data_live.py b/tests/integration/test_peripheral_demo_data_live.py new file mode 100644 index 0000000..fd0bc4f --- /dev/null +++ b/tests/integration/test_peripheral_demo_data_live.py @@ -0,0 +1,102 @@ +"""Opt-in persistence checks for the isolated generated demo dataset.""" +import os +import subprocess +import sys +from uuid import uuid4 + +import psycopg +import pytest +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.core.config import settings +from app.services.content_digest import digest +from samples.peripheral.auth import Identity +from samples.peripheral.config import SampleSettings +from samples.peripheral.demo_data import build_demo_content +from samples.peripheral.errors import SampleFailure +from samples.peripheral.store import PostgresStore + + +pytestmark = pytest.mark.skipif( + os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1", + reason="set PERIPHERAL_SAMPLE_LIVE_TEST=1 to use provisioned peripheral_sample storage", +) + + +def connection(): + return psycopg.connect( + host=settings.DB_HOST, port=settings.DB_PORT, user=settings.DB_USER, + password=settings.DB_PASSWORD, dbname="tjwater_v2", connect_timeout=3, + options="-c statement_timeout=10000", row_factory=dict_row, + ) + + +@pytest.fixture +def live_demo_project(): + project_id = uuid4() + content = build_demo_content("live-" + str(project_id)) + revision = digest(content) + with connection() as conn: + conn.execute( + """INSERT INTO peripheral_sample.demo_datasets + (project_id,dataset_id,digest,document) VALUES (%s,'water-demo-v1',%s,%s)""", + (project_id, revision, Jsonb(content)), + ) + try: + yield project_id, content, revision + finally: + with connection() as conn: + conn.execute("DELETE FROM peripheral_sample.demo_datasets WHERE project_id=%s", (project_id,)) + conn.execute("DELETE FROM peripheral_sample.scenarios WHERE project_id=%s", (project_id,)) + + +def test_persisted_demo_is_read_from_fresh_connection_and_process(live_demo_project): + project_id, content, revision = live_demo_project + store = PostgresStore(SampleSettings(db_name="tjwater_v2")) + result = store.demo_data(Identity(str(project_id), str(uuid4()), False)) + assert result["source_revision"] == revision + assert result["observations"] == content["observations"] + + script = """ +import sys +from samples.peripheral.auth import Identity +from samples.peripheral.config import SampleSettings +from samples.peripheral.store import PostgresStore +result = PostgresStore(SampleSettings(db_name='tjwater_v2')).demo_data(Identity(sys.argv[1], sys.argv[2], False)) +assert result['source_revision'] == sys.argv[3] and len(result['observations']) == 240 +print('ok') +""" + child = subprocess.run( + [sys.executable, "-c", script, str(project_id), str(uuid4()), revision], + check=True, capture_output=True, text=True, + ) + assert child.stdout.strip() == "ok" + + +def test_demo_read_is_project_isolated_and_never_calls_generator(live_demo_project, monkeypatch): + project_id, _, _ = live_demo_project + monkeypatch.setattr( + "samples.peripheral.demo_data.build_demo_content", + lambda *args, **kwargs: pytest.fail("persisted reads must not invoke the generator"), + ) + store = PostgresStore(SampleSettings(db_name="tjwater_v2")) + assert store.demo_data(Identity(str(project_id), str(uuid4()), False))["project_id"] == str(project_id) + with pytest.raises(SampleFailure) as missing: + store.demo_data(Identity(str(uuid4()), str(uuid4()), False)) + assert missing.value.code == "SOURCE_NOT_CONFIGURED" + + +def test_tampered_document_is_rejected_and_cleanup_remains_scoped(live_demo_project): + project_id, _, _ = live_demo_project + with connection() as conn: + conn.execute( + """UPDATE peripheral_sample.demo_datasets + SET document=jsonb_set(document,'{seed}',to_jsonb(%s::text)) + WHERE project_id=%s AND dataset_id='water-demo-v1'""", + ("tampered", project_id), + ) + store = PostgresStore(SampleSettings(db_name="tjwater_v2")) + with pytest.raises(SampleFailure) as rejected: + store.demo_data(Identity(str(project_id), str(uuid4()), False)) + assert rejected.value.code == "INTEGRITY_MISMATCH" diff --git a/tests/integration/test_peripheral_sample_live.py b/tests/integration/test_peripheral_sample_live.py new file mode 100644 index 0000000..0dd1472 --- /dev/null +++ b/tests/integration/test_peripheral_sample_live.py @@ -0,0 +1,355 @@ +"""Opt-in checks against provisioned sample tables, never business-table writes.""" +from concurrent.futures import ThreadPoolExecutor +import os +import subprocess +import sys +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from samples.peripheral import schemas as peripheral_schemas +from samples.peripheral.api import create_app +from samples.peripheral.auth import Identity, identity +from samples.peripheral.config import SampleSettings +from samples.peripheral.errors import SampleFailure +from samples.peripheral.schemas import ( + AddFeedback, + CreateTask, + CreateTestDataRecord, + CreateWorkOrder, + DeleteResource, + Observations, + ScenarioCommand, + Task, + Transition, + UpdateTestDataRecord, + UpdateWorkOrder, + WorkOrder, +) +from samples.peripheral.store import PostgresStore + +pytestmark = pytest.mark.skipif(os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1", reason="explicit live sample opt-in required") + + +@pytest.fixture +def sample(): + store = PostgresStore(SampleSettings(enable_faults=True)) + who = Identity(str(uuid4()), str(uuid4()), True) + yield store, who + # Only this test's unguessable project key; tables created by the authorized setup. + with store.connection() as conn: + for table in ( + "work_order_revisions", "test_data_record_revisions", "work_orders", + "test_data_records", "feedback", "requests", "revisions", "tasks", "scenarios", + ): + from psycopg import sql + exists = conn.execute( + "SELECT to_regclass(%s) AS name", (f"peripheral_sample.{table}",) + ).fetchone()["name"] + if exists is not None: + conn.execute( + sql.SQL("DELETE FROM peripheral_sample.{} WHERE project_id=%s").format( + sql.Identifier(table) + ), + (who.project_id,), + ) + + +def command(): + return CreateTask.model_validate({ + "request_id": str(uuid4()), "title": "AUTOTEST isolated sample", "summary": "not a production source", + "source": {"system": "next", "task_id": str(uuid4()), "object_type": "validation", "object_id": str(uuid4()), "revision": 1}, + "asset_ids": ["test-pressure"], "analysis_window": {"start": "2026-04-30T08:00:00Z", "end": "2026-04-30T09:00:00Z"}, + "evidence_refs": [], "requested_check": "verification only", + }) + + +def move(store, who, task, status): + return store.transition(who, task["task_id"], Transition(request_id=uuid4(), expected_revision=task["revision"], target_status=status, reason="AUTOTEST")) + + +def feedback(task): + return AddFeedback.model_validate({"request_id": str(uuid4()), "expected_revision": task["revision"], "feedback_id": str(uuid4()), + "occurred_at": "2026-04-30T08:30:00Z", "applicable_time": task["analysis_window"], "asset_ids": task["asset_ids"], + "finding": "instrument_fault", "notes": "AUTOTEST synthetic only", "attachment_refs": []}) + + +def scenario(store, who, name): + return store.set_scenario(who, ScenarioCommand(request_id=uuid4(), scenario=name)) + + +def test_persistence_transitions_feedback_idempotency_and_isolation(sample): + store, who = sample + cmd = command() + task = store.create_task(who, cmd) + Task.model_validate(task) + fresh = PostgresStore(store.settings) + assert fresh.get_task(who, task["task_id"]) == task + # A new interpreter has no service memory or open DB connections to reuse. + probe = subprocess.run([sys.executable, "-c", + "import sys; from samples.peripheral.store import PostgresStore; " + "from samples.peripheral.config import SampleSettings; from samples.peripheral.auth import Identity; " + "print(PostgresStore(SampleSettings()).get_task(Identity(sys.argv[1],sys.argv[2],True),sys.argv[3])['revision'])", + who.project_id, who.user_id, task["task_id"]], capture_output=True, text=True, timeout=15, check=True) + assert probe.stdout.strip() == "1" + assert fresh.create_task(who, cmd) == task + with pytest.raises(SampleFailure, match="同一 request_id"): + store.create_task(who, cmd.model_copy(update={"title": "changed"})) + outsider = Identity(str(uuid4()), who.user_id, True) + with pytest.raises(SampleFailure) as exc: + store.get_task(outsider, task["task_id"]) + assert exc.value.code == "TASK_NOT_FOUND" + with pytest.raises(SampleFailure): + store.receipt(Identity(who.project_id, str(uuid4()), True), cmd.request_id) + with pytest.raises(SampleFailure): + store.create_task(Identity(who.project_id, who.user_id, False), command()) + with pytest.raises(SampleFailure): + move(store, who, task, "completed") + task = move(store, who, task, "processing") + stale = task.copy() + cmd_feedback = feedback(task) + task = store.add_feedback(who, task["task_id"], cmd_feedback) + repeated = cmd_feedback.model_copy(update={"request_id": uuid4()}) + assert store.add_feedback(who, task["task_id"], repeated) == task + with pytest.raises(SampleFailure) as exc: + store.add_feedback(who, task["task_id"], repeated.model_copy(update={"request_id": uuid4(), "notes": "different"})) + assert exc.value.code == "FEEDBACK_CONFLICT" + with pytest.raises(SampleFailure) as exc: + move(store, who, stale, "pending_review") + assert exc.value.code == "REVISION_CONFLICT" + task = move(store, who, task, "pending_review") + task = move(store, who, task, "processing") + task = move(store, who, task, "pending_review") + task = move(store, who, task, "completed") + assert len(task["feedback"]) == 1 + assert len(task["history"]) == 6 + with store.connection(readonly=True) as conn: + assert conn.execute("SELECT count(*) AS n FROM peripheral_sample.revisions WHERE project_id=%s", (who.project_id,)).fetchone()["n"] == task["revision"] + + +def test_concurrent_same_request_and_revision_conflict(sample): + store, who = sample + cmd = command() + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda _: store.create_task(who, cmd), range(4))) + assert len({r["task_id"] for r in results}) == 1 + task = results[0] + def race(_): + try: + return move(store, who, task, "processing")["revision"] + except SampleFailure as exc: + return exc.code + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(race, range(2))) + assert sorted(map(str, outcomes)) == ["2", "REVISION_CONFLICT"] + + +def test_fault_receipt_recovery_duplicate_conflict_and_late_state(sample): + store, who = sample + scenario(store, who, "response_lost") + cmd = command() + with pytest.raises(SampleFailure) as exc: + store.create_task(who, cmd) + assert exc.value.effect == "unknown" + task = store.receipt(who, cmd.request_id)["result"] + assert store.create_task(who, cmd) == task + scenario(store, who, "unavailable") + with pytest.raises(SampleFailure): + store.get_task(who, task["task_id"]) + assert store.receipt(who, cmd.request_id)["task_id"] == task["task_id"] + scenario(store, who, "conflicting_feedback") + task = move(store, who, task, "processing") + task = store.add_feedback(who, task["task_id"], feedback(task)) + assert {f["finding"] for f in task["feedback"]} == {"instrument_fault", "no_instrument_fault"} + scenario(store, who, "duplicate_feedback") + assert len(store.get_task(who, task["task_id"])["feedback"]) == 3 + scenario(store, who, "late_state") + assert store.get_task(who, task["task_id"])["revision"] == task["revision"] - 1 + scenario(store, who, "normal") + assert store.get_task(who, task["task_id"]) == task + disabled = PostgresStore(SampleSettings(enable_faults=False)) + with pytest.raises(SampleFailure) as exc: + scenario(disabled, who, "missing_data") + assert exc.value.code == "FAULTS_DISABLED" + + +def test_historical_monitoring_readonly_mapping_and_missing_data(sample): + store, who = sample + points = store.points(who, limit=500) + assert points["origin"] == "historical_replay" + assert points["items"] + point = points["items"][0] + kwargs = {"point_id": point["point_id"], "start": "2026-04-30T08:00:00Z", "end": "2026-04-30T16:00:00Z"} + normal = store.observations(who, **kwargs) + Observations.model_validate(normal) + assert normal["items"] + assert {x["unit"] for x in normal["items"]} == {point["unit"]} + with store.connection(readonly=True, timeseries=True) as conn: + assert conn.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] == "on" + scenario(store, who, "missing_data") + missing = store.observations(who, **kwargs) + assert missing["origin"] == "synthetic" + assert all(item["value"] is None for item in missing["items"][::2]) + assert store.alerts(who)["availability"] == "not_configured" + + +def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample): + store, who = sample + capabilities = store.managed_resource_capabilities() + if capabilities != {"work_orders": "available", "test_data": "available"}: + pytest.skip("managed peripheral resource tables are not provisioned") + + order_command = CreateWorkOrder( + request_id=uuid4(), + title="演示 live test work order", + summary="AUTOTEST isolated synthetic resource", + status="open", + priority="high", + assignee="AUTOTEST", + scheduled_at="2026-09-18T01:00:00Z", + ) + order = store.create_work_order(who, order_command) + WorkOrder.model_validate(order) + assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order + assert store.create_work_order(who, order_command) == order + order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder( + request_id=uuid4(), expected_revision=1, status="in_progress", priority="urgent", + )) + assert order["revision"] == 2 and order["status"] == "in_progress" + with pytest.raises(SampleFailure) as stale_order: + store.update_work_order(who, order["work_order_id"], UpdateWorkOrder( + request_id=uuid4(), expected_revision=1, status="completed", + )) + assert stale_order.value.code == "REVISION_CONFLICT" + + data_command = CreateTestDataRecord( + request_id=uuid4(), + label="演示 live pressure", + point_id="演示", + observed_at="2026-09-18T00:00:00Z", + value=31.2, + unit="m", + quality="valid", + notes="AUTOTEST synthetic only", + ) + record = store.create_test_data(who, data_command) + peripheral_schemas.TestDataRecord.model_validate(record) + assert PostgresStore(store.settings).get_test_data(who, record["record_id"]) == record + assert store.create_test_data(who, data_command) == record + record = store.update_test_data(who, record["record_id"], UpdateTestDataRecord( + request_id=uuid4(), expected_revision=1, value=None, clear_value=True, quality="missing", + )) + assert record["revision"] == 2 and record["value"] is None + + outsider = Identity(str(uuid4()), who.user_id, True) + assert store.list_work_orders(outsider, 20)["items"] == [] + assert store.list_test_data(outsider, 20)["items"] == [] + assert [item["work_order_id"] for item in store.list_work_orders(who, 20)["items"]] == [ + order["work_order_id"] + ] + assert [item["record_id"] for item in store.list_test_data(who, 20)["items"]] == [ + record["record_id"] + ] + + deleted_order = store.delete_work_order(who, order["work_order_id"], DeleteResource( + request_id=uuid4(), expected_revision=2, + )) + deleted_data = store.delete_test_data(who, record["record_id"], DeleteResource( + request_id=uuid4(), expected_revision=2, + )) + assert deleted_order["revision"] == 3 and deleted_data["revision"] == 3 + assert store.list_work_orders(who, 20)["items"] == [] + assert store.list_test_data(who, 20)["items"] == [] + with store.connection(readonly=True) as conn: + order_revisions = conn.execute( + """SELECT count(*) AS n FROM peripheral_sample.work_order_revisions + WHERE project_id=%s AND work_order_id=%s""", + (who.project_id, order["work_order_id"]), + ).fetchone()["n"] + data_revisions = conn.execute( + """SELECT count(*) AS n FROM peripheral_sample.test_data_record_revisions + WHERE project_id=%s AND record_id=%s""", + (who.project_id, record["record_id"]), + ).fetchone()["n"] + assert order_revisions == 3 and data_revisions == 3 + + +def test_managed_resource_http_routes_use_provisioned_storage(sample): + store, who = sample + capabilities = store.managed_resource_capabilities() + if capabilities != {"work_orders": "available", "test_data": "available"}: + pytest.skip("managed peripheral resource tables are not provisioned") + + app = create_app(store.settings) + app.dependency_overrides[identity] = lambda: who + headers = { + "Authorization": "Bearer TEST-FIXTURE-ONLY", + "X-Project-Id": who.project_id, + } + with TestClient(app) as client: + order = client.post("/api/sample/v1/work-orders", headers=headers, json={ + "request_id": str(uuid4()), + "title": "演示 HTTP test work order", + "summary": "AUTOTEST isolated synthetic resource", + "status": "open", + "priority": "normal", + "assignee": None, + "scheduled_at": None, + }) + assert order.status_code == 201, order.text + order_document = order.json() + listed_orders = client.get("/api/sample/v1/work-orders", headers=headers) + assert [item["work_order_id"] for item in listed_orders.json()["items"]] == [ + order_document["work_order_id"] + ] + changed_order = client.patch( + f"/api/sample/v1/work-orders/{order_document['work_order_id']}", + headers=headers, + json={ + "request_id": str(uuid4()), "expected_revision": 1, + "status": "in_progress", + }, + ) + assert changed_order.status_code == 200 and changed_order.json()["revision"] == 2 + + record = client.post("/api/sample/v1/test-data", headers=headers, json={ + "request_id": str(uuid4()), + "label": "演示 HTTP pressure", + "point_id": "演示", + "observed_at": "2026-09-18T00:00:00Z", + "value": 31.2, + "unit": "m", + "quality": "valid", + "notes": "AUTOTEST synthetic only", + }) + assert record.status_code == 201, record.text + record_document = record.json() + listed_data = client.get("/api/sample/v1/test-data", headers=headers) + assert [item["record_id"] for item in listed_data.json()["items"]] == [ + record_document["record_id"] + ] + changed_data = client.patch( + f"/api/sample/v1/test-data/{record_document['record_id']}", + headers=headers, + json={ + "request_id": str(uuid4()), "expected_revision": 1, + "value": 30.8, + }, + ) + assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2 + + deleted_order = client.request( + "DELETE", + f"/api/sample/v1/work-orders/{order_document['work_order_id']}", + headers=headers, + json={"request_id": str(uuid4()), "expected_revision": 2}, + ) + deleted_data = client.request( + "DELETE", + f"/api/sample/v1/test-data/{record_document['record_id']}", + headers=headers, + json={"request_id": str(uuid4()), "expected_revision": 2}, + ) + assert deleted_order.status_code == 200 and deleted_order.json()["revision"] == 3 + assert deleted_data.status_code == 200 and deleted_data.json()["revision"] == 3 diff --git a/tests/integration/test_task_alerts_live.py b/tests/integration/test_task_alerts_live.py new file mode 100644 index 0000000..57ba8c0 --- /dev/null +++ b/tests/integration/test_task_alerts_live.py @@ -0,0 +1,93 @@ +"""Opt-in PostgreSQL checks for the task alert event state machine.""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from psycopg import AsyncConnection +from psycopg.rows import dict_row + +from app.core.config import settings +from app.domain.schemas.task_alert import TaskAlertEvent +from app.infra.db.postgresql.task_alerts import TaskAlertRepository +from app.services.task_alerts import TaskAlertService + + +pytestmark = [ + pytest.mark.skipif( + os.getenv("TASK_ALERT_LIVE_TEST") != "1", + reason="set TASK_ALERT_LIVE_TEST=1 to use provisioned workbench alert tables", + ), + pytest.mark.anyio, +] + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_alert_event_round_trip_and_idempotency(): + project_id = uuid4() + now = datetime(2026, 9, 18, 3, tzinfo=timezone.utc) + conn = await AsyncConnection.connect( + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + dbname="tjwater_v2", + connect_timeout=3, + options="-c statement_timeout=10000", + row_factory=dict_row, + ) + try: + service = TaskAlertService( + TaskAlertRepository(conn, project_id), + None, + clock=lambda: now, + internal_producer_id="AUTOTEST-scheduler", + ) + event = TaskAlertEvent( + event_id=uuid4(), + producer_id="AUTOTEST-scheduler", + alert_id=f"AUTOTEST-{uuid4()}", + event_type="raised", + occurred_at=now, + condition_id=None, + source={ + "task_type": "scheduled_task", + "task_id": "AUTOTEST-schedule", + "run_id": "AUTOTEST-occurrence", + }, + title="AUTOTEST alert", + description="Synthetic integration record", + severity="warning", + ) + first = await service.push(event) + repeated = await service.push(event) + assert first == repeated + row = await conn.execute( + """SELECT revision,status,document FROM workbench.alerts + WHERE project_id=%s AND alert_id=%s""", + (project_id, event.alert_id), + ) + stored = await row.fetchone() + assert stored["revision"] == 1 and stored["status"] == "active" + assert stored["document"]["producer_id"] == "AUTOTEST-scheduler" + finally: + # Assertion reads open an implicit transaction. End it before starting the + # cleanup transaction, otherwise closing the connection rolls the nested + # cleanup savepoint back together with that read transaction. + await conn.rollback() + async with conn.transaction(): + await conn.execute( + "DELETE FROM workbench.alert_events WHERE project_id=%s", (project_id,) + ) + await conn.execute( + "DELETE FROM workbench.alerts WHERE project_id=%s", (project_id,) + ) + await conn.close() diff --git a/tests/unit/test_geoserver_client.py b/tests/unit/test_geoserver_client.py index 2cf7791..f6496d7 100644 --- a/tests/unit/test_geoserver_client.py +++ b/tests/unit/test_geoserver_client.py @@ -44,3 +44,34 @@ def test_project_workspace_sets_30_day_client_cache(monkeypatch): assert layers == geoserver_client.PROJECT_LAYER_NAMES assert cache_updates == ["2592000"] * len(layers) + + +def test_publish_feature_type_updates_existing_layer_and_cache(monkeypatch): + requests: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path)) + if request.method == "GET" and request.url.path.endswith("/featuretypes/demo_layer.json"): + return httpx.Response(200, json={"featureType": {"name": "demo_layer"}}) + if request.method == "GET" and "/gwc/rest/layers/" in request.url.path: + return httpx.Response(200, content=b"demo") + return httpx.Response(200) + + monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin") + monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret") + monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_CLIENT_CACHE_SECONDS", 2_592_000) + http_client = httpx.Client( + base_url="http://geoserver.example/geoserver", + transport=httpx.MockTransport(handler), + ) + + geoserver_client.GeoServerAdminClient(client=http_client).publish_feature_type( + workspace="demo", + datastore="demo", + layer="demo_layer", + title="Demo layer", + map_bbox=(1.0, 2.0, 3.0, 4.0), + ) + + assert ("PUT", "/geoserver/rest/workspaces/demo/datastores/demo/featuretypes/demo_layer") in requests + assert ("PUT", "/geoserver/gwc/rest/layers/demo:demo_layer.xml") in requests diff --git a/tests/unit/test_gis_attributes_repository.py b/tests/unit/test_gis_attributes_repository.py new file mode 100644 index 0000000..9f2658c --- /dev/null +++ b/tests/unit/test_gis_attributes_repository.py @@ -0,0 +1,45 @@ +import asyncio +from datetime import date + +from app.infra.db.postgresql.gis_attributes import GisAttributeRepository +from tests.conftest import FakeConnection, FakeCursor + + +def test_list_element_groups_returns_empty_when_source_gis_is_not_provisioned(): + cursor = FakeCursor(fetchone_results=[{"relation": None}]) + + result = asyncio.run( + GisAttributeRepository.list_element_groups( + FakeConnection(cursor), "pipes", "P-1" + ) + ) + + assert result == [] + assert len(cursor.executed) == 1 + + +def test_list_element_groups_reads_linked_attributes(): + row = { + "dataset_key": "fx_pipe_inventory_20200811", + "dataset_title": "奉贤 DN100 及以上管网台账", + "source_date": date(2020, 8, 11), + "layer_key": "fx_20200811_dn100_pipes", + "layer_name": "DN100 及以上管线台账", + "source_fid": 42, + "match_method": "geometry", + "match_distance_m": 0.12, + "attributes": {"PIPE_D": 300, "MATERIAL": "DI"}, + } + cursor = FakeCursor( + fetchone_results=[{"relation": "source_gis.element_links"}], + fetchall_results=[[row]], + ) + + result = asyncio.run( + GisAttributeRepository.list_element_groups( + FakeConnection(cursor), "pipes", "P-42" + ) + ) + + assert result == [row] + assert cursor.executed[1][1] == ("pipes", "P-42") diff --git a/tests/unit/test_peripheral_demo_data.py b/tests/unit/test_peripheral_demo_data.py new file mode 100644 index 0000000..50ff027 --- /dev/null +++ b/tests/unit/test_peripheral_demo_data.py @@ -0,0 +1,154 @@ +import json +import inspect +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.services.content_digest import digest +from samples.peripheral import auth +from samples.peripheral.api import create_app, store +from samples.peripheral.config import SampleSettings +from samples.peripheral.demo_data import VERSION, build_demo_content +from samples.peripheral.errors import SampleFailure +from samples.peripheral.store import PostgresStore + + +START = datetime(2026, 9, 17, tzinfo=timezone.utc) + + +def test_same_seed_start_and_version_are_stable_across_processes(): + content = build_demo_content("repeatable", START) + assert content == build_demo_content("repeatable", START) + assert content["generator_version"] == VERSION + expected_digest = digest(content) + script = """ +import json +from datetime import datetime +from app.services.content_digest import digest +from samples.peripheral.demo_data import build_demo_content +content = build_demo_content('repeatable', datetime.fromisoformat('2026-09-17T00:00:00+00:00')) +print(json.dumps({'digest': digest(content), 'ids': [x['record_id'] for x in content['observations'][:3]]})) +""" + result = subprocess.run( + [sys.executable, "-c", script], check=True, capture_output=True, text=True, + ) + child = json.loads(result.stdout) + assert child["digest"] == expected_digest + assert child["ids"] == [item["record_id"] for item in content["observations"][:3]] + assert all(UUID(item["record_id"]).version == 5 for item in content["observations"]) + + +def test_default_canonical_digest_is_versioned_regression_anchor(): + content = build_demo_content() + assert digest(content) == "sha256:c630f66ad0247f70a4afe79cc5f95f4be35d54acfe85fbfe39f3166ee09a57c5" + assert (len(content["points"]), len(content["observations"]), len(content["device_states"])) == (5, 240, 9) + + +def test_seed_changes_content_and_timezone_is_normalized(): + base = build_demo_content("seed-a", START) + assert digest(base) != digest(build_demo_content("seed-b", START)) + same_in_china = build_demo_content("seed-a", START.astimezone(timezone(timedelta(hours=8)))) + assert same_in_china == base + assert base["window"]["start"] == "2026-09-17T00:00:00Z" + + +@pytest.mark.parametrize("seed", ["", "x" * 129, None, 7]) +def test_invalid_seed_is_rejected(seed): + with pytest.raises(ValueError, match="seed"): + build_demo_content(seed, START) + + +@pytest.mark.parametrize("start", [ + datetime(2026, 9, 17), + datetime(2026, 9, 17, 0, 1, tzinfo=timezone.utc), + datetime(2026, 9, 17, 0, 0, 1, tzinfo=timezone.utc), +]) +def test_invalid_start_is_rejected(start): + with pytest.raises(ValueError, match="timezone|aligned"): + build_demo_content("seed", start) + + +def test_story_counts_null_zero_and_temporal_links_are_explicit(): + content = build_demo_content("story", START) + assert len(content["points"]) == 5 + assert len(content["observations"]) == 240 + assert len(content["device_states"]) == 9 + assert len(content["alerts"]) == 2 + assert len(content["operations"]) == 4 + + missing = [item for item in content["observations"] if item["value"] is None] + zero = [item for item in content["observations"] if item["value"] == 0.0] + assert len(missing) == 4 and all(item["quality"] == "missing" for item in missing) + assert len(zero) == 4 and all(item["quality"] == "valid" for item in zero) + + states = {(item["asset_id"], item["state"]): item["observed_at"] for item in content["device_states"]} + communication = next(item for item in content["alerts"] if item["code"] == "communication_lost") + assert communication["asset_id"] == "DEMO-P001" + assert communication["occurred_at"] == states[("DEMO-P001", "offline")] + assert communication["cleared_at"] == states[("DEMO-P001", "online")] + operations = {(item["asset_id"], item["operation"]): item["occurred_at"] for item in content["operations"]} + assert operations[("DEMO-PUMP-01", "pump_stop")] == states[("DEMO-PUMP-01", "stopped")] + assert operations[("DEMO-VALVE-01", "valve_close")] == states[("DEMO-VALVE-01", "closed")] + assert len({item["record_id"] for item in content["observations"] + content["device_states"] + content["operations"]}) == 253 + + +class FakeStore: + def __init__(self, result=None, failure=None): + self.result = result + self.failure = failure + self.reads = 0 + + def demo_data(self, who): + self.reads += 1 + if self.failure: + raise self.failure + return {**self.result, "project_id": who.project_id, + "source_id": "test:demo", "source_revision": digest(self.result)} + + +def test_demo_endpoint_uses_next_auth_and_allows_read_only_permission(monkeypatch): + project_id, user_id = uuid4(), uuid4() + fake = FakeStore(build_demo_content("api", START)) + app = create_app(SampleSettings(project_code="tjwater_v2")) + app.dependency_overrides[store] = lambda: fake + monkeypatch.setattr(auth, "next_get", lambda request, path, params=None: { + "project_id": str(project_id), "user_id": str(user_id), "network": "tjwater_v2", + "permissions": ["simulation.view"], + }) + response = TestClient(app).get("/api/sample/v1/demo-data", headers={ + "Authorization": "Bearer TEST", "X-Project-Id": str(project_id), + }) + assert response.status_code == 200 + assert response.json()["project_id"] == str(project_id) + assert fake.reads == 1 + + +def test_demo_endpoint_requires_auth_and_reports_missing_storage_without_seeding(): + fake = FakeStore(failure=SampleFailure( + "STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503, + )) + app = create_app(SampleSettings(project_code="tjwater_v2")) + app.dependency_overrides[store] = lambda: fake + client = TestClient(app) + unauthorized = client.get("/api/sample/v1/demo-data") + assert unauthorized.status_code == 401 + assert fake.reads == 0 + # Authentication is deliberately isolated here so this assertion tests the + # missing-table behavior: the API reports it and never invokes a generator. + from samples.peripheral.api import identity + from samples.peripheral.auth import Identity + app.dependency_overrides[identity] = lambda: Identity(str(uuid4()), str(uuid4()), False) + unavailable = client.get("/api/sample/v1/demo-data") + assert unavailable.status_code == 503 + assert unavailable.json()["code"] == "STORAGE_NOT_CONFIGURED" + assert fake.reads == 1 + + +def test_demo_read_path_contains_no_schema_or_seed_writes(): + source = inspect.getsource(PostgresStore.demo_data).upper() + assert "SELECT " in source + assert all(keyword not in source for keyword in ("CREATE ", "ALTER ", "INSERT ", "UPDATE ", "DELETE ")) diff --git a/tests/unit/test_peripheral_managed_resources.py b/tests/unit/test_peripheral_managed_resources.py new file mode 100644 index 0000000..d926ecd --- /dev/null +++ b/tests/unit/test_peripheral_managed_resources.py @@ -0,0 +1,129 @@ +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from samples.peripheral.api import create_app, store +from samples.peripheral.auth import Identity, identity +from samples.peripheral.schemas import CreateTestDataRecord, UpdateTestDataRecord, UpdateWorkOrder + + +PROJECT_ID = uuid4() +USER_ID = uuid4() + + +class ManagedStore: + def __init__(self): + self.calls = [] + + def list_work_orders(self, who, limit, cursor): + self.calls.append(("list_work_orders", who, limit, cursor)) + return {"items": [], "next_cursor": None} + + def create_work_order(self, who, command): + self.calls.append(("create_work_order", who, command)) + now = "2026-09-18T00:00:00Z" + return command.model_dump(mode="json", exclude={"request_id"}) | { + "work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1, + "origin": "synthetic", "created_at": now, "updated_at": now, + "created_by": str(USER_ID), "updated_by": str(USER_ID), + } + + def update_work_order(self, who, work_order_id, command): + self.calls.append(("update_work_order", who, work_order_id, command)) + return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: { + "title": command.title or "updated", "summary": command.summary or "summary", + "status": command.status or "open", "priority": command.priority or "normal", + "assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1, + "work_order_id": str(work_order_id)} + + def delete_work_order(self, who, work_order_id, command): + self.calls.append(("delete_work_order", who, work_order_id, command)) + return {"resource_type": "work_order", "resource_id": str(work_order_id), + "revision": command.expected_revision + 1, "deleted": True} + + def list_test_data(self, who, limit, cursor): + self.calls.append(("list_test_data", who, limit, cursor)) + return {"items": [], "next_cursor": None} + + def create_test_data(self, who, command): + self.calls.append(("create_test_data", who, command)) + now = "2026-09-18T00:00:00Z" + return command.model_dump(mode="json", exclude={"request_id"}) | { + "record_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1, + "origin": "synthetic", "created_at": now, "updated_at": now, + "created_by": str(USER_ID), "updated_by": str(USER_ID), + } + + def update_test_data(self, who, record_id, command): + self.calls.append(("update_test_data", who, record_id, command)) + return self.create_test_data(who, CreateTestDataRecord( + request_id=command.request_id, label=command.label or "updated", point_id=command.point_id or "演示", + observed_at=command.observed_at or "2026-09-18T00:00:00Z", value=command.value, + unit=command.unit or "m", quality=command.quality or "valid", notes=command.notes or "", + )) | {"revision": command.expected_revision + 1, "record_id": str(record_id)} + + def delete_test_data(self, who, record_id, command): + self.calls.append(("delete_test_data", who, record_id, command)) + return {"resource_type": "test_data_record", "resource_id": str(record_id), + "revision": command.expected_revision + 1, "deleted": True} + + +def client_with(fake): + app = create_app() + app.dependency_overrides[store] = lambda: fake + app.dependency_overrides[identity] = lambda: Identity(str(PROJECT_ID), str(USER_ID), True) + return TestClient(app) + + +def test_managed_work_order_and_data_routes_are_typed_crud(): + fake = ManagedStore() + with client_with(fake) as client: + assert client.get("/api/sample/v1/work-orders").json()["items"] == [] + created = client.post("/api/sample/v1/work-orders", json={ + "request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调", + "status": "open", "priority": "high", "assignee": None, "scheduled_at": None, + }) + assert created.status_code == 201 and created.json()["origin"] == "synthetic" + work_order_id = created.json()["work_order_id"] + changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={ + "request_id": str(uuid4()), "expected_revision": 1, "status": "in_progress", + "clear_assignee": False, "clear_scheduled_at": False, + }) + assert changed.status_code == 200 and changed.json()["revision"] == 2 + deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={ + "request_id": str(uuid4()), "expected_revision": 2, + }) + assert deleted.json()["resource_type"] == "work_order" + + assert client.get("/api/sample/v1/test-data").json()["items"] == [] + data = client.post("/api/sample/v1/test-data", json={ + "request_id": str(uuid4()), "label": "演示 压力", "point_id": "演示", + "observed_at": "2026-09-18T00:00:00Z", "value": 31.2, "unit": "m", + "quality": "valid", "notes": "联调", + }) + assert data.status_code == 201 and data.json()["point_id"] == "演示" + record_id = data.json()["record_id"] + changed_data = client.patch(f"/api/sample/v1/test-data/{record_id}", json={ + "request_id": str(uuid4()), "expected_revision": 1, "value": 30.8, "clear_value": False, + }) + assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2 + deleted_data = client.request("DELETE", f"/api/sample/v1/test-data/{record_id}", json={ + "request_id": str(uuid4()), "expected_revision": 2, + }) + assert deleted_data.json()["resource_type"] == "test_data_record" + + +def test_managed_update_models_reject_empty_or_conflicting_changes(): + request_id = uuid4() + for model, payload in ( + (UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1}), + (UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}), + (UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1, + "value": 1.0, "clear_value": True}), + ): + try: + model.model_validate(payload) + except ValueError: + pass + else: + raise AssertionError("invalid partial update was accepted") diff --git a/tests/unit/test_postgresql_analysis_repository.py b/tests/unit/test_postgresql_analysis_repository.py index 110ec89..5ba4d61 100644 --- a/tests/unit/test_postgresql_analysis_repository.py +++ b/tests/unit/test_postgresql_analysis_repository.py @@ -40,7 +40,7 @@ def test_list_results_uses_typed_comparison_when_result_type_is_present(): query, params = conn.cursor_instance.calls[0] assert "result_type = %s" in query assert "%s IS NULL" not in query - assert params == (run_id, "leakage_identification") + assert params == (run_id, "leakage_identification", 100, 0) def test_list_results_omits_result_type_filter_when_not_requested(): @@ -51,4 +51,56 @@ def test_list_results_omits_result_type_filter_when_not_requested(): query, params = conn.cursor_instance.calls[0] assert "result_type = %s" not in query - assert params == (run_id,) + assert params == (run_id, 100, 0) + + +def test_list_runs_and_results_apply_explicit_page_bounds(): + conn = _FakeConnection() + run_id = uuid4() + + asyncio.run(AnalysisRepository.list_runs(conn, limit=25, offset=50)) + asyncio.run( + AnalysisRepository.list_results(conn, run_id, limit=40, offset=80) + ) + + run_query, run_params = conn.cursor_instance.calls[0] + result_query, result_params = conn.cursor_instance.calls[1] + assert "LIMIT %s OFFSET %s" in run_query + assert run_params == (25, 50) + assert "LIMIT %s OFFSET %s" in result_query + assert result_params == (run_id, 40, 80) + + +def test_list_recent_runs_is_bounded_and_orders_newest_first(): + conn = _FakeConnection() + + asyncio.run(AnalysisRepository.list_recent_runs(conn, limit=25)) + + query, params = conn.cursor_instance.calls[0] + assert "ORDER BY created_at DESC, run_id" in query + assert "LIMIT %s" in query + assert params == (25,) + + +def test_list_recent_results_bounds_latest_rows_then_restores_display_order(): + conn = _FakeConnection() + run_id = uuid4() + + asyncio.run(AnalysisRepository.list_recent_results(conn, run_id, limit=25)) + + query, params = conn.cursor_instance.calls[0] + assert "ORDER BY created_at DESC, result_id DESC" in query + assert "ORDER BY created_at, result_id" in query + assert params == (run_id, 25) + + +def test_list_run_summaries_is_bounded_and_checks_for_results(): + conn = _FakeConnection() + + asyncio.run(AnalysisRepository.list_run_summaries(conn, limit=11, offset=20)) + + query, params = conn.cursor_instance.calls[0] + assert "EXISTS" in query + assert "analysis.results" in query + assert "ORDER BY r.created_at DESC, r.run_id" in query + assert params == (11, 20) diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py index 7ddcf59..fd2c39a 100644 --- a/tests/unit/test_sensor_placement_service.py +++ b/tests/unit/test_sensor_placement_service.py @@ -159,9 +159,13 @@ def test_create_sensor_placement_writes_run_and_result_atomically(monkeypatch): min_diameter=300, created_by="alice", sensor_locations=["J1", "J2"], + sensor_type="water_quality", + method="sensitivity", ) assert created["sensor_count"] == 2 + assert created["sensor_type"] == "water_quality" + assert created["method"] == "sensitivity" statements = [call.args[0] for call in cursor.execute.call_args_list] assert "INSERT INTO analysis.runs" in statements[0] assert "INSERT INTO analysis.results" in statements[1] diff --git a/tests/unit/test_task_alerts.py b/tests/unit/test_task_alerts.py new file mode 100644 index 0000000..09bce9f --- /dev/null +++ b/tests/unit/test_task_alerts.py @@ -0,0 +1,140 @@ +from contextlib import asynccontextmanager +from copy import deepcopy +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.domain.domain_errors import DomainError +from app.domain.schemas.task_alert import TaskAlertEvent +from app.services.task_alerts import TaskAlertService + + +NOW = datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc) +USER_ID = uuid4() + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class Repository: + def __init__(self): + self.events = {} + self.alerts = {} + + @asynccontextmanager + async def transaction(self): + yield self + + async def lock_alert(self, _alert_id): + return None + + async def get_event(self, producer_id, event_id): + return deepcopy(self.events.get((producer_id, event_id))) + + async def get_alert(self, alert_id): + value = self.alerts.get(alert_id) + return (value["revision"], deepcopy(value["document"])) if value else None + + async def save_event(self, event, resulting_state): + self.events[(event["producer_id"], event["event_id"])] = deepcopy( + event | {"resulting_state": resulting_state} + ) + + async def save_alert(self, state): + self.alerts[state["alert_id"]] = { + "revision": state["revision"], + "document": deepcopy({key: value for key, value in state.items() if key != "revision"}), + } + + +def command( + event_type="raised", + *, + event_id=None, + title="Low pressure", + producer_id=f"principal:{USER_ID}", +): + return TaskAlertEvent( + event_id=event_id or uuid4(), + producer_id=producer_id, + alert_id="schedule-1:2026-09-18T02:30:00Z", + event_type=event_type, + occurred_at=NOW, + condition_id=None, + source={ + "task_type": "scheduled_task", + "task_id": "schedule-1", + "run_id": "occurrence-1", + }, + title=title, + description="Scheduled task reported a failure.", + severity="warning", + ) + + +@pytest.mark.anyio +async def test_alert_events_are_idempotent_and_transitions_are_monotonic(): + repository = Repository() + service = TaskAlertService( + repository, + SimpleNamespace(project_role="member", user_id=USER_ID), + clock=lambda: NOW, + ) + raised_command = command() + raised = await service.push(raised_command) + repeated = await service.push(raised_command) + assert raised.status == repeated.status == "active" + assert raised.revision == repeated.revision == 1 + + acknowledged = await service.push(command("acknowledged")) + resolved = await service.push(command("resolved")) + assert acknowledged.status == "acknowledged" and acknowledged.revision == 2 + assert resolved.status == "resolved" and resolved.revision == 3 + + with pytest.raises(DomainError) as invalid: + await service.push(command("acknowledged")) + assert invalid.value.code == "INVALID_TRANSITION" + + +@pytest.mark.anyio +async def test_alert_event_reuse_and_fact_rewrites_are_rejected(): + repository = Repository() + service = TaskAlertService( + repository, + SimpleNamespace(project_role="member", user_id=USER_ID), + clock=lambda: NOW, + ) + event_id = uuid4() + await service.push(command(event_id=event_id)) + + with pytest.raises(DomainError) as reused: + await service.push(command(event_id=event_id, title="Changed")) + assert reused.value.code == "IDEMPOTENCY_CONFLICT" + + with pytest.raises(DomainError) as rewritten: + await service.push(command("resolved", title="Changed")) + assert rewritten.value.code == "IDEMPOTENCY_CONFLICT" + + +@pytest.mark.anyio +async def test_internal_alert_producer_is_bound_without_user_membership(): + repository = Repository() + service = TaskAlertService( + repository, + None, + clock=lambda: NOW, + internal_producer_id="scheduler:demo", + ) + + state = await service.push(command(producer_id="scheduler:demo")) + assert state.status == "active" + + mismatched = command(producer_id="scheduler:demo") + mismatched.producer_id = "another-task" + with pytest.raises(DomainError) as denied: + await service.push(mismatched) + assert denied.value.code == "PERMISSION_DENIED" diff --git a/tests/unit/test_workbench_runtime.py b/tests/unit/test_workbench_runtime.py new file mode 100644 index 0000000..6b7a29f --- /dev/null +++ b/tests/unit/test_workbench_runtime.py @@ -0,0 +1,284 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert + +from app.services.workbench_runtime import ( + build_workbench_runtime_snapshot, + project_latest_analysis, + project_sensor_location_map_payload, + sensor_location_ids_for_map_projection, +) + +NOW = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def test_workbench_alert_rejects_naive_datetimes(): + with pytest.raises(ValidationError): + WorkbenchPersistedAlert.model_validate({ + "alert_id": "alert-1", + "condition_id": None, + "source": { + "task_type": "scheduled_task", + "task_id": "schedule-1", + "run_id": "occurrence-1", + }, + "title": "test", + "description": "test", + "occurred_at": "2026-09-18T02:30:00", + "severity": "warning", + "status": "active", + }) +@pytest.mark.anyio +async def test_snapshot_reports_not_configured_without_persisted_sections(): + snapshot = await build_workbench_runtime_snapshot( + clock=lambda: NOW, + ) + + assert snapshot == { + "availability": "not_configured", + "source": "none", + "scenario_availability": "not_configured", + "artifact_availability": "not_configured", + "schedule_availability": "not_configured", + "work_order_availability": "not_configured", + "alert_availability": "not_configured", + "generated_at": NOW, + "conditions": [], + "alerts": [], + "scenarios": [], + "latest_analysis": None, + "work_orders": [], + } + + +def analysis_run(**overrides): + value = { + "run_id": uuid4(), + "name": "北区爆管定位", + "run_type": "burst_location", + "created_by": "alice", + "created_at": NOW, + "started_at": NOW, + "status": "completed", + "parameters": {}, + } + value.update(overrides) + return value + + +@pytest.mark.anyio +async def test_snapshot_keeps_analysis_results_out_of_operational_conditions(): + failed_analysis = analysis_run(status="failed") + result_created_at = NOW.replace(minute=31) + + snapshot = await build_workbench_runtime_snapshot( + analysis_runs=[failed_analysis], + latest_analysis_results=[ + { + "result_type": "burst_location", + "created_at": result_created_at, + "payload": { + "drawing_payload": { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "properties": {"label": "后端结果"}, + "geometry": { + "type": "Point", + "coordinates": [121.5, 30.9], + }, + }], + } + }, + } + ], + clock=lambda: NOW, + ) + + assert snapshot["source"] == "none" + assert snapshot["scenario_availability"] == "ready" + assert snapshot["artifact_availability"] == "ready" + assert snapshot["schedule_availability"] == "not_configured" + assert snapshot["work_order_availability"] == "not_configured" + assert snapshot["conditions"] == [] + assert snapshot["scenarios"] == [ + { + "id": f"analysis-run-{failed_analysis['run_id']}", + "run_id": str(failed_analysis["run_id"]), + "name": "北区爆管定位", + "description": "爆管定位 · 已失败 · 创建人 alice", + "status": "review", + "run_type": "burst_location", + "run_type_label": "爆管定位", + "run_status": "failed", + "created_at": NOW, + } + ] + assert snapshot["latest_analysis"]["results"] == [ + { + "result_type": "burst_location", + "created_at": result_created_at, + "facts": [], + } + ] + assert snapshot["latest_analysis"]["map_payload"]["features"][0]["properties"] == { + "label": "后端结果" + } + assert snapshot["alerts"] == [] + + +def test_latest_analysis_projects_only_known_result_facts(): + projected = project_latest_analysis( + analysis_run(run_type="sensor_placement"), + [{ + "result_type": "sensor_placement", + "node_id": "J-1", + "created_at": NOW, + "payload": { + "sensor_number": 2, + "minimum_diameter": 300, + "sensor_locations": ["J-1", "J-2"], + "internal_debug": "must-not-leak", + }, + }], + ) + + assert projected["results"][0]["facts"] == [ + {"label": "关联节点", "value": "J-1"}, + {"label": "测点数量", "value": "2"}, + {"label": "最小管径参数", "value": "300"}, + {"label": "测点位置", "value": "J-1、J-2"}, + ] + assert "must-not-leak" not in str(projected) + + +def test_sensor_location_projection_uses_latest_result_order_and_known_nodes(): + results = [ + { + "result_type": "sensor_placement", + "payload": {"sensor_locations": ["J-old"]}, + }, + { + "result_type": "sensor_placement", + "payload": {"sensor_locations": ["J-2", "J-1", "J-2"]}, + }, + ] + + node_ids = sensor_location_ids_for_map_projection(results) + payload = project_sensor_location_map_payload(node_ids, [ + {"node_id": "J-1", "longitude": 121.4, "latitude": 30.8}, + {"node_id": "J-2", "longitude": 121.5, "latitude": 30.9}, + ]) + + assert node_ids == ["J-2", "J-1"] + assert payload is not None + assert [feature["id"] for feature in payload["features"]] == ["J-2", "J-1"] + + +@pytest.mark.anyio +async def test_snapshot_projects_persisted_work_orders_and_alerts(): + work_order = { + "id": "wo-1", + "code": "WO-20260917-001", + "scheduled_at": NOW.replace(hour=3), + "title": "北区阀门现场复核", + "summary": "复核阀门开度并回传。", + "status": "running", + "risk_level": "attention", + "updated_at": NOW, + "detail": "来自正式工单同步适配器。", + "recommendation": "完成后复核压力。", + "duration_minutes": 45, + "source": "生产工单系统", + "dispatcher": "调度中心", + "priority": "urgent", + "subtasks": [{ + "id": "inspect-valve", + "title": "阀门开度复核", + "summary": "现场复核阀门开度。", + "status": "running", + "scheduled_at": NOW.replace(hour=3), + "location": "北区阀门 V-1", + "assignee": "北区班组", + "duration_minutes": 45, + "reply_window_minutes": 20, + "stages": [ + { + "id": "dispatch", + "sequence": 1, + "title": "派发", + "status": "done", + "owner": "调度中心", + "time_label": "10:20", + "description": "已派发。", + }, + { + "id": "execution", + "sequence": 2, + "title": "执行中", + "status": "current", + "owner": "北区班组", + "time_label": "10:30—11:15", + "description": "现场执行中。", + }, + { + "id": "reply", + "sequence": 3, + "title": "待复令", + "status": "pending", + "owner": "北区班组 → 调度中心", + "time_label": "11:15—11:35", + "description": "等待执行结果。", + }, + ], + "reply_requirements": ["回传阀门开度", "复核压力"], + }], + } + persisted_alert = { + "alert_id": "alarm-1", + "condition_id": None, + "source": { + "task_type": "scheduled_task", + "task_id": "scada-diagnosis-5m", + "run_id": "trigger-1", + }, + "title": "北区设备通信告警", + "description": "由 SCADA 数据诊断任务明确推送。", + "occurred_at": NOW.replace(minute=29), + "severity": "warning", + "status": "active", + } + + snapshot = await build_workbench_runtime_snapshot( + work_order_storage_configured=True, + work_orders=[work_order], + alert_storage_configured=True, + persisted_alerts=[persisted_alert], + clock=lambda: NOW, + ) + + assert snapshot["availability"] == "ready" + assert snapshot["source"] == "none" + assert snapshot["schedule_availability"] == "not_configured" + assert snapshot["work_order_availability"] == "ready" + assert snapshot["alert_availability"] == "ready" + assert snapshot["conditions"] == [] + assert snapshot["alerts"] == [{ + "id": "backend-alert-alarm-1", + "condition_id": None, + "source": persisted_alert["source"], + "title": "北区设备通信告警", + "description": "由 SCADA 数据诊断任务明确推送。", + "occurred_at": NOW.replace(minute=29), + "severity": "warning", + }] + assert snapshot["work_orders"][0]["code"] == "WO-20260917-001"