feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+135
View File
@@ -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
@@ -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"] == []