From 21dd393aee3bdafe4426981a715bc80aaa424e1d Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 23 Mar 2026 18:03:00 +0800 Subject: [PATCH 01/93] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Copilot=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8A=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + app/api/v1/endpoints/copilot_chat.py | 121 ++++++++++++++++++ app/api/v1/router.py | 4 + app/core/config.py | 4 + copilot-sidecar-python/server.py | 180 +++++++++++++++++++++++++++ requirements.txt | 3 +- 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/endpoints/copilot_chat.py create mode 100644 copilot-sidecar-python/server.py diff --git a/.env.example b/.env.example index 9133314..90d3d1b 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,8 @@ KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_AUDIENCE="account" +# ============================================ +# Copilot Python Sidecar +# ============================================ +COPILOT_SIDECAR_URL="http://127.0.0.1:8787" +COPILOT_STREAM_TIMEOUT_SECONDS=120 diff --git a/app/api/v1/endpoints/copilot_chat.py b/app/api/v1/endpoints/copilot_chat.py new file mode 100644 index 0000000..860bd05 --- /dev/null +++ b/app/api/v1/endpoints/copilot_chat.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +from typing import AsyncGenerator, Optional + +import httpx +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.auth.dependencies import get_current_active_user +from app.core.config import settings +from app.domain.schemas.user import UserInDB + +router = APIRouter() + + +class CopilotChatStreamRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=10000) + conversation_id: Optional[str] = Field(default=None, max_length=128) + + +def _sse_event(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +@router.post( + "/chat/stream", + summary="Copilot 聊天流式响应", + description="向 Python Copilot sidecar 转发请求并通过 SSE 返回增量内容", +) +async def copilot_chat_stream( + payload: CopilotChatStreamRequest, + request: Request, + current_user: UserInDB = Depends(get_current_active_user), +): + timeout = httpx.Timeout( + connect=10.0, + read=float(settings.COPILOT_STREAM_TIMEOUT_SECONDS), + write=10.0, + pool=10.0, + ) + sidecar_url = settings.COPILOT_SIDECAR_URL.rstrip("/") + upstream_url = f"{sidecar_url}/chat/stream" + + async def event_generator() -> AsyncGenerator[str, None]: + headers: dict[str, str] = {} + auth_header = request.headers.get("authorization") + project_id = request.headers.get("x-project-id") + if auth_header: + headers["authorization"] = auth_header + if project_id: + headers["x-project-id"] = project_id + + body = { + "message": payload.message, + "conversationId": payload.conversation_id, + "userId": current_user.username, + } + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream( + "POST", + upstream_url, + json=body, + headers=headers, + ) as response: + if response.status_code >= 400: + detail_text = await response.aread() + detail = detail_text.decode("utf-8", errors="replace") + yield _sse_event( + "error", + { + "message": "Copilot sidecar request failed", + "status": response.status_code, + "detail": detail, + }, + ) + return + + async for line in response.aiter_lines(): + if await request.is_disconnected(): + return + yield f"{line}\n" + except httpx.ReadTimeout: + yield _sse_event( + "error", + { + "message": "Copilot stream timeout", + "status": status.HTTP_504_GATEWAY_TIMEOUT, + }, + ) + except httpx.ConnectError as exc: + yield _sse_event( + "error", + { + "message": "Copilot sidecar unavailable", + "status": status.HTTP_503_SERVICE_UNAVAILABLE, + "detail": str(exc), + }, + ) + except Exception as exc: + yield _sse_event( + "error", + { + "message": "Unexpected stream proxy error", + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "detail": str(exc), + }, + ) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 53a6125..c5c58ae 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -18,6 +18,7 @@ from app.api.v1.endpoints import ( user_management, # 新增:用户管理 audit, # 新增:审计日志 meta, + copilot_chat, ) from app.api.v1.endpoints.network import ( general, @@ -110,3 +111,6 @@ api_router.include_router(project_data.router, tags=["Project Data"]) # Extension api_router.include_router(extension.router, tags=["Extension"]) + +# Copilot Chat +api_router.include_router(copilot_chat.router, prefix="/copilot", tags=["Copilot"]) diff --git a/app/core/config.py b/app/core/config.py index 7404bd4..81b7496 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -62,6 +62,10 @@ class Settings(BaseSettings): KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_AUDIENCE: str = "" + # Copilot Sidecar + COPILOT_SIDECAR_URL: str = "http://127.0.0.1:8787" + COPILOT_STREAM_TIMEOUT_SECONDS: int = 120 + @property def SQLALCHEMY_DATABASE_URI(self) -> str: db_password = quote_plus(self.DB_PASSWORD) diff --git a/copilot-sidecar-python/server.py b/copilot-sidecar-python/server.py new file mode 100644 index 0000000..edf326e --- /dev/null +++ b/copilot-sidecar-python/server.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse +from copilot import CopilotClient, PermissionHandler + + +def _sse(event: str, data: dict[str, Any]) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +@dataclass +class SessionHolder: + session: Any + last_used_at: float + + +app = FastAPI(title="TJWater Copilot Python Sidecar") +client: Optional[CopilotClient] = None +sessions: dict[str, SessionHolder] = {} +session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800")) +model = os.getenv("COPILOT_MODEL", "gpt-5.1-codex") + + +@app.on_event("startup") +async def startup_event() -> None: + global client + client = CopilotClient() + await client.start() + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + if client is not None: + for holder in sessions.values(): + try: + await holder.session.disconnect() + except Exception: + pass + sessions.clear() + await client.stop() + + +async def _cleanup_sessions() -> None: + now = time.time() + expired = [ + sid + for sid, holder in sessions.items() + if now - holder.last_used_at > session_ttl_seconds + ] + for sid in expired: + holder = sessions.pop(sid, None) + if holder is None: + continue + try: + await holder.session.disconnect() + except Exception: + pass + + +async def _get_or_create_session(conversation_id: str): + await _cleanup_sessions() + if conversation_id in sessions: + sessions[conversation_id].last_used_at = time.time() + return sessions[conversation_id].session + + if client is None: + raise RuntimeError("Copilot client is not initialized") + + session = await client.create_session( + { + "model": model, + "streaming": True, + "on_permission_request": PermissionHandler.approve_all, + } + ) + sessions[conversation_id] = SessionHolder(session=session, last_used_at=time.time()) + return session + + +@app.get("/health") +async def health() -> dict[str, Any]: + return {"ok": True, "model": model, "sessions": len(sessions)} + + +@app.post("/chat/stream") +async def chat_stream(request: Request): + payload = await request.json() + message = payload.get("message") + conversation_id = payload.get("conversationId") + if not isinstance(message, str) or not message.strip(): + return JSONResponse(status_code=400, content={"message": "message is required"}) + + conv_id = ( + conversation_id.strip() + if isinstance(conversation_id, str) and conversation_id.strip() + else f"conv-{uuid.uuid4().hex[:10]}" + ) + + async def event_generator(): + session = None + queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue() + done = asyncio.Event() + error_emitted = False + + def on_event(event): + nonlocal error_emitted + event_type = getattr(event.type, "value", str(event.type)) + data = getattr(event, "data", None) + if event_type == "assistant.message_delta": + content = getattr(data, "delta_content", "") or "" + if content: + queue.put_nowait(("token", {"conversationId": conv_id, "content": content})) + elif event_type == "assistant.message": + content = getattr(data, "content", "") or "" + if content: + queue.put_nowait(("token", {"conversationId": conv_id, "content": content})) + elif event_type == "session.idle": + queue.put_nowait(("done", {"conversationId": conv_id})) + done.set() + elif event_type == "error": + error_emitted = True + queue.put_nowait( + ( + "error", + { + "conversationId": conv_id, + "message": "copilot session error", + "detail": str(data), + }, + ) + ) + done.set() + + try: + session = await _get_or_create_session(conv_id) + unsubscribe = session.on(on_event) + try: + await session.send({"prompt": message}) + while not done.is_set() or not queue.empty(): + if await request.is_disconnected(): + return + try: + event_name, event_data = await asyncio.wait_for(queue.get(), timeout=0.2) + yield _sse(event_name, event_data) + except asyncio.TimeoutError: + continue + if not error_emitted: + yield _sse("done", {"conversationId": conv_id}) + finally: + unsubscribe() + if conv_id in sessions: + sessions[conv_id].last_used_at = time.time() + except Exception as exc: + yield _sse( + "error", + { + "conversationId": conv_id, + "message": "copilot generation failed", + "detail": str(exc), + }, + ) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/requirements.txt b/requirements.txt index 259c4e2..e69742c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -167,4 +167,5 @@ zipp==3.23.0 zmq==0.0.0 pymoo==0.6.1.6 scikit-learn==1.6.1 -scipy==1.15.2 \ No newline at end of file +scipy==1.15.2 +github-copilot-sdk==0.2.0 \ No newline at end of file From c18461003537231b4f80ef285c94fecc88b9f836 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 24 Mar 2026 11:22:00 +0800 Subject: [PATCH 02/93] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Copilot=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=8F=8A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../endpoints/{copilot_chat.py => copilot.py} | 7 +- app/api/v1/endpoints/simulation.py | 34 ++--- app/api/v1/router.py | 4 +- app/infra/audit/middleware.py | 1 + .../server.py | 2 +- tests/api/test_copilot_chat_endpoint.py | 117 ++++++++++++++++++ 6 files changed, 141 insertions(+), 24 deletions(-) rename app/api/v1/endpoints/{copilot_chat.py => copilot.py} (94%) rename {copilot-sidecar-python => copilot-sidecar}/server.py (99%) create mode 100644 tests/api/test_copilot_chat_endpoint.py diff --git a/app/api/v1/endpoints/copilot_chat.py b/app/api/v1/endpoints/copilot.py similarity index 94% rename from app/api/v1/endpoints/copilot_chat.py rename to app/api/v1/endpoints/copilot.py index 860bd05..f8c5c75 100644 --- a/app/api/v1/endpoints/copilot_chat.py +++ b/app/api/v1/endpoints/copilot.py @@ -8,9 +8,8 @@ from fastapi import APIRouter, Depends, Request, status from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from app.auth.dependencies import get_current_active_user +from app.auth.keycloak_dependencies import get_current_keycloak_username from app.core.config import settings -from app.domain.schemas.user import UserInDB router = APIRouter() @@ -32,7 +31,7 @@ def _sse_event(event: str, data: dict) -> str: async def copilot_chat_stream( payload: CopilotChatStreamRequest, request: Request, - current_user: UserInDB = Depends(get_current_active_user), + username: str = Depends(get_current_keycloak_username), ): timeout = httpx.Timeout( connect=10.0, @@ -55,7 +54,7 @@ async def copilot_chat_stream( body = { "message": payload.message, "conversationId": payload.conversation_id, - "userId": current_user.username, + "userId": username, } try: diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 34b0e3a..ac3a679 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -312,23 +312,23 @@ async def valve_isolation_endpoint( - affected_nodes: 受影响的节点列表 - isolatable: 是否可以有效隔离 """ - result = { - "accident_element": "P461309", - "accident_elements": ["P461309"], - "affected_nodes": [ - "J316629_A", - "J317037_B", - "J317060_B", - "J408189_B", - "J499996", - "J524940", - "J535933", - "J58841", - ], - "isolatable": True, - "must_close_valves": ["210521658", "V12974", "V12986", "V12993"], - "optional_valves": [], - } + # result = { + # "accident_element": "P461309", + # "accident_elements": ["P461309"], + # "affected_nodes": [ + # "J316629_A", + # "J317037_B", + # "J317060_B", + # "J408189_B", + # "J499996", + # "J524940", + # "J535933", + # "J58841", + # ], + # "isolatable": True, + # "must_close_valves": ["210521658", "V12974", "V12986", "V12993"], + # "optional_valves": [], + # } result = analyze_valve_isolation(network, accident_element, disabled_valves) return result diff --git a/app/api/v1/router.py b/app/api/v1/router.py index c5c58ae..49b2783 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( auth, + copilot, project, simulation, scada, @@ -18,7 +19,6 @@ from app.api.v1.endpoints import ( user_management, # 新增:用户管理 audit, # 新增:审计日志 meta, - copilot_chat, ) from app.api.v1.endpoints.network import ( general, @@ -113,4 +113,4 @@ api_router.include_router(project_data.router, tags=["Project Data"]) api_router.include_router(extension.router, tags=["Extension"]) # Copilot Chat -api_router.include_router(copilot_chat.router, prefix="/copilot", tags=["Copilot"]) +api_router.include_router(copilot.router, prefix="/copilot", tags=["Copilot"]) diff --git a/app/infra/audit/middleware.py b/app/infra/audit/middleware.py index eb94fe8..657002f 100644 --- a/app/infra/audit/middleware.py +++ b/app/infra/audit/middleware.py @@ -60,6 +60,7 @@ class AuditMiddleware(BaseHTTPMiddleware): "/meta/projects", "/api/v1/openproject/", "/openproject/", + "/api/v1/copilot/chat/", } async def dispatch(self, request: Request, call_next: Callable) -> Response: diff --git a/copilot-sidecar-python/server.py b/copilot-sidecar/server.py similarity index 99% rename from copilot-sidecar-python/server.py rename to copilot-sidecar/server.py index edf326e..ed44fe6 100644 --- a/copilot-sidecar-python/server.py +++ b/copilot-sidecar/server.py @@ -27,7 +27,7 @@ app = FastAPI(title="TJWater Copilot Python Sidecar") client: Optional[CopilotClient] = None sessions: dict[str, SessionHolder] = {} session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800")) -model = os.getenv("COPILOT_MODEL", "gpt-5.1-codex") +model = os.getenv("COPILOT_MODEL", "gpt-5.3-codex") @app.on_event("startup") diff --git a/tests/api/test_copilot_chat_endpoint.py b/tests/api/test_copilot_chat_endpoint.py new file mode 100644 index 0000000..86535aa --- /dev/null +++ b/tests/api/test_copilot_chat_endpoint.py @@ -0,0 +1,117 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import copilot as copilot_endpoint + + +class _FakeStreamResponse: + def __init__(self, status_code: int, lines: list[str] | None = None, body: bytes = b""): + self.status_code = status_code + self._lines = lines or [] + self._body = body + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def aread(self) -> bytes: + return self._body + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class _FakeAsyncClient: + response: _FakeStreamResponse + captured: dict + + def __init__(self, *args, **kwargs): + self._kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def stream(self, method: str, url: str, json: dict, headers: dict): + _FakeAsyncClient.captured = { + "method": method, + "url": url, + "json": json, + "headers": headers, + "client_kwargs": self._kwargs, + } + return _FakeAsyncClient.response + + +def _build_client(monkeypatch) -> TestClient: + app = FastAPI() + app.include_router(copilot_endpoint.router, prefix="/api/v1/copilot") + app.dependency_overrides[copilot_endpoint.get_current_keycloak_username] = ( + lambda: "tester" + ) + monkeypatch.setattr(copilot_endpoint.httpx, "AsyncClient", _FakeAsyncClient) + return TestClient(app) + + +def test_chat_stream_forwards_auth_and_payload(monkeypatch): + _FakeAsyncClient.response = _FakeStreamResponse( + status_code=200, + lines=[ + 'event: token', + 'data: {"conversationId":"c1","content":"hello"}', + "", + 'event: done', + 'data: {"conversationId":"c1"}', + "", + ], + ) + client = _build_client(monkeypatch) + + response = client.post( + "/api/v1/copilot/chat/stream", + json={"message": "hi", "conversation_id": "conv-1"}, + headers={ + "Authorization": "Bearer keycloak-token", + "X-Project-Id": "project-a", + }, + ) + + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + assert "event: token" in response.text + assert "event: done" in response.text + + captured = _FakeAsyncClient.captured + assert captured["method"] == "POST" + assert captured["url"].endswith("/chat/stream") + assert captured["headers"]["authorization"] == "Bearer keycloak-token" + assert captured["headers"]["x-project-id"] == "project-a" + assert captured["json"] == { + "message": "hi", + "conversationId": "conv-1", + "userId": "tester", + } + + +def test_chat_stream_emits_error_event_when_upstream_fails(monkeypatch): + _FakeAsyncClient.response = _FakeStreamResponse( + status_code=401, + body=b"upstream unauthorized", + ) + client = _build_client(monkeypatch) + + response = client.post( + "/api/v1/copilot/chat/stream", + json={"message": "hi"}, + headers={"Authorization": "Bearer keycloak-token"}, + ) + + assert response.status_code == 200 + assert "event: error" in response.text + assert "Copilot sidecar request failed" in response.text + assert '"status": 401' in response.text From 600ddd329c7ab865bbe3c50e4c52b054de14d216 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 24 Mar 2026 16:01:22 +0800 Subject: [PATCH 03/93] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B5=81=E5=BC=8F=20Co?= =?UTF-8?q?pilot=20=E8=AF=B7=E6=B1=82=E5=A4=84=E7=90=86=E5=8F=8A=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E4=B8=AD=E9=97=B4=E4=BB=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/infra/audit/middleware.py | 34 +++++++++++++-- copilot-sidecar/server.py | 77 +++++++++++++++++++-------------- scripts/run_server.py | 80 +++++++++++++++++++++++++++++++---- 3 files changed, 147 insertions(+), 44 deletions(-) diff --git a/app/infra/audit/middleware.py b/app/infra/audit/middleware.py index 657002f..544b29b 100644 --- a/app/infra/audit/middleware.py +++ b/app/infra/audit/middleware.py @@ -61,12 +61,24 @@ class AuditMiddleware(BaseHTTPMiddleware): "/api/v1/openproject/", "/openproject/", "/api/v1/copilot/chat/", + "/api/v1/copilot/chat/stream", } + EXCLUDED_PATH_PREFIXES = ( + "/api/v1/copilot/chat/", + "/copilot/chat/", + ) async def dispatch(self, request: Request, call_next: Callable) -> Response: # 提取开始时间 start_time = time.time() + # 流式 Copilot 请求前置排除,避免读取/改写 body 影响 SSE 生命周期 + if self._is_excluded_path(request.url.path): + response = await call_next(request) + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + return response + # 1. 预判是否需要读取Body (针对写操作) # 注意:我们暂时移除早期的 return,因为需要等待路由匹配后才能检查 Tag should_capture_body = request.method in ["POST", "PUT", "PATCH"] @@ -75,13 +87,24 @@ class AuditMiddleware(BaseHTTPMiddleware): if should_capture_body: try: # 注意:读取 body 后需要重新设置,避免影响后续处理 + original_receive = request._receive body = await request.body() if body: request_data = json.loads(body.decode()) - # 重新构造请求以供后续使用 + # 重新构造请求以供后续使用:仅回放一次,后续回落原始 receive + body_sent = False + async def receive(): - return {"type": "http.request", "body": body} + nonlocal body_sent + if not body_sent: + body_sent = True + return { + "type": "http.request", + "body": body, + "more_body": False, + } + return await original_receive() request._receive = receive except Exception as e: @@ -91,7 +114,7 @@ class AuditMiddleware(BaseHTTPMiddleware): response = await call_next(request) # 3. 决定是否审计 - if request.url.path in self.EXCLUDED_PATHS: + if self._is_excluded_path(request.url.path): process_time = time.time() - start_time response.headers["X-Process-Time"] = str(process_time) return response @@ -151,6 +174,11 @@ class AuditMiddleware(BaseHTTPMiddleware): return response + def _is_excluded_path(self, path: str) -> bool: + if path in self.EXCLUDED_PATHS: + return True + return any(path.startswith(prefix) for prefix in self.EXCLUDED_PATH_PREFIXES) + def _resolve_project_id(self, request: Request) -> UUID | None: project_header = request.headers.get("X-Project-Id") if not project_header: diff --git a/copilot-sidecar/server.py b/copilot-sidecar/server.py index ed44fe6..1930c69 100644 --- a/copilot-sidecar/server.py +++ b/copilot-sidecar/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import json +import logging import os import time import uuid @@ -9,7 +10,8 @@ from dataclasses import dataclass from typing import Any, Optional from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field, ConfigDict from copilot import CopilotClient, PermissionHandler @@ -23,11 +25,21 @@ class SessionHolder: last_used_at: float -app = FastAPI(title="TJWater Copilot Python Sidecar") +app = FastAPI(title="TJWater Copilot Sidecar") client: Optional[CopilotClient] = None sessions: dict[str, SessionHolder] = {} session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800")) -model = os.getenv("COPILOT_MODEL", "gpt-5.3-codex") +model = os.getenv("COPILOT_MODEL", "gpt-4.1") +logger = logging.getLogger("copilot_sidecar") + + +class ChatStreamRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=10000) + conversation_id: Optional[str] = Field( + default=None, alias="conversationId", max_length=128 + ) + user_id: Optional[str] = Field(default=None, alias="userId", max_length=128) + model_config = ConfigDict(populate_by_name=True) @app.on_event("startup") @@ -43,8 +55,8 @@ async def shutdown_event() -> None: for holder in sessions.values(): try: await holder.session.disconnect() - except Exception: - pass + except Exception as exc: + logger.warning("Failed to disconnect session during shutdown: %s", exc) sessions.clear() await client.stop() @@ -62,8 +74,8 @@ async def _cleanup_sessions() -> None: continue try: await holder.session.disconnect() - except Exception: - pass + except Exception as exc: + logger.warning("Failed to disconnect expired session %s: %s", sid, exc) async def _get_or_create_session(conversation_id: str): @@ -76,11 +88,9 @@ async def _get_or_create_session(conversation_id: str): raise RuntimeError("Copilot client is not initialized") session = await client.create_session( - { - "model": model, - "streaming": True, - "on_permission_request": PermissionHandler.approve_all, - } + model=model, + streaming=True, + on_permission_request=PermissionHandler.approve_all, ) sessions[conversation_id] = SessionHolder(session=session, last_used_at=time.time()) return session @@ -92,42 +102,40 @@ async def health() -> dict[str, Any]: @app.post("/chat/stream") -async def chat_stream(request: Request): - payload = await request.json() - message = payload.get("message") - conversation_id = payload.get("conversationId") - if not isinstance(message, str) or not message.strip(): - return JSONResponse(status_code=400, content={"message": "message is required"}) - +async def chat_stream(payload: ChatStreamRequest, request: Request): conv_id = ( - conversation_id.strip() - if isinstance(conversation_id, str) and conversation_id.strip() + payload.conversation_id.strip() + if isinstance(payload.conversation_id, str) and payload.conversation_id.strip() else f"conv-{uuid.uuid4().hex[:10]}" ) + message = payload.message.strip() async def event_generator(): - session = None queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue() done = asyncio.Event() - error_emitted = False + saw_message_delta = False def on_event(event): - nonlocal error_emitted + nonlocal saw_message_delta event_type = getattr(event.type, "value", str(event.type)) data = getattr(event, "data", None) if event_type == "assistant.message_delta": content = getattr(data, "delta_content", "") or "" if content: - queue.put_nowait(("token", {"conversationId": conv_id, "content": content})) - elif event_type == "assistant.message": + saw_message_delta = True + queue.put_nowait( + ("token", {"conversationId": conv_id, "content": content}) + ) + elif event_type == "assistant.message" and not saw_message_delta: content = getattr(data, "content", "") or "" if content: - queue.put_nowait(("token", {"conversationId": conv_id, "content": content})) + queue.put_nowait( + ("token", {"conversationId": conv_id, "content": content}) + ) elif event_type == "session.idle": queue.put_nowait(("done", {"conversationId": conv_id})) done.set() elif event_type == "error": - error_emitted = True queue.put_nowait( ( "error", @@ -144,22 +152,27 @@ async def chat_stream(request: Request): session = await _get_or_create_session(conv_id) unsubscribe = session.on(on_event) try: - await session.send({"prompt": message}) + await session.send(message) while not done.is_set() or not queue.empty(): if await request.is_disconnected(): + logger.info( + "Client disconnected during stream: conversation=%s", + conv_id, + ) return try: - event_name, event_data = await asyncio.wait_for(queue.get(), timeout=0.2) + event_name, event_data = await asyncio.wait_for( + queue.get(), timeout=0.2 + ) yield _sse(event_name, event_data) except asyncio.TimeoutError: continue - if not error_emitted: - yield _sse("done", {"conversationId": conv_id}) finally: unsubscribe() if conv_id in sessions: sessions[conv_id].last_used_at = time.time() except Exception as exc: + logger.exception("Copilot generation failed for %s: %s", conv_id, exc) yield _sse( "error", { diff --git a/scripts/run_server.py b/scripts/run_server.py index 57f2c67..5b50447 100644 --- a/scripts/run_server.py +++ b/scripts/run_server.py @@ -1,21 +1,83 @@ import asyncio -import sys +import atexit import os +import signal +import subprocess +import sys +from urllib.parse import urlparse import uvicorn # 将项目根目录添加到 python 路径 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +_SIDECAR_PROCESS: subprocess.Popen | None = None + + +def _parse_sidecar_target() -> tuple[str, int]: + sidecar_url = os.getenv("COPILOT_SIDECAR_URL", "http://127.0.0.1:8787").strip() + parsed = urlparse(sidecar_url) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 8787 + return host, port + + +def _stop_sidecar() -> None: + global _SIDECAR_PROCESS + proc = _SIDECAR_PROCESS + if proc is None: + return + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=3) + _SIDECAR_PROCESS = None + + +def _start_sidecar_if_needed() -> None: + global _SIDECAR_PROCESS + sidecar_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "copilot-sidecar") + ) + + host, port = _parse_sidecar_target() + cmd = [ + sys.executable, + "-m", + "uvicorn", + "server:app", + "--host", + host, + "--port", + str(port), + "--log-level", + os.getenv("COPILOT_SIDECAR_LOG_LEVEL", "warning"), + ] + _SIDECAR_PROCESS = subprocess.Popen(cmd, cwd=sidecar_dir) + print(f"[run_server] sidecar started at {host}:{port}.") + + if __name__ == "__main__": # Windows 设置事件循环策略 if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - # 用 uvicorn.run 支持 workers 参数 - uvicorn.run( - "app.main:app", - host="0.0.0.0", - port=8000, - # workers=2, # 这里可以设置多进程 - loop="asyncio", - ) + atexit.register(_stop_sidecar) + signal.signal(signal.SIGTERM, lambda *_: _stop_sidecar()) + signal.signal(signal.SIGINT, lambda *_: _stop_sidecar()) + + _start_sidecar_if_needed() + try: + # 用 uvicorn.run 支持 workers 参数 + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + # workers=2, # 这里可以设置多进程 + loop="asyncio", + ) + finally: + _stop_sidecar() From 621cd9d2f92de2ea917ab19c1a22d5887ca45ae8 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 26 Mar 2026 16:09:17 +0800 Subject: [PATCH 04/93] =?UTF-8?q?=E5=88=A0=E9=99=A4=20router=20=E4=B8=AD?= =?UTF-8?q?=E5=A4=9A=E4=BD=99=E7=9A=84tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/timeseries/realtime.py | 26 ++--- app/api/v1/endpoints/timeseries/scada.py | 61 +++++----- app/api/v1/endpoints/timeseries/scheme.py | 123 +++++++++----------- copilot-sidecar/server.py | 2 +- 4 files changed, 100 insertions(+), 112 deletions(-) diff --git a/app/api/v1/endpoints/timeseries/realtime.py b/app/api/v1/endpoints/timeseries/realtime.py index d6fabf3..5725eb6 100644 --- a/app/api/v1/endpoints/timeseries/realtime.py +++ b/app/api/v1/endpoints/timeseries/realtime.py @@ -9,8 +9,7 @@ from .dependencies import get_timescale_connection router = APIRouter() -@router.post("/realtime/links/batch", status_code=201, summary="批量插入实时管道数据", - tags=["时间序列-实时数据"]) +@router.post("/realtime/links/batch", status_code=201, summary="批量插入实时管道数据") async def insert_realtime_links( data: List[dict] = Body(..., description="管道数据列表,每项包含管道ID、时间戳等信息"), conn: AsyncConnection = Depends(get_timescale_connection) @@ -30,7 +29,7 @@ async def insert_realtime_links( return {"message": f"Inserted {len(data)} records"} -@router.get("/realtime/links", summary="查询实时管道数据", tags=["时间序列-实时数据"]) +@router.get("/realtime/links", summary="查询实时管道数据") async def get_realtime_links( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -51,7 +50,7 @@ async def get_realtime_links( return await RealtimeRepository.get_links_by_time_range(conn, start_time, end_time) -@router.delete("/realtime/links", summary="删除实时管道数据", tags=["时间序列-实时数据"]) +@router.delete("/realtime/links", summary="删除实时管道数据") async def delete_realtime_links( start_time: datetime = Query(..., description="删除开始时间"), end_time: datetime = Query(..., description="删除结束时间"), @@ -73,8 +72,7 @@ async def delete_realtime_links( return {"message": "Deleted successfully"} -@router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段", - tags=["时间序列-实时数据"]) +@router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段") async def update_realtime_link_field( link_id: str = Path(..., description="管道ID"), time: datetime = Query(..., description="更新数据的时间戳"), @@ -106,8 +104,7 @@ async def update_realtime_link_field( raise HTTPException(status_code=400, detail=str(e)) -@router.post("/realtime/nodes/batch", status_code=201, summary="批量插入实时节点数据", - tags=["时间序列-实时数据"]) +@router.post("/realtime/nodes/batch", status_code=201, summary="批量插入实时节点数据") async def insert_realtime_nodes( data: List[dict] = Body(..., description="节点数据列表,每项包含节点ID、时间戳等信息"), conn: AsyncConnection = Depends(get_timescale_connection) @@ -127,7 +124,7 @@ async def insert_realtime_nodes( return {"message": f"Inserted {len(data)} records"} -@router.get("/realtime/nodes", summary="查询实时节点数据", tags=["时间序列-实时数据"]) +@router.get("/realtime/nodes", summary="查询实时节点数据") async def get_realtime_nodes( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -148,7 +145,7 @@ async def get_realtime_nodes( return await RealtimeRepository.get_nodes_by_time_range(conn, start_time, end_time) -@router.delete("/realtime/nodes", summary="删除实时节点数据", tags=["时间序列-实时数据"]) +@router.delete("/realtime/nodes", summary="删除实时节点数据") async def delete_realtime_nodes( start_time: datetime = Query(..., description="删除开始时间"), end_time: datetime = Query(..., description="删除结束时间"), @@ -172,8 +169,7 @@ async def delete_realtime_nodes( -@router.post("/realtime/simulation/store", status_code=201, summary="存储实时模拟结果", - tags=["时间序列-实时数据"]) +@router.post("/realtime/simulation/store", status_code=201, summary="存储实时模拟结果") async def store_realtime_simulation_result( node_result_list: List[dict] = Body(..., description="节点模拟结果列表"), link_result_list: List[dict] = Body(..., description="管道模拟结果列表"), @@ -199,8 +195,7 @@ async def store_realtime_simulation_result( return {"message": "Simulation results stored successfully"} -@router.get("/realtime/query/by-time-property", summary="按时间和属性查询实时数据", - tags=["时间序列-实时数据"]) +@router.get("/realtime/query/by-time-property", summary="按时间和属性查询实时数据") async def query_realtime_records_by_time_property( query_time: str = Query(..., description="查询时间"), type: str = Query(..., description="数据类型,pipe(管道)或 junction(节点)"), @@ -232,8 +227,7 @@ async def query_realtime_records_by_time_property( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/realtime/query/by-id-time", summary="按ID和时间查询实时模拟数据", - tags=["时间序列-实时数据"]) +@router.get("/realtime/query/by-id-time", summary="按ID和时间查询实时模拟数据") async def query_realtime_simulation_by_id_time( id: str = Query(..., description="元素ID(管道ID或节点ID)"), type: str = Query(..., description="元素类型,pipe(管道)或 junction(节点)"), diff --git a/app/api/v1/endpoints/timeseries/scada.py b/app/api/v1/endpoints/timeseries/scada.py index a42f87c..3ed1bab 100644 --- a/app/api/v1/endpoints/timeseries/scada.py +++ b/app/api/v1/endpoints/timeseries/scada.py @@ -9,20 +9,19 @@ from .dependencies import get_timescale_connection router = APIRouter() -@router.post("/scada/batch", status_code=201, summary="批量插入SCADA监测数据", - tags=["时间序列-监测数据"]) +@router.post("/scada/batch", status_code=201, summary="批量插入SCADA监测数据") async def insert_scada_data( data: List[dict] = Body(..., description="SCADA设备监测数据列表"), - conn: AsyncConnection = Depends(get_timescale_connection) + conn: AsyncConnection = Depends(get_timescale_connection), ): """ 批量插入SCADA监测数据 - + 将多个设备的实时监测数据批量插入时间序列数据库。 - + Args: data: SCADA设备监测数据列表,每项包含device_id、时间戳和监测值等信息 - + Returns: 插入成功的记录数 """ @@ -30,24 +29,25 @@ async def insert_scada_data( return {"message": f"Inserted {len(data)} records"} -@router.get("/scada/by-ids-time-range", summary="按设备ID和时间范围查询SCADA数据", - tags=["时间序列-监测数据"]) +@router.get("/scada/by-ids-time-range", summary="按设备ID和时间范围查询SCADA数据") async def get_scada_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), - device_ids: str = Query(..., description="设备ID列���,逗号分隔,如 'device1,device2,device3'"), + device_ids: str = Query( + ..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'" + ), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 按设备ID和时间范围查询SCADA监测数据 - + 查询多个设备在指定时间范围内的所有监测数据。 - + Args: start_time: 查询开始时间 end_time: 查询结束时间 device_ids: 设备ID列表,用逗号分隔 - + Returns: SCADA监测数据列表 """ @@ -59,29 +59,32 @@ async def get_scada_by_ids_time_range( ) -@router.get("/scada/by-ids-field-time-range", summary="按设备ID、字段和时间范围查询SCADA数据", - tags=["时间序列-监测数据"]) +@router.get( + "/scada/by-ids-field-time-range", summary="按设备ID、字段和时间范围查询SCADA数据" +) async def get_scada_field_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), field: str = Query(..., description="要查询的字段名称"), - device_ids: str = Query(..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'"), + device_ids: str = Query( + ..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'" + ), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 按设备ID、字段和时间范围查询特定SCADA数据 - + 查询多个设备在指定时间范围内的特定字段监测数据。 - + Args: start_time: 查询开始时间 end_time: 查询结束时间 field: 字段名称 device_ids: 设备ID列表,用逗号分隔 - + Returns: SCADA字段数据列表 - + Raises: HTTPException: 当字段不存在或查询参数无效时返回400错误 """ @@ -98,8 +101,7 @@ async def get_scada_field_by_ids_time_range( raise HTTPException(status_code=400, detail=str(e)) -@router.patch("/scada/{device_id}/field", summary="更新SCADA设备字段", - tags=["时间序列-监测数据"]) +@router.patch("/scada/{device_id}/field", summary="更新SCADA设备字段") async def update_scada_field( device_id: str = Path(..., description="设备ID"), time: datetime = Query(..., description="更新数据的时间戳"), @@ -109,18 +111,18 @@ async def update_scada_field( ): """ 更新指定设备的字段值 - + 更新SCADA设备在特定时间的某个字段监测数据。 - + Args: device_id: 设备ID time: 数据时间戳 field: 字段名称 value: 字段新值 - + Returns: 更新结果信息 - + Raises: HTTPException: 当字段不存在或更新失败时返回400错误 """ @@ -131,8 +133,7 @@ async def update_scada_field( raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/scada/by-id-time-range", summary="按设备ID和时间范围删除SCADA数据", - tags=["时间序列-监测数据"]) +@router.delete("/scada/by-id-time-range", summary="按设备ID和时间范围删除SCADA数据") async def delete_scada_data( device_id: str = Query(..., description="设备ID"), start_time: datetime = Query(..., description="删除开始时间"), @@ -141,14 +142,14 @@ async def delete_scada_data( ): """ 删除指定设备和时间范围内的SCADA数据 - + 删除在指定时间范围内的特定设备监测数据。 - + Args: device_id: 设备ID start_time: 删除开始时间 end_time: 删除结束时间 - + Returns: 删除结果信息 """ diff --git a/app/api/v1/endpoints/timeseries/scheme.py b/app/api/v1/endpoints/timeseries/scheme.py index 7e342c0..76f71e6 100644 --- a/app/api/v1/endpoints/timeseries/scheme.py +++ b/app/api/v1/endpoints/timeseries/scheme.py @@ -9,20 +9,19 @@ from .dependencies import get_timescale_connection router = APIRouter() -@router.post("/scheme/links/batch", status_code=201, summary="批量插入方案管道数据", - tags=["时间序列-方案数据"]) +@router.post("/scheme/links/batch", status_code=201, summary="批量插入方案管道数据") async def insert_scheme_links( data: List[dict] = Body(..., description="方案管道数据列表"), - conn: AsyncConnection = Depends(get_timescale_connection) + conn: AsyncConnection = Depends(get_timescale_connection), ): """ 批量插入方案管道数据 - + 将特定方案的管道模拟数据批量插入时间序列数据库。 - + Args: data: 方案管道数据列表 - + Returns: 插入成功的记录数 """ @@ -30,7 +29,7 @@ async def insert_scheme_links( return {"message": f"Inserted {len(data)} records"} -@router.get("/scheme/links", summary="查询方案管道数据", tags=["时间序列-方案数据"]) +@router.get("/scheme/links", summary="查询方案管道数据") async def get_scheme_links( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -40,15 +39,15 @@ async def get_scheme_links( ): """ 查询指定方案和时间范围内的管道数据 - + 根据方案和时间范围查询管道的模拟值。 - + Args: scheme_type: 方案类型 scheme_name: 方案名称 start_time: 查询开始时间 end_time: 查询结束时间 - + Returns: 方案管道数据列表 """ @@ -57,8 +56,7 @@ async def get_scheme_links( ) -@router.get("/scheme/links/{link_id}/field", summary="查询方案管道字段数据", - tags=["时间序列-方案数据"]) +@router.get("/scheme/links/{link_id}/field", summary="查询方案管道字段数据") async def get_scheme_link_field( link_id: str = Path(..., description="管道ID"), scheme_type: str = Query(..., description="方案类型"), @@ -70,9 +68,9 @@ async def get_scheme_link_field( ): """ 查询指定方案管道的特定字段数据 - + 查询特定方案中指定管道在时间范围内的特定字段值。 - + Args: link_id: 管道ID scheme_type: 方案类型 @@ -80,10 +78,10 @@ async def get_scheme_link_field( start_time: 查询开始时间 end_time: 查询结束时间 field: 字段名称 - + Returns: 字段数据列表 - + Raises: HTTPException: 当查询参数无效时返回400错误 """ @@ -95,8 +93,7 @@ async def get_scheme_link_field( raise HTTPException(status_code=400, detail=str(e)) -@router.patch("/scheme/links/{link_id}/field", summary="更新方案管道字段", - tags=["时间序列-方案数据"]) +@router.patch("/scheme/links/{link_id}/field", summary="更新方案管道字段") async def update_scheme_link_field( link_id: str = Path(..., description="管道ID"), scheme_type: str = Query(..., description="方案类型"), @@ -108,9 +105,9 @@ async def update_scheme_link_field( ): """ 更新指定方案管道的字段值 - + 更新特定方案中指定管道在某个时间的字段数据。 - + Args: link_id: 管道ID scheme_type: 方案类型 @@ -118,10 +115,10 @@ async def update_scheme_link_field( time: 数据时间戳 field: 字段名称 value: 字段新值 - + Returns: 更新结果信息 - + Raises: HTTPException: 当字段不存在或更新失败时返回400错误 """ @@ -134,7 +131,7 @@ async def update_scheme_link_field( raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/scheme/links", summary="删除方案管道数据", tags=["时间序列-方案数据"]) +@router.delete("/scheme/links", summary="删除方案管道数据") async def delete_scheme_links( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -144,15 +141,15 @@ async def delete_scheme_links( ): """ 删除指定方案和时间范围内的管道数据 - + 删除在指定方案和时间范围内的所有管道模拟数据。 - + Args: scheme_type: 方案类型 scheme_name: 方案名称 start_time: 删除开始时间 end_time: 删除结束时间 - + Returns: 删除结果信息 """ @@ -162,20 +159,19 @@ async def delete_scheme_links( return {"message": "Deleted successfully"} -@router.post("/scheme/nodes/batch", status_code=201, summary="批量插入方案节点数据", - tags=["时间序列-方案数据"]) +@router.post("/scheme/nodes/batch", status_code=201, summary="批量插入方案节点数据") async def insert_scheme_nodes( data: List[dict] = Body(..., description="方案节点数据列表"), - conn: AsyncConnection = Depends(get_timescale_connection) + conn: AsyncConnection = Depends(get_timescale_connection), ): """ 批量插入方案节点数据 - + 将特定方案的节点模拟数据批量插入时间序列数据库。 - + Args: data: 方案节点数据列表 - + Returns: 插入成功的记录数 """ @@ -183,8 +179,7 @@ async def insert_scheme_nodes( return {"message": f"Inserted {len(data)} records"} -@router.get("/scheme/nodes/{node_id}/field", summary="查询方案节点字段数据", - tags=["时间序列-方案数据"]) +@router.get("/scheme/nodes/{node_id}/field", summary="查询方案节点字段数据") async def get_scheme_node_field( node_id: str = Path(..., description="节点ID"), scheme_type: str = Query(..., description="方案类型"), @@ -196,9 +191,9 @@ async def get_scheme_node_field( ): """ 查询指定方案节点的特定字段数据 - + 查询特定方案中指定节点在时间范围内的特定字段值。 - + Args: node_id: 节点ID scheme_type: 方案类型 @@ -206,10 +201,10 @@ async def get_scheme_node_field( start_time: 查询开始时间 end_time: 查询结束时间 field: 字段名称 - + Returns: 字段数据列表 - + Raises: HTTPException: 当查询参数无效时返回400错误 """ @@ -221,8 +216,7 @@ async def get_scheme_node_field( raise HTTPException(status_code=400, detail=str(e)) -@router.patch("/scheme/nodes/{node_id}/field", summary="更新方案节点字段", - tags=["时间序列-方案数据"]) +@router.patch("/scheme/nodes/{node_id}/field", summary="更新方案节点字段") async def update_scheme_node_field( node_id: str = Path(..., description="节点ID"), scheme_type: str = Query(..., description="方案类型"), @@ -234,9 +228,9 @@ async def update_scheme_node_field( ): """ 更新指定方案节点的字段值 - + 更新特定方案中指定节点在某个时间的字段数据。 - + Args: node_id: 节点ID scheme_type: 方案类型 @@ -244,10 +238,10 @@ async def update_scheme_node_field( time: 数据时间戳 field: 字段名称 value: 字段新值 - + Returns: 更新结果信息 - + Raises: HTTPException: 当字段不存在或更新失败时返回400错误 """ @@ -260,7 +254,7 @@ async def update_scheme_node_field( raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/scheme/nodes", summary="删除方案节点数据", tags=["时间序列-方案数据"]) +@router.delete("/scheme/nodes", summary="删除方案节点数据") async def delete_scheme_nodes( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -270,15 +264,15 @@ async def delete_scheme_nodes( ): """ 删除指定方案和时间范围内的节点数据 - + 删除在指定方案和时间范围内的所有节点模拟数据。 - + Args: scheme_type: 方案类型 scheme_name: 方案名称 start_time: 删除开始时间 end_time: 删除结束时间 - + Returns: 删除结果信息 """ @@ -288,8 +282,7 @@ async def delete_scheme_nodes( return {"message": "Deleted successfully"} -@router.post("/scheme/simulation/store", status_code=201, summary="存储方案模拟结果", - tags=["时间序列-方案数据"]) +@router.post("/scheme/simulation/store", status_code=201, summary="存储方案模拟结果") async def store_scheme_simulation_result( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -300,16 +293,16 @@ async def store_scheme_simulation_result( ): """ 存储方案模拟结果到时间序列数据库 - + 将特定方案的节点和管道模拟计算结果批量存储到TimescaleDB数据库。 - + Args: scheme_type: 方案类型 scheme_name: 方案名称 node_result_list: 节点模拟结果列表 link_result_list: 管道模拟结果列表 result_start_time: 模拟结果对应的起始时间 - + Returns: 存储结果信息 """ @@ -324,8 +317,9 @@ async def store_scheme_simulation_result( return {"message": "Scheme simulation results stored successfully"} -@router.get("/scheme/query/by-scheme-time-property", summary="按方案、时间和属性查询数据", - tags=["时间序列-方案数据"]) +@router.get( + "/scheme/query/by-scheme-time-property", summary="按方案、时间和属性查询数据" +) async def query_scheme_records_by_scheme_time_property( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -336,19 +330,19 @@ async def query_scheme_records_by_scheme_time_property( ): """ 按指定方案、时间和属性查询所有方案数据 - + 查询在特定方案和时间点,所有指定类型元素的特定属性值。 - + Args: scheme_type: 方案类型 scheme_name: 方案名称 query_time: 查询时间 type: 元素类型(pipe或junction) property: 属性名称 - + Returns: 查询结果列表 - + Raises: HTTPException: 当查询参数无效时返回400错误 """ @@ -361,8 +355,7 @@ async def query_scheme_records_by_scheme_time_property( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/scheme/query/by-id-time", summary="按ID和时间查询方案模拟数据", - tags=["时间序列-方案数据"]) +@router.get("/scheme/query/by-id-time", summary="按ID和时间查询方案模拟数据") async def query_scheme_simulation_by_id_time( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -373,19 +366,19 @@ async def query_scheme_simulation_by_id_time( ): """ 按指定ID和时间查询方案模拟结果 - + 查询特定方案中的元素在某一时间点的模拟数据。 - + Args: scheme_type: 方案类型 scheme_name: 方案名称 id: 元素ID type: 元素类型(pipe或junction) query_time: 查询时间 - + Returns: 模拟结果数据 - + Raises: HTTPException: 当查询参数无效时返回400错误 """ diff --git a/copilot-sidecar/server.py b/copilot-sidecar/server.py index 1930c69..a00fdf8 100644 --- a/copilot-sidecar/server.py +++ b/copilot-sidecar/server.py @@ -29,7 +29,7 @@ app = FastAPI(title="TJWater Copilot Sidecar") client: Optional[CopilotClient] = None sessions: dict[str, SessionHolder] = {} session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800")) -model = os.getenv("COPILOT_MODEL", "gpt-4.1") +model = os.getenv("COPILOT_MODEL", "gpt-5.4") logger = logging.getLogger("copilot_sidecar") From 88eec2787b74e6d14419b55d6993e5f2f398861d Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 27 Mar 2026 12:31:52 +0800 Subject: [PATCH 05/93] =?UTF-8?q?=E6=95=B4=E7=90=86=20api=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/timeseries/composite.py | 15 +++++---------- app/api/v1/router.py | 8 +++++--- app/main.py | 2 +- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/app/api/v1/endpoints/timeseries/composite.py b/app/api/v1/endpoints/timeseries/composite.py index c0cbf41..b863097 100644 --- a/app/api/v1/endpoints/timeseries/composite.py +++ b/app/api/v1/endpoints/timeseries/composite.py @@ -8,8 +8,7 @@ from .dependencies import get_timescale_connection, get_postgres_connection router = APIRouter() -@router.get("/composite/scada-simulation", summary="获取SCADA关联的模拟数据", - tags=["复合查询"]) +@router.get("/composite/scada-simulation", summary="获取SCADA关联的模拟数据") async def get_scada_associated_simulation_data( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -74,8 +73,7 @@ async def get_scada_associated_simulation_data( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/composite/element-simulation", summary="获取管网元素的模拟数据", - tags=["复合查询"]) +@router.get("/composite/element-simulation", summary="获取管网元素的模拟数据") async def get_feature_simulation_data( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -145,8 +143,7 @@ async def get_feature_simulation_data( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/composite/element-scada", summary="获取管网元素关联的SCADA监测数据", - tags=["复合查询"]) +@router.get("/composite/element-scada", summary="获取管网元素关联的SCADA监测数据") async def get_element_associated_scada_data( element_id: str = Query(..., description="管网元素ID(管道或节点)"), start_time: datetime = Query(..., description="查询开始时间"), @@ -188,8 +185,7 @@ async def get_element_associated_scada_data( raise HTTPException(status_code=400, detail=str(e)) -@router.post("/composite/clean-scada", summary="清洗SCADA监测数据", - tags=["复合查询"]) +@router.post("/composite/clean-scada", summary="清洗SCADA监测数据") async def clean_scada_data( device_ids: str = Query(..., description="设备ID列表或 'all' 表示清洗所有设备"), start_time: datetime = Query(..., description="清洗数据的开始时间"), @@ -232,8 +228,7 @@ async def clean_scada_data( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/composite/pipeline-health-prediction", summary="预测管道健康状况", - tags=["复合查询"]) +@router.get("/composite/pipeline-health-prediction", summary="预测管道健康状况") async def predict_pipeline_health( query_time: datetime = Query(..., description="查询时间"), network_name: str = Query(..., description="管网名称(或数据库名称)"), diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 49b2783..ebbefcb 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -54,7 +54,9 @@ api_router = APIRouter() # Core Services api_router.include_router(auth.router, prefix="/auth", tags=["Auth"]) -api_router.include_router(user_management.router, prefix="/users", tags=["User Management"]) # 新增 +api_router.include_router( + user_management.router, prefix="/users", tags=["User Management"] +) # 新增 api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增 api_router.include_router(meta.router, tags=["Metadata"]) api_router.include_router(project.router, tags=["Project"]) @@ -84,8 +86,8 @@ api_router.include_router(visuals.router, tags=["Visuals"]) # Simulation & Data api_router.include_router(simulation.router, tags=["Simulation Control"]) -api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"]) -api_router.include_router(scada.router, tags=["SCADA"]) +# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"]) +api_router.include_router(scada.router) api_router.include_router(snapshots.router, tags=["Snapshots"]) api_router.include_router(users.router, tags=["Users"]) api_router.include_router(schemes.router, tags=["Schemes"]) diff --git a/app/main.py b/app/main.py index fe00e06..d82cb87 100644 --- a/app/main.py +++ b/app/main.py @@ -70,7 +70,7 @@ app = FastAPI( # Include Routers app.include_router(api_router, prefix="/api/v1") # Legcy Routers without version prefix -app.include_router(api_router) +# app.include_router(api_router) # 配置中间件 app.add_middleware(GZipMiddleware, minimum_size=1000) From 0196206ed3007e0b7edfe6353bf1afacea3f591a Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 27 Mar 2026 13:05:22 +0800 Subject: [PATCH 06/93] =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E5=8C=96=E7=9B=AE=E5=BD=95=E7=9A=84=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/skills/SKILL.md | 36 + .github/skills/ai/copilot-assistant/SKILL.md | 30 + .../ai/copilot-assistant/copilot/SKILL.md | 26 + .../analytics/scada-operations/SKILL.md | 60 ++ .../analytics/scada-operations/scada/SKILL.md | 56 ++ .../analytics/simulation-analysis/SKILL.md | 89 +++ .../burst_detection/SKILL.md | 28 + .../burst_location/SKILL.md | 28 + .../simulation-analysis/leakage/SKILL.md | 28 + .../simulation-analysis/risk/SKILL.md | 30 + .../simulation-analysis/simulation/SKILL.md | 55 ++ .github/skills/api-spec.md | 671 ++++++++++++++++++ .../skills/business/component-config/SKILL.md | 121 ++++ .../component-config/controls/SKILL.md | 31 + .../business/component-config/curves/SKILL.md | 32 + .../component-config/options/SKILL.md | 37 + .../component-config/patterns/SKILL.md | 32 + .../component-config/quality/SKILL.md | 50 ++ .../component-config/visuals/SKILL.md | 40 ++ .../skills/business/identity-access/SKILL.md | 51 ++ .../business/identity-access/auth/SKILL.md | 30 + .../identity-access/user_management/SKILL.md | 31 + .../business/identity-access/users/SKILL.md | 28 + .../skills/business/network-assets/SKILL.md | 260 +++++++ .../business/network-assets/demands/SKILL.md | 31 + .../business/network-assets/general/SKILL.md | 54 ++ .../business/network-assets/geometry/SKILL.md | 31 + .../network-assets/junctions/SKILL.md | 43 ++ .../business/network-assets/pipes/SKILL.md | 45 ++ .../business/network-assets/pumps/SKILL.md | 35 + .../business/network-assets/regions/SKILL.md | 62 ++ .../network-assets/reservoirs/SKILL.md | 41 ++ .../business/network-assets/tags/SKILL.md | 29 + .../business/network-assets/tanks/SKILL.md | 53 ++ .../business/network-assets/valves/SKILL.md | 42 ++ .../business/project-workspace/SKILL.md | 113 +++ .../project-workspace/extension/SKILL.md | 29 + .../business/project-workspace/misc/SKILL.md | 31 + .../project-workspace/project/SKILL.md | 54 ++ .../project-workspace/project_data/SKILL.md | 29 + .../project-workspace/schemes/SKILL.md | 28 + .../project-workspace/snapshots/SKILL.md | 43 ++ .github/skills/examples.md | 21 + .../governance-observability/SKILL.md | 47 ++ .../governance-observability/audit/SKILL.md | 28 + .../governance-observability/cache/SKILL.md | 29 + .../governance-observability/meta/SKILL.md | 28 + .github/skills/runbook.md | 20 + .github/skills/scripts/call-api.sh | 15 + app/api/v1/endpoints/data_query.py | 388 ---------- 50 files changed, 2861 insertions(+), 388 deletions(-) create mode 100644 .github/skills/SKILL.md create mode 100644 .github/skills/ai/copilot-assistant/SKILL.md create mode 100644 .github/skills/ai/copilot-assistant/copilot/SKILL.md create mode 100644 .github/skills/analytics/scada-operations/SKILL.md create mode 100644 .github/skills/analytics/scada-operations/scada/SKILL.md create mode 100644 .github/skills/analytics/simulation-analysis/SKILL.md create mode 100644 .github/skills/analytics/simulation-analysis/burst_detection/SKILL.md create mode 100644 .github/skills/analytics/simulation-analysis/burst_location/SKILL.md create mode 100644 .github/skills/analytics/simulation-analysis/leakage/SKILL.md create mode 100644 .github/skills/analytics/simulation-analysis/risk/SKILL.md create mode 100644 .github/skills/analytics/simulation-analysis/simulation/SKILL.md create mode 100644 .github/skills/api-spec.md create mode 100644 .github/skills/business/component-config/SKILL.md create mode 100644 .github/skills/business/component-config/controls/SKILL.md create mode 100644 .github/skills/business/component-config/curves/SKILL.md create mode 100644 .github/skills/business/component-config/options/SKILL.md create mode 100644 .github/skills/business/component-config/patterns/SKILL.md create mode 100644 .github/skills/business/component-config/quality/SKILL.md create mode 100644 .github/skills/business/component-config/visuals/SKILL.md create mode 100644 .github/skills/business/identity-access/SKILL.md create mode 100644 .github/skills/business/identity-access/auth/SKILL.md create mode 100644 .github/skills/business/identity-access/user_management/SKILL.md create mode 100644 .github/skills/business/identity-access/users/SKILL.md create mode 100644 .github/skills/business/network-assets/SKILL.md create mode 100644 .github/skills/business/network-assets/demands/SKILL.md create mode 100644 .github/skills/business/network-assets/general/SKILL.md create mode 100644 .github/skills/business/network-assets/geometry/SKILL.md create mode 100644 .github/skills/business/network-assets/junctions/SKILL.md create mode 100644 .github/skills/business/network-assets/pipes/SKILL.md create mode 100644 .github/skills/business/network-assets/pumps/SKILL.md create mode 100644 .github/skills/business/network-assets/regions/SKILL.md create mode 100644 .github/skills/business/network-assets/reservoirs/SKILL.md create mode 100644 .github/skills/business/network-assets/tags/SKILL.md create mode 100644 .github/skills/business/network-assets/tanks/SKILL.md create mode 100644 .github/skills/business/network-assets/valves/SKILL.md create mode 100644 .github/skills/business/project-workspace/SKILL.md create mode 100644 .github/skills/business/project-workspace/extension/SKILL.md create mode 100644 .github/skills/business/project-workspace/misc/SKILL.md create mode 100644 .github/skills/business/project-workspace/project/SKILL.md create mode 100644 .github/skills/business/project-workspace/project_data/SKILL.md create mode 100644 .github/skills/business/project-workspace/schemes/SKILL.md create mode 100644 .github/skills/business/project-workspace/snapshots/SKILL.md create mode 100644 .github/skills/examples.md create mode 100644 .github/skills/platform/governance-observability/SKILL.md create mode 100644 .github/skills/platform/governance-observability/audit/SKILL.md create mode 100644 .github/skills/platform/governance-observability/cache/SKILL.md create mode 100644 .github/skills/platform/governance-observability/meta/SKILL.md create mode 100644 .github/skills/runbook.md create mode 100755 .github/skills/scripts/call-api.sh delete mode 100644 app/api/v1/endpoints/data_query.py diff --git a/.github/skills/SKILL.md b/.github/skills/SKILL.md new file mode 100644 index 0000000..4b7e5e5 --- /dev/null +++ b/.github/skills/SKILL.md @@ -0,0 +1,36 @@ +--- +name: api-operations-overview +description: 按“领域 -> 场景 -> 操作”组织 TJWater API Skills,快速定位可调用接口。 +version: 2.0.0 +--- + +# 何时使用 + +当你需要按业务语义快速定位 API,或希望在联调时按分层目录检索接口。 + +# 分层结构(<=3 层) + +- 领域(Domain) +- 场景(Scenario) +- 操作(Action) + +# 目录导航 + +- `business/identity-access` +- `business/project-workspace` +- `business/network-assets` +- `business/component-config` +- `analytics/simulation-analysis` +- `analytics/scada-operations` +- `data/timeseries-access` +- `platform/governance-observability` +- `ai/copilot-assistant` + +完整操作清单:`api-spec.md` + +# See Also + +- 关联示例: `examples.md` +- 关联运行手册: `runbook.md` + +Action Skills 总数:`39` diff --git a/.github/skills/ai/copilot-assistant/SKILL.md b/.github/skills/ai/copilot-assistant/SKILL.md new file mode 100644 index 0000000..820bf95 --- /dev/null +++ b/.github/skills/ai/copilot-assistant/SKILL.md @@ -0,0 +1,30 @@ +--- +name: api-operations-ai-copilot-assistant +description: Copilot 助手接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **ai/copilot-assistant** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`POST` + +# Action Skills + +- `copilot`: `copilot/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `copilot` +- 详情技能:`copilot/SKILL.md` +- `POST /api/v1/copilot/chat/stream` - Copilot 聊天流式响应 + +# See Also + +- 关联项目空间: `../../business/project-workspace` +- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/ai/copilot-assistant/copilot/SKILL.md b/.github/skills/ai/copilot-assistant/copilot/SKILL.md new file mode 100644 index 0000000..a3c54eb --- /dev/null +++ b/.github/skills/ai/copilot-assistant/copilot/SKILL.md @@ -0,0 +1,26 @@ +--- +name: api-operations-ai-copilot-assistant-copilot +description: ai/copilot-assistant 场景下 copilot 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **copilot** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`POST` + +# 操作列表 + +- `POST /api/v1/copilot/chat/stream` - Copilot 聊天流式响应 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联项目空间: `../../business/project-workspace` +- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/analytics/scada-operations/SKILL.md b/.github/skills/analytics/scada-operations/SKILL.md new file mode 100644 index 0000000..35a5b84 --- /dev/null +++ b/.github/skills/analytics/scada-operations/SKILL.md @@ -0,0 +1,60 @@ +--- +name: api-operations-analytics-scada-operations +description: SCADA 数据读写与历史查询接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **analytics/scada-operations** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`DELETE`, `GET`, `PATCH`, `POST` + +# Action Skills + +- `scada`: `scada/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `scada` +- 详情技能:`scada/SKILL.md` +- `POST /api/v1/addscadadevice/` - 添加SCADA设备 +- `POST /api/v1/addscadadevicedata/` - 添加SCADA设备数据 +- `POST /api/v1/addscadaelement/` - 添加SCADA元素映射 +- `POST /api/v1/cleanscadadevice/` - 清空SCADA设备表 +- `POST /api/v1/cleanscadadevicedata/` - 清空SCADA设备数据表 +- `POST /api/v1/cleanscadaelement/` - 清空SCADA元素映射表 +- `POST /api/v1/deletescadadevice/` - 删除SCADA设备 +- `POST /api/v1/deletescadadevicedata/` - 删除SCADA设备数据 +- `POST /api/v1/deletescadaelement/` - 删除SCADA元素映射 +- `GET /api/v1/getallscadadeviceids/` - 获取所有SCADA设备ID +- `GET /api/v1/getallscadadevices/` - 获取所有SCADA设备 +- `GET /api/v1/getallscadainfo/` - 获取所有SCADA信息 +- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA属性 +- `GET /api/v1/getscadadevice/` - 获取SCADA设备 +- `GET /api/v1/getscadadevicedata/` - 获取SCADA设备数据 +- `GET /api/v1/getscadadevicedataschema/` - 获取SCADA设备数据架构 +- `GET /api/v1/getscadadeviceschema/` - 获取SCADA设备架构 +- `GET /api/v1/getscadaelement/` - 获取单个SCADA元素映射 +- `GET /api/v1/getscadaelements/` - 获取所有SCADA元素映射 +- `GET /api/v1/getscadaelementschema/` - 获取SCADA元素架构 +- `GET /api/v1/getscadainfo/` - 获取SCADA信息 +- `GET /api/v1/getscadainfoschema/` - 获取SCADA信息架构 +- `GET /api/v1/getscadaproperties/` - 获取SCADA属性 +- `POST /api/v1/scada/batch` - 批量插入SCADA监测数据 +- `DELETE /api/v1/scada/by-id-time-range` - 按设备ID和时间范围删除SCADA数据 +- `GET /api/v1/scada/by-ids-field-time-range` - 按设备ID、字段和时间范围查询SCADA数据 +- `GET /api/v1/scada/by-ids-time-range` - 按设备ID和时间范围查询SCADA数据 +- `PATCH /api/v1/scada/{device_id}/field` - 更新SCADA设备字段 +- `POST /api/v1/setscadadevice/` - 更新SCADA设备 +- `POST /api/v1/setscadadevicedata/` - 更新SCADA设备数据 +- `POST /api/v1/setscadaelement/` - 更新SCADA元素映射 + +# See Also + +- 关联时序数据: `../../data/timeseries-access` +- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/analytics/scada-operations/scada/SKILL.md b/.github/skills/analytics/scada-operations/scada/SKILL.md new file mode 100644 index 0000000..3236ec5 --- /dev/null +++ b/.github/skills/analytics/scada-operations/scada/SKILL.md @@ -0,0 +1,56 @@ +--- +name: api-operations-analytics-scada-operations-scada +description: analytics/scada-operations 场景下 scada 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **scada** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`DELETE`, `GET`, `PATCH`, `POST` + +# 操作列表 + +- `POST /api/v1/addscadadevice/` - 添加SCADA设备 +- `POST /api/v1/addscadadevicedata/` - 添加SCADA设备数据 +- `POST /api/v1/addscadaelement/` - 添加SCADA元素映射 +- `POST /api/v1/cleanscadadevice/` - 清空SCADA设备表 +- `POST /api/v1/cleanscadadevicedata/` - 清空SCADA设备数据表 +- `POST /api/v1/cleanscadaelement/` - 清空SCADA元素映射表 +- `POST /api/v1/deletescadadevice/` - 删除SCADA设备 +- `POST /api/v1/deletescadadevicedata/` - 删除SCADA设备数据 +- `POST /api/v1/deletescadaelement/` - 删除SCADA元素映射 +- `GET /api/v1/getallscadadeviceids/` - 获取所有SCADA设备ID +- `GET /api/v1/getallscadadevices/` - 获取所有SCADA设备 +- `GET /api/v1/getallscadainfo/` - 获取所有SCADA信息 +- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA属性 +- `GET /api/v1/getscadadevice/` - 获取SCADA设备 +- `GET /api/v1/getscadadevicedata/` - 获取SCADA设备数据 +- `GET /api/v1/getscadadevicedataschema/` - 获取SCADA设备数据架构 +- `GET /api/v1/getscadadeviceschema/` - 获取SCADA设备架构 +- `GET /api/v1/getscadaelement/` - 获取单个SCADA元素映射 +- `GET /api/v1/getscadaelements/` - 获取所有SCADA元素映射 +- `GET /api/v1/getscadaelementschema/` - 获取SCADA元素架构 +- `GET /api/v1/getscadainfo/` - 获取SCADA信息 +- `GET /api/v1/getscadainfoschema/` - 获取SCADA信息架构 +- `GET /api/v1/getscadaproperties/` - 获取SCADA属性 +- `POST /api/v1/scada/batch` - 批量插入SCADA监测数据 +- `DELETE /api/v1/scada/by-id-time-range` - 按设备ID和时间范围删除SCADA数据 +- `GET /api/v1/scada/by-ids-field-time-range` - 按设备ID、字段和时间范围查询SCADA数据 +- `GET /api/v1/scada/by-ids-time-range` - 按设备ID和时间范围查询SCADA数据 +- `PATCH /api/v1/scada/{device_id}/field` - 更新SCADA设备字段 +- `POST /api/v1/setscadadevice/` - 更新SCADA设备 +- `POST /api/v1/setscadadevicedata/` - 更新SCADA设备数据 +- `POST /api/v1/setscadaelement/` - 更新SCADA元素映射 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联时序数据: `../../data/timeseries-access` +- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/analytics/simulation-analysis/SKILL.md b/.github/skills/analytics/simulation-analysis/SKILL.md new file mode 100644 index 0000000..e00455e --- /dev/null +++ b/.github/skills/analytics/simulation-analysis/SKILL.md @@ -0,0 +1,89 @@ +--- +name: api-operations-analytics-simulation-analysis +description: 仿真、风险、漏损与爆管分析接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **analytics/simulation-analysis** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# Action Skills + +- `burst_detection`: `burst_detection/SKILL.md` +- `burst_location`: `burst_location/SKILL.md` +- `leakage`: `leakage/SKILL.md` +- `risk`: `risk/SKILL.md` +- `simulation`: `simulation/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `burst_detection` +- 详情技能:`burst_detection/SKILL.md` +- `POST /api/v1/burst-detection/detect/` - 执行爆管检测 +- `GET /api/v1/burst-detection/schemes/` - 查询爆管检测方案列表 +- `GET /api/v1/burst-detection/schemes/{scheme_name}` - 获取爆管检测方案详情 + +## Action: `burst_location` +- 详情技能:`burst_location/SKILL.md` +- `POST /api/v1/burst-location/locate/` - 执行爆管定位 +- `GET /api/v1/burst-location/schemes/` - 查询爆管定位方案列表 +- `GET /api/v1/burst-location/schemes/{scheme_name}` - 获取爆管定位方案详情 + +## Action: `leakage` +- 详情技能:`leakage/SKILL.md` +- `POST /api/v1/leakage/identify/` - 执行漏损识别 +- `GET /api/v1/leakage/schemes/` - 查询漏损识别方案列表 +- `GET /api/v1/leakage/schemes/{scheme_name}` - 获取漏损识别方案详情 + +## Action: `risk` +- 详情技能:`risk/SKILL.md` +- `GET /api/v1/getnetworkpiperiskprobabilitynow/` - 获取整个网络的管道风险概率 +- `GET /api/v1/getpiperiskprobability/` - 获取管道风险概率历史 +- `GET /api/v1/getpiperiskprobabilitygeometries/` - 获取管道风险几何信息 +- `GET /api/v1/getpiperiskprobabilitynow/` - 获取管道当前风险概率 +- `GET /api/v1/getpipesriskprobability/` - 批量获取多条管道风险概率 + +## Action: `simulation` +- 详情技能:`simulation/SKILL.md` +- `GET /api/v1/age_analysis/` - 水龄分析(高级) +- `GET /api/v1/ageanalysis/` - 水龄分析(基础) +- `GET /api/v1/burst_analysis/` - 爆管分析(高级) +- `GET /api/v1/burstanalysis/` - 爆管分析(基础) +- `GET /api/v1/contaminant_simulation/` - 污染物模拟 +- `POST /api/v1/daily_scheduling_analysis/` - 日排程分析 +- `GET /api/v1/dumpoutput/` - 导出模拟输出 +- `GET /api/v1/flushing_analysis/` - 冲洗分析(高级) +- `GET /api/v1/flushinganalysis/` - 冲洗分析(基础) +- `POST /api/v1/network_project/` - 导入网络项目 +- `POST /api/v1/network_update/` - 管网更新(高级) +- `GET /api/v1/networkupdate/` - 管网更新(基础) +- `POST /api/v1/pressure_regulation/` - 压力调节(高级) +- `POST /api/v1/pressure_sensor_placement_kmeans/` - 压力传感器放置-KMeans聚类分析(高级) +- `POST /api/v1/pressure_sensor_placement_sensitivity/` - 压力传感器放置-灵敏度分析(高级) +- `GET /api/v1/pressureregulation/` - 压力调节(基础) +- `GET /api/v1/pressuresensorplacementkmeans/` - 压力传感器放置-KMeans聚类分析(基础) +- `GET /api/v1/pressuresensorplacementsensitivity/` - 压力传感器放置-灵敏度分析(基础) +- `POST /api/v1/project_management/` - 项目管理(高级) +- `GET /api/v1/projectmanagement/` - 项目管理(基础) +- `POST /api/v1/pump_failure/` - 泵故障管理 +- `GET /api/v1/runinp/` - 运行INP文件 +- `GET /api/v1/runproject/` - 运行项目模拟 +- `GET /api/v1/runprojectreturndict/` - 运行项目模拟(返回字典) +- `POST /api/v1/runsimulationmanuallybydate/` - 手动运行日期指定模拟 +- `POST /api/v1/scheduling_analysis/` - 排程分析 +- `POST /api/v1/sensorplacementscheme/create` - 传感器放置方案创建 +- `GET /api/v1/valve_close_analysis/` - 阀门关闭分析(高级) +- `GET /api/v1/valve_isolation_analysis/` - 阀门隔离分析 +- `GET /api/v1/valvecloseanalysis/` - 阀门关闭分析(基础) + +# See Also + +- 关联网络资产: `../../business/network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/burst_detection/SKILL.md b/.github/skills/analytics/simulation-analysis/burst_detection/SKILL.md new file mode 100644 index 0000000..38f5db5 --- /dev/null +++ b/.github/skills/analytics/simulation-analysis/burst_detection/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-analytics-simulation-analysis-burst-detection +description: analytics/simulation-analysis 场景下 burst-detection 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **burst_detection** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/burst-detection/detect/` - 执行爆管检测 +- `GET /api/v1/burst-detection/schemes/` - 查询爆管检测方案列表 +- `GET /api/v1/burst-detection/schemes/{scheme_name}` - 获取爆管检测方案详情 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../../business/network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/burst_location/SKILL.md b/.github/skills/analytics/simulation-analysis/burst_location/SKILL.md new file mode 100644 index 0000000..21f8d3e --- /dev/null +++ b/.github/skills/analytics/simulation-analysis/burst_location/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-analytics-simulation-analysis-burst-location +description: analytics/simulation-analysis 场景下 burst-location 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **burst_location** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/burst-location/locate/` - 执行爆管定位 +- `GET /api/v1/burst-location/schemes/` - 查询爆管定位方案列表 +- `GET /api/v1/burst-location/schemes/{scheme_name}` - 获取爆管定位方案详情 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../../business/network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/leakage/SKILL.md b/.github/skills/analytics/simulation-analysis/leakage/SKILL.md new file mode 100644 index 0000000..d0b948e --- /dev/null +++ b/.github/skills/analytics/simulation-analysis/leakage/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-analytics-simulation-analysis-leakage +description: analytics/simulation-analysis 场景下 leakage 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **leakage** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/leakage/identify/` - 执行漏损识别 +- `GET /api/v1/leakage/schemes/` - 查询漏损识别方案列表 +- `GET /api/v1/leakage/schemes/{scheme_name}` - 获取漏损识别方案详情 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../../business/network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/risk/SKILL.md b/.github/skills/analytics/simulation-analysis/risk/SKILL.md new file mode 100644 index 0000000..506af82 --- /dev/null +++ b/.github/skills/analytics/simulation-analysis/risk/SKILL.md @@ -0,0 +1,30 @@ +--- +name: api-operations-analytics-simulation-analysis-risk +description: analytics/simulation-analysis 场景下 risk 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **risk** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/getnetworkpiperiskprobabilitynow/` - 获取整个网络的管道风险概率 +- `GET /api/v1/getpiperiskprobability/` - 获取管道风险概率历史 +- `GET /api/v1/getpiperiskprobabilitygeometries/` - 获取管道风险几何信息 +- `GET /api/v1/getpiperiskprobabilitynow/` - 获取管道当前风险概率 +- `GET /api/v1/getpipesriskprobability/` - 批量获取多条管道风险概率 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../../business/network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/simulation/SKILL.md b/.github/skills/analytics/simulation-analysis/simulation/SKILL.md new file mode 100644 index 0000000..393ab5e --- /dev/null +++ b/.github/skills/analytics/simulation-analysis/simulation/SKILL.md @@ -0,0 +1,55 @@ +--- +name: api-operations-analytics-simulation-analysis-simulation +description: analytics/simulation-analysis 场景下 simulation 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **simulation** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/age_analysis/` - 水龄分析(高级) +- `GET /api/v1/ageanalysis/` - 水龄分析(基础) +- `GET /api/v1/burst_analysis/` - 爆管分析(高级) +- `GET /api/v1/burstanalysis/` - 爆管分析(基础) +- `GET /api/v1/contaminant_simulation/` - 污染物模拟 +- `POST /api/v1/daily_scheduling_analysis/` - 日排程分析 +- `GET /api/v1/dumpoutput/` - 导出模拟输出 +- `GET /api/v1/flushing_analysis/` - 冲洗分析(高级) +- `GET /api/v1/flushinganalysis/` - 冲洗分析(基础) +- `POST /api/v1/network_project/` - 导入网络项目 +- `POST /api/v1/network_update/` - 管网更新(高级) +- `GET /api/v1/networkupdate/` - 管网更新(基础) +- `POST /api/v1/pressure_regulation/` - 压力调节(高级) +- `POST /api/v1/pressure_sensor_placement_kmeans/` - 压力传感器放置-KMeans聚类分析(高级) +- `POST /api/v1/pressure_sensor_placement_sensitivity/` - 压力传感器放置-灵敏度分析(高级) +- `GET /api/v1/pressureregulation/` - 压力调节(基础) +- `GET /api/v1/pressuresensorplacementkmeans/` - 压力传感器放置-KMeans聚类分析(基础) +- `GET /api/v1/pressuresensorplacementsensitivity/` - 压力传感器放置-灵敏度分析(基础) +- `POST /api/v1/project_management/` - 项目管理(高级) +- `GET /api/v1/projectmanagement/` - 项目管理(基础) +- `POST /api/v1/pump_failure/` - 泵故障管理 +- `GET /api/v1/runinp/` - 运行INP文件 +- `GET /api/v1/runproject/` - 运行项目模拟 +- `GET /api/v1/runprojectreturndict/` - 运行项目模拟(返回字典) +- `POST /api/v1/runsimulationmanuallybydate/` - 手动运行日期指定模拟 +- `POST /api/v1/scheduling_analysis/` - 排程分析 +- `POST /api/v1/sensorplacementscheme/create` - 传感器放置方案创建 +- `GET /api/v1/valve_close_analysis/` - 阀门关闭分析(高级) +- `GET /api/v1/valve_isolation_analysis/` - 阀门隔离分析 +- `GET /api/v1/valvecloseanalysis/` - 阀门关闭分析(基础) + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../../business/network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/api-spec.md b/.github/skills/api-spec.md new file mode 100644 index 0000000..7395a1d --- /dev/null +++ b/.github/skills/api-spec.md @@ -0,0 +1,671 @@ +# API Skills 索引(领域 -> 场景 -> 操作) + +说明:操作层 Action 以 endpoint 模块为单位。 + +## ai/copilot-assistant + +### Action: `copilot` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/copilot/chat/stream` | Copilot 聊天流式响应 | + +## analytics/scada-operations + +### Action: `scada` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | +| POST | `/api/v1/addscadadevicedata/` | 添加SCADA设备数据 | +| POST | `/api/v1/addscadaelement/` | 添加SCADA元素映射 | +| POST | `/api/v1/cleanscadadevice/` | 清空SCADA设备表 | +| POST | `/api/v1/cleanscadadevicedata/` | 清空SCADA设备数据表 | +| POST | `/api/v1/cleanscadaelement/` | 清空SCADA元素映射表 | +| POST | `/api/v1/deletescadadevice/` | 删除SCADA设备 | +| POST | `/api/v1/deletescadadevicedata/` | 删除SCADA设备数据 | +| POST | `/api/v1/deletescadaelement/` | 删除SCADA元素映射 | +| GET | `/api/v1/getallscadadeviceids/` | 获取所有SCADA设备ID | +| GET | `/api/v1/getallscadadevices/` | 获取所有SCADA设备 | +| GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | +| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA属性 | +| GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | +| GET | `/api/v1/getscadadevicedata/` | 获取SCADA设备数据 | +| GET | `/api/v1/getscadadevicedataschema/` | 获取SCADA设备数据架构 | +| GET | `/api/v1/getscadadeviceschema/` | 获取SCADA设备架构 | +| GET | `/api/v1/getscadaelement/` | 获取单个SCADA元素映射 | +| GET | `/api/v1/getscadaelements/` | 获取所有SCADA元素映射 | +| GET | `/api/v1/getscadaelementschema/` | 获取SCADA元素架构 | +| GET | `/api/v1/getscadainfo/` | 获取SCADA信息 | +| GET | `/api/v1/getscadainfoschema/` | 获取SCADA信息架构 | +| GET | `/api/v1/getscadaproperties/` | 获取SCADA属性 | +| POST | `/api/v1/scada/batch` | 批量插入SCADA监测数据 | +| DELETE | `/api/v1/scada/by-id-time-range` | 按设备ID和时间范围删除SCADA数据 | +| GET | `/api/v1/scada/by-ids-field-time-range` | 按设备ID、字段和时间范围查询SCADA数据 | +| GET | `/api/v1/scada/by-ids-time-range` | 按设备ID和时间范围查询SCADA数据 | +| PATCH | `/api/v1/scada/{device_id}/field` | 更新SCADA设备字段 | +| POST | `/api/v1/setscadadevice/` | 更新SCADA设备 | +| POST | `/api/v1/setscadadevicedata/` | 更新SCADA设备数据 | +| POST | `/api/v1/setscadaelement/` | 更新SCADA元素映射 | + +## analytics/simulation-analysis + +### Action: `burst_detection` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/burst-detection/detect/` | 执行爆管检测 | +| GET | `/api/v1/burst-detection/schemes/` | 查询爆管检测方案列表 | +| GET | `/api/v1/burst-detection/schemes/{scheme_name}` | 获取爆管检测方案详情 | + +### Action: `burst_location` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/burst-location/locate/` | 执行爆管定位 | +| GET | `/api/v1/burst-location/schemes/` | 查询爆管定位方案列表 | +| GET | `/api/v1/burst-location/schemes/{scheme_name}` | 获取爆管定位方案详情 | + +### Action: `leakage` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/leakage/identify/` | 执行漏损识别 | +| GET | `/api/v1/leakage/schemes/` | 查询漏损识别方案列表 | +| GET | `/api/v1/leakage/schemes/{scheme_name}` | 获取漏损识别方案详情 | + +### Action: `risk` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getnetworkpiperiskprobabilitynow/` | 获取整个网络的管道风险概率 | +| GET | `/api/v1/getpiperiskprobability/` | 获取管道风险概率历史 | +| GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | +| GET | `/api/v1/getpiperiskprobabilitynow/` | 获取管道当前风险概率 | +| GET | `/api/v1/getpipesriskprobability/` | 批量获取多条管道风险概率 | + +### Action: `simulation` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/age_analysis/` | 水龄分析(高级) | +| GET | `/api/v1/ageanalysis/` | 水龄分析(基础) | +| GET | `/api/v1/burst_analysis/` | 爆管分析(高级) | +| GET | `/api/v1/burstanalysis/` | 爆管分析(基础) | +| GET | `/api/v1/contaminant_simulation/` | 污染物模拟 | +| POST | `/api/v1/daily_scheduling_analysis/` | 日排程分析 | +| GET | `/api/v1/dumpoutput/` | 导出模拟输出 | +| GET | `/api/v1/flushing_analysis/` | 冲洗分析(高级) | +| GET | `/api/v1/flushinganalysis/` | 冲洗分析(基础) | +| POST | `/api/v1/network_project/` | 导入网络项目 | +| POST | `/api/v1/network_update/` | 管网更新(高级) | +| GET | `/api/v1/networkupdate/` | 管网更新(基础) | +| POST | `/api/v1/pressure_regulation/` | 压力调节(高级) | +| POST | `/api/v1/pressure_sensor_placement_kmeans/` | 压力传感器放置-KMeans聚类分析(高级) | +| POST | `/api/v1/pressure_sensor_placement_sensitivity/` | 压力传感器放置-灵敏度分析(高级) | +| GET | `/api/v1/pressureregulation/` | 压力调节(基础) | +| GET | `/api/v1/pressuresensorplacementkmeans/` | 压力传感器放置-KMeans聚类分析(基础) | +| GET | `/api/v1/pressuresensorplacementsensitivity/` | 压力传感器放置-灵敏度分析(基础) | +| POST | `/api/v1/project_management/` | 项目管理(高级) | +| GET | `/api/v1/projectmanagement/` | 项目管理(基础) | +| POST | `/api/v1/pump_failure/` | 泵故障管理 | +| GET | `/api/v1/runinp/` | 运行INP文件 | +| GET | `/api/v1/runproject/` | 运行项目模拟 | +| GET | `/api/v1/runprojectreturndict/` | 运行项目模拟(返回字典) | +| POST | `/api/v1/runsimulationmanuallybydate/` | 手动运行日期指定模拟 | +| POST | `/api/v1/scheduling_analysis/` | 排程分析 | +| POST | `/api/v1/sensorplacementscheme/create` | 传感器放置方案创建 | +| GET | `/api/v1/valve_close_analysis/` | 阀门关闭分析(高级) | +| GET | `/api/v1/valve_isolation_analysis/` | 阀门隔离分析 | +| GET | `/api/v1/valvecloseanalysis/` | 阀门关闭分析(基础) | + +## business/component-config + +### Action: `controls` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getcontrolproperties/` | 获取控制属性 | +| GET | `/api/v1/getcontrolschema/` | 获取控制架构 | +| GET | `/api/v1/getruleproperties/` | 获取规则属性 | +| GET | `/api/v1/getruleschema/` | 获取规则架构 | +| POST | `/api/v1/setcontrolproperties/` | 设置控制属性 | +| POST | `/api/v1/setruleproperties/` | 设置规则属性 | + +### Action: `curves` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addcurve/` | 添加曲线 | +| POST | `/api/v1/deletecurve/` | 删除曲线 | +| GET | `/api/v1/getcurveproperties/` | 获取曲线属性 | +| GET | `/api/v1/getcurves/` | 获取所有曲线 | +| GET | `/api/v1/getcurveschema` | 获取曲线架构 | +| GET | `/api/v1/iscurve/` | 检查曲线存在性 | +| POST | `/api/v1/setcurveproperties/` | 设置曲线属性 | + +### Action: `options` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getenergyproperties/` | 获取能耗选项属性 | +| GET | `/api/v1/getenergyschema/` | 获取能耗选项架构 | +| GET | `/api/v1/getoptionproperties/` | 获取选项属性 | +| GET | `/api/v1/getoptionschema/` | 获取选项架构 | +| GET | `/api/v1/getpumpenergyproperties/` | 获取泵能耗属性 | +| GET | `/api/v1/getpumpenergyschema/` | 获取泵能耗选项架构 | +| GET | `/api/v1/gettimeproperties/` | 获取时间选项属性 | +| GET | `/api/v1/gettimeschema` | 获取时间选项架构 | +| POST | `/api/v1/setenergyproperties/` | 设置能耗选项属性 | +| POST | `/api/v1/setoptionproperties/` | 设置选项属性 | +| GET | `/api/v1/setpumpenergyproperties/` | 设置泵能耗属性 | +| POST | `/api/v1/settimeproperties/` | 设置时间选项属性 | + +### Action: `patterns` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addpattern/` | 添加模式 | +| POST | `/api/v1/deletepattern/` | 删除模式 | +| GET | `/api/v1/getpatternproperties/` | 获取模式属性 | +| GET | `/api/v1/getpatterns/` | 获取所有模式 | +| GET | `/api/v1/getpatternschema` | 获取模式架构 | +| GET | `/api/v1/ispattern/` | 检查模式存在性 | +| POST | `/api/v1/setpatternproperties/` | 设置模式属性 | + +### Action: `quality` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addmixing/` | 添加混合 | +| POST | `/api/v1/addsource/` | 添加水源 | +| POST | `/api/v1/deletemixing/` | 删除混合 | +| POST | `/api/v1/deletesource/` | 删除水源 | +| GET | `/api/v1/getemitterproperties/` | 获取发射器属性 | +| GET | `/api/v1/getemitterschema` | 获取发射器架构 | +| GET | `/api/v1/getmixing/` | 获取混合属性 | +| GET | `/api/v1/getmixingschema/` | 获取混合架构 | +| GET | `/api/v1/getpipereaction/` | 获取管道反应属性 | +| GET | `/api/v1/getpipereactionschema/` | 获取管道反应架构 | +| GET | `/api/v1/getqualityproperties/` | 获取水质属性 | +| GET | `/api/v1/getqualityschema/` | 获取水质架构 | +| GET | `/api/v1/getreaction/` | 获取反应属性 | +| GET | `/api/v1/getreactionschema/` | 获取反应架构 | +| GET | `/api/v1/getsource/` | 获取水源属性 | +| GET | `/api/v1/getsourcechema/` | 获取水源架构 | +| GET | `/api/v1/gettankreaction/` | 获取水池反应属性 | +| GET | `/api/v1/gettankreactionschema/` | 获取水池反应架构 | +| POST | `/api/v1/setemitterproperties/` | 设置发射器属性 | +| POST | `/api/v1/setmixing/` | 设置混合属性 | +| POST | `/api/v1/setpipereaction/` | 设置管道反应属性 | +| POST | `/api/v1/setqualityproperties/` | 设置水质属性 | +| POST | `/api/v1/setreaction/` | 设置反应属性 | +| POST | `/api/v1/setsource/` | 设置水源属性 | +| POST | `/api/v1/settankreaction/` | 设置水池反应属性 | + +### Action: `visuals` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addlabel/` | 添加标签 | +| POST | `/api/v1/addvertex/` | 添加图形元素 | +| POST | `/api/v1/deletelabel/` | 删除标签 | +| POST | `/api/v1/deletevertex/` | 删除图形元素 | +| GET | `/api/v1/getallvertexlinks/` | 获取所有图形元素链接 | +| GET | `/api/v1/getallvertices/` | 获取所有图形元素 | +| GET | `/api/v1/getbackdropproperties/` | 获取背景属性 | +| GET | `/api/v1/getbackdropschema/` | 获取背景架构 | +| GET | `/api/v1/getlabelproperties/` | 获取标签属性 | +| GET | `/api/v1/getlabelschema/` | 获取标签架构 | +| GET | `/api/v1/getvertexproperties/` | 获取图形元素属性 | +| GET | `/api/v1/getvertexschema/` | 获取图形元素架构 | +| POST | `/api/v1/setbackdropproperties/` | 设置背景属性 | +| POST | `/api/v1/setlabelproperties/` | 设置标签属性 | +| POST | `/api/v1/setvertexproperties/` | 设置图形元素属性 | + +## business/identity-access + +### Action: `auth` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/auth/login` | login | +| POST | `/api/v1/auth/login/simple` | login_simple | +| GET | `/api/v1/auth/me` | get_current_user_info | +| POST | `/api/v1/auth/refresh` | refresh_token | +| POST | `/api/v1/auth/register` | register | + +### Action: `user_management` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/users/` | 列出所有用户 | +| DELETE | `/api/v1/users/{user_id}` | 删除用户 | +| GET | `/api/v1/users/{user_id}` | 获取用户详情 | +| PUT | `/api/v1/users/{user_id}` | 更新用户信息 | +| POST | `/api/v1/users/{user_id}/activate` | 激活用户 | +| POST | `/api/v1/users/{user_id}/deactivate` | 停用用户 | + +### Action: `users` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getallusers/` | 获取所有用户 | +| GET | `/api/v1/getuser/` | 获取单个用户 | +| GET | `/api/v1/getuserschema/` | 获取用户模式 | + +## business/network-assets + +### Action: `demands` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/calculatedemandtonetwork/` | 计算需水量到整网分配 | +| GET | `/api/v1/calculatedemandtonodes/` | 计算需水量到节点分配 | +| GET | `/api/v1/calculatedemandtoregion/` | 计算需水量到区域分配 | +| GET | `/api/v1/getdemandproperties/` | 获取需水量属性 | +| GET | `/api/v1/getdemandschema` | 获取需水量属性架构 | +| POST | `/api/v1/setdemandproperties/` | 设置需水量属性 | + +### Action: `general` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/deletelink/` | 删除管线 | +| POST | `/api/v1/deletenode/` | 删除节点 | +| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA点属性 | +| GET | `/api/v1/getelementproperties/` | 获取元素属性 | +| GET | `/api/v1/getelementpropertieswithtype/` | 获取指定类型元素属性 | +| GET | `/api/v1/getelementtype/` | 获取元素类型 | +| GET | `/api/v1/getelementtypevalue/` | 获取元素类型值 | +| GET | `/api/v1/getlinkproperties/` | 获取管线属性 | +| GET | `/api/v1/getlinks/` | 获取所有管线 | +| GET | `/api/v1/getlinktype/` | 获取管线类型 | +| GET | `/api/v1/getnodelinks/` | 获取节点的关联管线 | +| GET | `/api/v1/getnodeproperties/` | 获取节点属性 | +| GET | `/api/v1/getnodes/` | 获取所有节点 | +| GET | `/api/v1/getnodetype/` | 获取节点类型 | +| GET | `/api/v1/getscadaproperties/` | 获取SCADA点属性 | +| GET | `/api/v1/getstatus/` | 获取管线状态 | +| GET | `/api/v1/getstatusschema` | 获取状态属性架构 | +| GET | `/api/v1/gettitle/` | 获取水网标题属性 | +| GET | `/api/v1/gettitleschema/` | 获取标题属性架构 | +| GET | `/api/v1/isjunction/` | 检查是否为接点 | +| GET | `/api/v1/islink/` | 检查管线有效性 | +| GET | `/api/v1/isnode/` | 检查节点有效性 | +| GET | `/api/v1/ispipe/` | 检查是否为管道 | +| GET | `/api/v1/ispump/` | 检查是否为泵 | +| GET | `/api/v1/isreservoir/` | 检查是否为水源 | +| GET | `/api/v1/istank/` | 检查是否为蓄水池 | +| GET | `/api/v1/isvalve/` | 检查是否为阀门 | +| POST | `/api/v1/setstatus/` | 设置管线状态 | +| GET | `/api/v1/settitle/` | 设置水网标题属性 | + +### Action: `geometry` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getmajornodecoords/` | 获取主要节点坐标 | +| GET | `/api/v1/getmajorpipenodes/` | 获取主要管道节点 | +| GET | `/api/v1/getnetworkgeometries/` | 获取完整网络几何信息 | +| GET | `/api/v1/getnetworkinextent/` | 获取范围内的网络元素 | +| GET | `/api/v1/getnetworklinknodes/` | 获取网络管线节点 | +| GET | `/api/v1/getnodecoord/` | 获取节点坐标 | + +### Action: `junctions` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addjunction/` | 添加节点 | +| POST | `/api/v1/deletejunction/` | 删除节点 | +| GET | `/api/v1/getalljunctionproperties/` | 获取所有节点属性 | +| GET | `/api/v1/getjunctioncoord/` | 获取节点坐标 | +| GET | `/api/v1/getjunctiondemand/` | 获取节点需水量 | +| GET | `/api/v1/getjunctionelevation/` | 获取节点标高 | +| GET | `/api/v1/getjunctionpattern/` | 获取节点需水模式 | +| GET | `/api/v1/getjunctionproperties/` | 获取节点属性 | +| GET | `/api/v1/getjunctionschema` | 获取节点架构 | +| GET | `/api/v1/getjunctionx/` | 获取节点 X 坐标 | +| GET | `/api/v1/getjunctiony/` | 获取节点 Y 坐标 | +| POST | `/api/v1/setjunctioncoord/` | 设置节点坐标 | +| POST | `/api/v1/setjunctiondemand/` | 设置节点需水量 | +| POST | `/api/v1/setjunctionelevation/` | 设置节点标高 | +| POST | `/api/v1/setjunctionpattern/` | 设置节点需水模式 | +| POST | `/api/v1/setjunctionproperties/` | 批量设置节点属性 | +| POST | `/api/v1/setjunctionx/` | 设置节点 X 坐标 | +| POST | `/api/v1/setjunctiony/` | 设置节点 Y 坐标 | + +### Action: `pipes` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addpipe/` | 添加管道 | +| POST | `/api/v1/deletepipe/` | 删除管道 | +| GET | `/api/v1/getallpipeproperties/` | 获取所有管道属性 | +| GET | `/api/v1/getpipediameter/` | 获取管道管径 | +| GET | `/api/v1/getpipelength/` | 获取管道长度 | +| GET | `/api/v1/getpipeminorloss/` | 获取管道局部阻力系数 | +| GET | `/api/v1/getpipenode1/` | 获取管道起始节点 | +| GET | `/api/v1/getpipenode2/` | 获取管道终止节点 | +| GET | `/api/v1/getpipeproperties/` | 获取管道属性 | +| GET | `/api/v1/getpiperoughness/` | 获取管道粗糙度 | +| GET | `/api/v1/getpipeschema` | 获取管道模式 | +| GET | `/api/v1/getpipestatus/` | 获取管道状态 | +| POST | `/api/v1/setpipediameter/` | 设置管道管径 | +| POST | `/api/v1/setpipelength/` | 设置管道长度 | +| POST | `/api/v1/setpipeminorloss/` | 设置管道局部阻力系数 | +| POST | `/api/v1/setpipenode1/` | 设置管道起始节点 | +| POST | `/api/v1/setpipenode2/` | 设置管道终止节点 | +| POST | `/api/v1/setpipeproperties/` | 设置管道属性 | +| POST | `/api/v1/setpiperoughness/` | 设置管道粗糙度 | +| POST | `/api/v1/setpipestatus/` | 设置管道状态 | + +### Action: `pumps` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addpump/` | 添加水泵 | +| POST | `/api/v1/deletepump/` | 删除水泵 | +| GET | `/api/v1/getallpumpproperties/` | 获取所有水泵属性 | +| GET | `/api/v1/getpumpnode1/` | 获取水泵起始节点 | +| GET | `/api/v1/getpumpnode2/` | 获取水泵终止节点 | +| GET | `/api/v1/getpumpproperties/` | 获取水泵属性 | +| GET | `/api/v1/getpumpschema` | 获取水泵模式 | +| POST | `/api/v1/setpumpnode1/` | 设置水泵起始节点 | +| POST | `/api/v1/setpumpnode2/` | 设置水泵终止节点 | +| POST | `/api/v1/setpumpproperties/` | 设置水泵属性 | + +### Action: `regions` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/adddistrictmeteringarea/` | 添加新DMA | +| POST | `/api/v1/addregion/` | 添加新区域 | +| POST | `/api/v1/addservicearea/` | 添加新服务区 | +| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | +| GET | `/api/v1/calculatedistrictmeteringarea/` | 计算DMA分区 | +| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | +| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | +| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | +| GET | `/api/v1/calculateregion/` | 计算区域 | +| GET | `/api/v1/calculateservicearea/` | 计算服务区 | +| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | +| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | +| POST | `/api/v1/deleteregion/` | 删除区域 | +| POST | `/api/v1/deleteservicearea/` | 删除服务区 | +| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | +| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | +| POST | `/api/v1/generateregion/` | 生成区域分区 | +| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | +| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | +| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | +| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | +| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | +| GET | `/api/v1/getallregions/` | 获取所有区域 | +| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | +| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | +| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | +| GET | `/api/v1/getdistrictmeteringareaschema/` | 获取DMA属性架构 | +| GET | `/api/v1/getregion/` | 获取区域信息 | +| GET | `/api/v1/getregionschema/` | 获取区域属性架构 | +| GET | `/api/v1/getservicearea/` | 获取服务区信息 | +| GET | `/api/v1/getserviceareaschema/` | 获取服务区属性架构 | +| GET | `/api/v1/getvirtualdistrict/` | 获取虚拟分区信息 | +| GET | `/api/v1/getvirtualdistrictschema/` | 获取虚拟分区属性架构 | +| POST | `/api/v1/setdistrictmeteringarea/` | 设置DMA属性 | +| POST | `/api/v1/setregion/` | 设置区域属性 | +| POST | `/api/v1/setservicearea/` | 设置服务区属性 | +| POST | `/api/v1/setvirtualdistrict/` | 设置虚拟分区属性 | + +### Action: `reservoirs` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addreservoir/` | 添加水库 | +| POST | `/api/v1/deletereservoir/` | 删除水库 | +| GET | `/api/v1/getallreservoirproperties/` | 获取所有水库属性 | +| GET | `/api/v1/getreservoircoord/` | 获取水库坐标 | +| GET | `/api/v1/getreservoirhead/` | 获取水库水头 | +| GET | `/api/v1/getreservoirpattern/` | 获取水库模式 | +| GET | `/api/v1/getreservoirproperties/` | 获取水库属性 | +| GET | `/api/v1/getreservoirschema` | 获取水库模式 | +| GET | `/api/v1/getreservoirx/` | 获取水库X坐标 | +| GET | `/api/v1/getreservoiry/` | 获取水库Y坐标 | +| POST | `/api/v1/setreservoircoord/` | 设置水库坐标 | +| POST | `/api/v1/setreservoirhead/` | 设置水库水头 | +| POST | `/api/v1/setreservoirpattern/` | 设置水库模式 | +| POST | `/api/v1/setreservoirproperties/` | 设置水库属性 | +| POST | `/api/v1/setreservoirx/` | 设置水库X坐标 | +| POST | `/api/v1/setreservoiry/` | 设置水库Y坐标 | + +### Action: `tags` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/gettag/` | 获取标签信息 | +| GET | `/api/v1/gettags/` | 获取所有标签 | +| GET | `/api/v1/gettagschema/` | 获取标签属性架构 | +| POST | `/api/v1/settag/` | 设置标签 | + +### Action: `tanks` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addtank/` | 新增水箱 | +| POST | `/api/v1/deletetank/` | 删除水箱 | +| GET | `/api/v1/getalltankproperties/` | 获取所有水箱属性 | +| GET | `/api/v1/gettankcoord/` | 获取水箱坐标 | +| GET | `/api/v1/gettankdiameter/` | 获取水箱直径 | +| GET | `/api/v1/gettankelevation/` | 获取水箱标高 | +| GET | `/api/v1/gettankinitlevel/` | 获取水箱初始水位 | +| GET | `/api/v1/gettankmaxlevel/` | 获取水箱最大水位 | +| GET | `/api/v1/gettankminlevel/` | 获取水箱最小水位 | +| GET | `/api/v1/gettankminvol/` | 获取水箱最小体积 | +| GET | `/api/v1/gettankoverflow/` | 获取水箱溢流口 | +| GET | `/api/v1/gettankproperties/` | 获取水箱属性 | +| GET | `/api/v1/gettankschema` | 获取水箱模式 | +| GET | `/api/v1/gettankvolcurve/` | 获取水箱容积曲线 | +| GET | `/api/v1/gettankx/` | 获取水箱X坐标 | +| GET | `/api/v1/gettanky/` | 获取水箱Y坐标 | +| POST | `/api/v1/settankcoord/` | 设置水箱坐标 | +| POST | `/api/v1/settankdiameter/` | 设置水箱直径 | +| POST | `/api/v1/settankelevation/` | 设置水箱标高 | +| POST | `/api/v1/settankinitlevel/` | 设置水箱初始水位 | +| POST | `/api/v1/settankmaxlevel/` | 设置水箱最大水位 | +| POST | `/api/v1/settankminlevel/` | 设置水箱最小水位 | +| POST | `/api/v1/settankminvol/` | 设置水箱最小体积 | +| POST | `/api/v1/settankoverflow/` | 设置水箱溢流口 | +| POST | `/api/v1/settankproperties/` | 设置水箱属性 | +| POST | `/api/v1/settankvolcurve/` | 设置水箱容积曲线 | +| POST | `/api/v1/settankx/` | 设置水箱X坐标 | +| POST | `/api/v1/settanky/` | 设置水箱Y坐标 | + +### Action: `valves` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/addvalve/` | 添加阀门 | +| POST | `/api/v1/deletevalve/` | 删除阀门 | +| GET | `/api/v1/getallvalveproperties/` | 获取所有阀门属性 | +| GET | `/api/v1/getvalvediameter/` | 获取阀门直径 | +| GET | `/api/v1/getvalveminorloss/` | 获取阀门损失系数 | +| GET | `/api/v1/getvalvenode1/` | 获取阀门起点节点 | +| GET | `/api/v1/getvalvenode2/` | 获取阀门终点节点 | +| GET | `/api/v1/getvalveproperties/` | 获取阀门所有属性 | +| GET | `/api/v1/getvalveschema` | 获取阀门架构 | +| GET | `/api/v1/getvalvesetting/` | 获取阀门开度 | +| GET | `/api/v1/getvalvetype/` | 获取阀门类型 | +| POST | `/api/v1/setvalvenode1/` | 设置阀门起点节点 | +| POST | `/api/v1/setvalvenode2/` | 设置阀门终点节点 | +| POST | `/api/v1/setvalvenodediameter/` | 设置阀门直径 | +| POST | `/api/v1/setvalveproperties/` | 批量设置阀门属性 | +| POST | `/api/v1/setvalvesetting/` | 设置阀门开度 | +| POST | `/api/v1/setvalvetype/` | 设置阀门类型 | + +## business/project-workspace + +### Action: `extension` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getallextensiondata/` | 获取所有扩展数据 | +| GET | `/api/v1/getallextensiondatakeys/` | 获取所有扩展数据键 | +| GET | `/api/v1/getextensiondata/` | 获取指定扩展数据 | +| POST | `/api/v1/setextensiondata/` | 设置扩展数据 | + +### Action: `misc` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getallburstlocateresults/` | 获取所有爆管定位结果 | +| GET | `/api/v1/getallsensorplacements/` | 获取所有传感器位置 | +| GET | `/api/v1/getjson/` | 获取JSON示例 | +| GET | `/api/v1/getrealtimedata/` | 获取实时数据 | +| GET | `/api/v1/getsimulationresult/` | 获取模拟结果 | +| POST | `/api/v1/test_dict/` | 测试字典处理 | + +### Action: `project` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/closeproject/` | 关闭项目 | +| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | +| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | +| POST | `/api/v1/copyproject/` | 复制项目 | +| POST | `/api/v1/createproject/` | 创建新项目 | +| POST | `/api/v1/deleteproject/` | 删除项目 | +| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | +| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | +| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | +| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | +| GET | `/api/v1/exportinp/` | 导出项目为 ChangeSet | +| GET | `/api/v1/haveproject/` | 检查项目是否存在 | +| POST | `/api/v1/importinp/` | 导入 INP 文件内容 | +| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | +| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | +| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | +| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | +| GET | `/api/v1/isprojectopen/` | 检查项目是否已打开 | +| GET | `/api/v1/listprojects/` | 获取项目列表 | +| POST | `/api/v1/lockproject/` | 锁定项目 | +| POST | `/api/v1/lockproject/` | 锁定项目 | +| POST | `/api/v1/openproject/` | 打开项目 | +| GET | `/api/v1/project_info/` | 获取项目信息 | +| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | +| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | +| POST | `/api/v1/unlockproject/` | 解锁项目 | +| POST | `/api/v1/unlockproject/` | 解锁项目 | +| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | +| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | + +### Action: `project_data` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/burst-locate-result` | 获取爆管定位结果 | +| GET | `/api/v1/burst-locate-result/{burst_incident}` | 按事件查询爆管定位结果 | +| GET | `/api/v1/scada-info` | 获取SCADA信息 | +| GET | `/api/v1/scheme-list` | 获取方案列表 | + +### Action: `schemes` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/getallschemes/` | 获取所有方案 | +| GET | `/api/v1/getscheme/` | 获取单个方案 | +| GET | `/api/v1/getschemeschema/` | 获取方案模式 | + +### Action: `snapshots` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/batch/` | 执行批量命令 | +| POST | `/api/v1/compressedbatch/` | 执行压缩批量命令 | +| GET | `/api/v1/getcurrentoperationid/` | 获取当前操作ID | +| GET | `/api/v1/getrestoreoperation/` | 获取恢复操作ID | +| GET | `/api/v1/getsnapshots/` | 获取快照列表 | +| GET | `/api/v1/havesnapshot/` | 检查快照是否存在 | +| GET | `/api/v1/havesnapshotforcurrentoperation/` | 检查当前操作快照是否存在 | +| GET | `/api/v1/havesnapshotforoperation/` | 检查操作快照是否存在 | +| POST | `/api/v1/pickoperation/` | 选择操作 | +| POST | `/api/v1/picksnapshot/` | 选择快照 | +| POST | `/api/v1/redo/` | 重做操作 | +| POST | `/api/v1/setrestoreoperation/` | 设置恢复操作ID | +| GET | `/api/v1/syncwithserver/` | 与服务器同步 | +| POST | `/api/v1/takenapshotforcurrentoperation` | 为当前操作创建快照(兼容模式) | +| POST | `/api/v1/takesnapshot/` | 创建快照 | +| POST | `/api/v1/takesnapshotforcurrentoperation` | 为当前操作创建快照 | +| POST | `/api/v1/takesnapshotforoperation/` | 为操作创建快照 | +| POST | `/api/v1/undo/` | 撤销操作 | + +## data/timeseries-access + +### Action: `composite` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/composite/clean-scada` | 清洗SCADA监测数据 | +| GET | `/api/v1/composite/element-scada` | 获取管网元素关联的SCADA监测数据 | +| GET | `/api/v1/composite/element-simulation` | 获取管网元素的模拟数据 | +| GET | `/api/v1/composite/pipeline-health-prediction` | 预测管道健康状况 | +| GET | `/api/v1/composite/scada-simulation` | 获取SCADA关联的模拟数据 | + +### Action: `realtime` + +| Method | Path | Summary | +|---|---|---| +| DELETE | `/api/v1/realtime/links` | 删除实时管道数据 | +| GET | `/api/v1/realtime/links` | 查询实时管道数据 | +| POST | `/api/v1/realtime/links/batch` | 批量插入实时管道数据 | +| PATCH | `/api/v1/realtime/links/{link_id}/field` | 更新实时管道字段 | +| DELETE | `/api/v1/realtime/nodes` | 删除实时节点数据 | +| GET | `/api/v1/realtime/nodes` | 查询实时节点数据 | +| POST | `/api/v1/realtime/nodes/batch` | 批量插入实时节点数据 | +| GET | `/api/v1/realtime/query/by-id-time` | 按ID和时间查询实时模拟数据 | +| GET | `/api/v1/realtime/query/by-time-property` | 按时间和属性查询实时数据 | +| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | + +### Action: `scheme` + +| Method | Path | Summary | +|---|---|---| +| DELETE | `/api/v1/scheme/links` | 删除方案管道数据 | +| GET | `/api/v1/scheme/links` | 查询方案管道数据 | +| POST | `/api/v1/scheme/links/batch` | 批量插入方案管道数据 | +| GET | `/api/v1/scheme/links/{link_id}/field` | 查询方案管道字段数据 | +| PATCH | `/api/v1/scheme/links/{link_id}/field` | 更新方案管道字段 | +| DELETE | `/api/v1/scheme/nodes` | 删除方案节点数据 | +| POST | `/api/v1/scheme/nodes/batch` | 批量插入方案节点数据 | +| GET | `/api/v1/scheme/nodes/{node_id}/field` | 查询方案节点字段数据 | +| PATCH | `/api/v1/scheme/nodes/{node_id}/field` | 更新方案节点字段 | +| GET | `/api/v1/scheme/query/by-id-time` | 按ID和时间查询方案模拟数据 | +| GET | `/api/v1/scheme/query/by-scheme-time-property` | 按方案、时间和属性查询数据 | +| POST | `/api/v1/scheme/simulation/store` | 存储方案模拟结果 | + +## platform/governance-observability + +### Action: `audit` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/audit/logs` | 查询审计日志 | +| GET | `/api/v1/audit/logs/count` | 获取审计日志总数 | +| GET | `/api/v1/audit/logs/my` | 查询我的审计日志 | + +### Action: `cache` + +| Method | Path | Summary | +|---|---|---| +| POST | `/api/v1/clearallredis/` | 清除所有缓存 | +| POST | `/api/v1/clearrediskey/` | 清除单个缓存键 | +| POST | `/api/v1/clearrediskeys/` | 清除匹配的缓存键 | +| GET | `/api/v1/queryredis/` | 查询缓存键列表 | + +### Action: `meta` + +| Method | Path | Summary | +|---|---|---| +| GET | `/api/v1/meta/db/health` | 检查数据库健康状态 | +| GET | `/api/v1/meta/project` | 获取项目元数据 | +| GET | `/api/v1/meta/projects` | 列出用户项目 | + diff --git a/.github/skills/business/component-config/SKILL.md b/.github/skills/business/component-config/SKILL.md new file mode 100644 index 0000000..f8fb63d --- /dev/null +++ b/.github/skills/business/component-config/SKILL.md @@ -0,0 +1,121 @@ +--- +name: api-operations-business-component-config +description: 组件参数、控制规则、水质和可视化接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **business/component-config** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# Action Skills + +- `controls`: `controls/SKILL.md` +- `curves`: `curves/SKILL.md` +- `options`: `options/SKILL.md` +- `patterns`: `patterns/SKILL.md` +- `quality`: `quality/SKILL.md` +- `visuals`: `visuals/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `controls` +- 详情技能:`controls/SKILL.md` +- `GET /api/v1/getcontrolproperties/` - 获取控制属性 +- `GET /api/v1/getcontrolschema/` - 获取控制架构 +- `GET /api/v1/getruleproperties/` - 获取规则属性 +- `GET /api/v1/getruleschema/` - 获取规则架构 +- `POST /api/v1/setcontrolproperties/` - 设置控制属性 +- `POST /api/v1/setruleproperties/` - 设置规则属性 + +## Action: `curves` +- 详情技能:`curves/SKILL.md` +- `POST /api/v1/addcurve/` - 添加曲线 +- `POST /api/v1/deletecurve/` - 删除曲线 +- `GET /api/v1/getcurveproperties/` - 获取曲线属性 +- `GET /api/v1/getcurves/` - 获取所有曲线 +- `GET /api/v1/getcurveschema` - 获取曲线架构 +- `GET /api/v1/iscurve/` - 检查曲线存在性 +- `POST /api/v1/setcurveproperties/` - 设置曲线属性 + +## Action: `options` +- 详情技能:`options/SKILL.md` +- `GET /api/v1/getenergyproperties/` - 获取能耗选项属性 +- `GET /api/v1/getenergyschema/` - 获取能耗选项架构 +- `GET /api/v1/getoptionproperties/` - 获取选项属性 +- `GET /api/v1/getoptionschema/` - 获取选项架构 +- `GET /api/v1/getpumpenergyproperties/` - 获取泵能耗属性 +- `GET /api/v1/getpumpenergyschema/` - 获取泵能耗选项架构 +- `GET /api/v1/gettimeproperties/` - 获取时间选项属性 +- `GET /api/v1/gettimeschema` - 获取时间选项架构 +- `POST /api/v1/setenergyproperties/` - 设置能耗选项属性 +- `POST /api/v1/setoptionproperties/` - 设置选项属性 +- `GET /api/v1/setpumpenergyproperties/` - 设置泵能耗属性 +- `POST /api/v1/settimeproperties/` - 设置时间选项属性 + +## Action: `patterns` +- 详情技能:`patterns/SKILL.md` +- `POST /api/v1/addpattern/` - 添加模式 +- `POST /api/v1/deletepattern/` - 删除模式 +- `GET /api/v1/getpatternproperties/` - 获取模式属性 +- `GET /api/v1/getpatterns/` - 获取所有模式 +- `GET /api/v1/getpatternschema` - 获取模式架构 +- `GET /api/v1/ispattern/` - 检查模式存在性 +- `POST /api/v1/setpatternproperties/` - 设置模式属性 + +## Action: `quality` +- 详情技能:`quality/SKILL.md` +- `POST /api/v1/addmixing/` - 添加混合 +- `POST /api/v1/addsource/` - 添加水源 +- `POST /api/v1/deletemixing/` - 删除混合 +- `POST /api/v1/deletesource/` - 删除水源 +- `GET /api/v1/getemitterproperties/` - 获取发射器属性 +- `GET /api/v1/getemitterschema` - 获取发射器架构 +- `GET /api/v1/getmixing/` - 获取混合属性 +- `GET /api/v1/getmixingschema/` - 获取混合架构 +- `GET /api/v1/getpipereaction/` - 获取管道反应属性 +- `GET /api/v1/getpipereactionschema/` - 获取管道反应架构 +- `GET /api/v1/getqualityproperties/` - 获取水质属性 +- `GET /api/v1/getqualityschema/` - 获取水质架构 +- `GET /api/v1/getreaction/` - 获取反应属性 +- `GET /api/v1/getreactionschema/` - 获取反应架构 +- `GET /api/v1/getsource/` - 获取水源属性 +- `GET /api/v1/getsourcechema/` - 获取水源架构 +- `GET /api/v1/gettankreaction/` - 获取水池反应属性 +- `GET /api/v1/gettankreactionschema/` - 获取水池反应架构 +- `POST /api/v1/setemitterproperties/` - 设置发射器属性 +- `POST /api/v1/setmixing/` - 设置混合属性 +- `POST /api/v1/setpipereaction/` - 设置管道反应属性 +- `POST /api/v1/setqualityproperties/` - 设置水质属性 +- `POST /api/v1/setreaction/` - 设置反应属性 +- `POST /api/v1/setsource/` - 设置水源属性 +- `POST /api/v1/settankreaction/` - 设置水池反应属性 + +## Action: `visuals` +- 详情技能:`visuals/SKILL.md` +- `POST /api/v1/addlabel/` - 添加标签 +- `POST /api/v1/addvertex/` - 添加图形元素 +- `POST /api/v1/deletelabel/` - 删除标签 +- `POST /api/v1/deletevertex/` - 删除图形元素 +- `GET /api/v1/getallvertexlinks/` - 获取所有图形元素链接 +- `GET /api/v1/getallvertices/` - 获取所有图形元素 +- `GET /api/v1/getbackdropproperties/` - 获取背景属性 +- `GET /api/v1/getbackdropschema/` - 获取背景架构 +- `GET /api/v1/getlabelproperties/` - 获取标签属性 +- `GET /api/v1/getlabelschema/` - 获取标签架构 +- `GET /api/v1/getvertexproperties/` - 获取图形元素属性 +- `GET /api/v1/getvertexschema/` - 获取图形元素架构 +- `POST /api/v1/setbackdropproperties/` - 设置背景属性 +- `POST /api/v1/setlabelproperties/` - 设置标签属性 +- `POST /api/v1/setvertexproperties/` - 设置图形元素属性 + +# See Also + +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/controls/SKILL.md b/.github/skills/business/component-config/controls/SKILL.md new file mode 100644 index 0000000..14a8abc --- /dev/null +++ b/.github/skills/business/component-config/controls/SKILL.md @@ -0,0 +1,31 @@ +--- +name: api-operations-business-component-config-controls +description: business/component-config 场景下 controls 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **controls** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/getcontrolproperties/` - 获取控制属性 +- `GET /api/v1/getcontrolschema/` - 获取控制架构 +- `GET /api/v1/getruleproperties/` - 获取规则属性 +- `GET /api/v1/getruleschema/` - 获取规则架构 +- `POST /api/v1/setcontrolproperties/` - 设置控制属性 +- `POST /api/v1/setruleproperties/` - 设置规则属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/curves/SKILL.md b/.github/skills/business/component-config/curves/SKILL.md new file mode 100644 index 0000000..a863cbc --- /dev/null +++ b/.github/skills/business/component-config/curves/SKILL.md @@ -0,0 +1,32 @@ +--- +name: api-operations-business-component-config-curves +description: business/component-config 场景下 curves 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **curves** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addcurve/` - 添加曲线 +- `POST /api/v1/deletecurve/` - 删除曲线 +- `GET /api/v1/getcurveproperties/` - 获取曲线属性 +- `GET /api/v1/getcurves/` - 获取所有曲线 +- `GET /api/v1/getcurveschema` - 获取曲线架构 +- `GET /api/v1/iscurve/` - 检查曲线存在性 +- `POST /api/v1/setcurveproperties/` - 设置曲线属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/options/SKILL.md b/.github/skills/business/component-config/options/SKILL.md new file mode 100644 index 0000000..e852892 --- /dev/null +++ b/.github/skills/business/component-config/options/SKILL.md @@ -0,0 +1,37 @@ +--- +name: api-operations-business-component-config-options +description: business/component-config 场景下 options 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **options** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/getenergyproperties/` - 获取能耗选项属性 +- `GET /api/v1/getenergyschema/` - 获取能耗选项架构 +- `GET /api/v1/getoptionproperties/` - 获取选项属性 +- `GET /api/v1/getoptionschema/` - 获取选项架构 +- `GET /api/v1/getpumpenergyproperties/` - 获取泵能耗属性 +- `GET /api/v1/getpumpenergyschema/` - 获取泵能耗选项架构 +- `GET /api/v1/gettimeproperties/` - 获取时间选项属性 +- `GET /api/v1/gettimeschema` - 获取时间选项架构 +- `POST /api/v1/setenergyproperties/` - 设置能耗选项属性 +- `POST /api/v1/setoptionproperties/` - 设置选项属性 +- `GET /api/v1/setpumpenergyproperties/` - 设置泵能耗属性 +- `POST /api/v1/settimeproperties/` - 设置时间选项属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/patterns/SKILL.md b/.github/skills/business/component-config/patterns/SKILL.md new file mode 100644 index 0000000..c879de0 --- /dev/null +++ b/.github/skills/business/component-config/patterns/SKILL.md @@ -0,0 +1,32 @@ +--- +name: api-operations-business-component-config-patterns +description: business/component-config 场景下 patterns 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **patterns** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addpattern/` - 添加模式 +- `POST /api/v1/deletepattern/` - 删除模式 +- `GET /api/v1/getpatternproperties/` - 获取模式属性 +- `GET /api/v1/getpatterns/` - 获取所有模式 +- `GET /api/v1/getpatternschema` - 获取模式架构 +- `GET /api/v1/ispattern/` - 检查模式存在性 +- `POST /api/v1/setpatternproperties/` - 设置模式属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/quality/SKILL.md b/.github/skills/business/component-config/quality/SKILL.md new file mode 100644 index 0000000..1ddaf2d --- /dev/null +++ b/.github/skills/business/component-config/quality/SKILL.md @@ -0,0 +1,50 @@ +--- +name: api-operations-business-component-config-quality +description: business/component-config 场景下 quality 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **quality** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addmixing/` - 添加混合 +- `POST /api/v1/addsource/` - 添加水源 +- `POST /api/v1/deletemixing/` - 删除混合 +- `POST /api/v1/deletesource/` - 删除水源 +- `GET /api/v1/getemitterproperties/` - 获取发射器属性 +- `GET /api/v1/getemitterschema` - 获取发射器架构 +- `GET /api/v1/getmixing/` - 获取混合属性 +- `GET /api/v1/getmixingschema/` - 获取混合架构 +- `GET /api/v1/getpipereaction/` - 获取管道反应属性 +- `GET /api/v1/getpipereactionschema/` - 获取管道反应架构 +- `GET /api/v1/getqualityproperties/` - 获取水质属性 +- `GET /api/v1/getqualityschema/` - 获取水质架构 +- `GET /api/v1/getreaction/` - 获取反应属性 +- `GET /api/v1/getreactionschema/` - 获取反应架构 +- `GET /api/v1/getsource/` - 获取水源属性 +- `GET /api/v1/getsourcechema/` - 获取水源架构 +- `GET /api/v1/gettankreaction/` - 获取水池反应属性 +- `GET /api/v1/gettankreactionschema/` - 获取水池反应架构 +- `POST /api/v1/setemitterproperties/` - 设置发射器属性 +- `POST /api/v1/setmixing/` - 设置混合属性 +- `POST /api/v1/setpipereaction/` - 设置管道反应属性 +- `POST /api/v1/setqualityproperties/` - 设置水质属性 +- `POST /api/v1/setreaction/` - 设置反应属性 +- `POST /api/v1/setsource/` - 设置水源属性 +- `POST /api/v1/settankreaction/` - 设置水池反应属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/visuals/SKILL.md b/.github/skills/business/component-config/visuals/SKILL.md new file mode 100644 index 0000000..e45ae0f --- /dev/null +++ b/.github/skills/business/component-config/visuals/SKILL.md @@ -0,0 +1,40 @@ +--- +name: api-operations-business-component-config-visuals +description: business/component-config 场景下 visuals 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **visuals** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addlabel/` - 添加标签 +- `POST /api/v1/addvertex/` - 添加图形元素 +- `POST /api/v1/deletelabel/` - 删除标签 +- `POST /api/v1/deletevertex/` - 删除图形元素 +- `GET /api/v1/getallvertexlinks/` - 获取所有图形元素链接 +- `GET /api/v1/getallvertices/` - 获取所有图形元素 +- `GET /api/v1/getbackdropproperties/` - 获取背景属性 +- `GET /api/v1/getbackdropschema/` - 获取背景架构 +- `GET /api/v1/getlabelproperties/` - 获取标签属性 +- `GET /api/v1/getlabelschema/` - 获取标签架构 +- `GET /api/v1/getvertexproperties/` - 获取图形元素属性 +- `GET /api/v1/getvertexschema/` - 获取图形元素架构 +- `POST /api/v1/setbackdropproperties/` - 设置背景属性 +- `POST /api/v1/setlabelproperties/` - 设置标签属性 +- `POST /api/v1/setvertexproperties/` - 设置图形元素属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/identity-access/SKILL.md b/.github/skills/business/identity-access/SKILL.md new file mode 100644 index 0000000..e706592 --- /dev/null +++ b/.github/skills/business/identity-access/SKILL.md @@ -0,0 +1,51 @@ +--- +name: api-operations-business-identity-access +description: 认证、授权与用户管理接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **business/identity-access** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`DELETE`, `GET`, `POST`, `PUT` + +# Action Skills + +- `auth`: `auth/SKILL.md` +- `user_management`: `user_management/SKILL.md` +- `users`: `users/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `auth` +- 详情技能:`auth/SKILL.md` +- `POST /api/v1/auth/login` - login +- `POST /api/v1/auth/login/simple` - login_simple +- `GET /api/v1/auth/me` - get_current_user_info +- `POST /api/v1/auth/refresh` - refresh_token +- `POST /api/v1/auth/register` - register + +## Action: `user_management` +- 详情技能:`user_management/SKILL.md` +- `GET /api/v1/users/` - 列出所有用户 +- `DELETE /api/v1/users/{user_id}` - 删除用户 +- `GET /api/v1/users/{user_id}` - 获取用户详情 +- `PUT /api/v1/users/{user_id}` - 更新用户信息 +- `POST /api/v1/users/{user_id}/activate` - 激活用户 +- `POST /api/v1/users/{user_id}/deactivate` - 停用用户 + +## Action: `users` +- 详情技能:`users/SKILL.md` +- `GET /api/v1/getallusers/` - 获取所有用户 +- `GET /api/v1/getuser/` - 获取单个用户 +- `GET /api/v1/getuserschema/` - 获取用户模式 + +# See Also + +- 关联平台治理: `../../platform/governance-observability` +- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/identity-access/auth/SKILL.md b/.github/skills/business/identity-access/auth/SKILL.md new file mode 100644 index 0000000..4a5a391 --- /dev/null +++ b/.github/skills/business/identity-access/auth/SKILL.md @@ -0,0 +1,30 @@ +--- +name: api-operations-business-identity-access-auth +description: business/identity-access 场景下 auth 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **auth** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/auth/login` - login +- `POST /api/v1/auth/login/simple` - login_simple +- `GET /api/v1/auth/me` - get_current_user_info +- `POST /api/v1/auth/refresh` - refresh_token +- `POST /api/v1/auth/register` - register + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联平台治理: `../../platform/governance-observability` +- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/identity-access/user_management/SKILL.md b/.github/skills/business/identity-access/user_management/SKILL.md new file mode 100644 index 0000000..faaf7ab --- /dev/null +++ b/.github/skills/business/identity-access/user_management/SKILL.md @@ -0,0 +1,31 @@ +--- +name: api-operations-business-identity-access-user_management +description: business/identity-access 场景下 user_management 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **user_management** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`DELETE`, `GET`, `POST`, `PUT` + +# 操作列表 + +- `GET /api/v1/users/` - 列出所有用户 +- `DELETE /api/v1/users/{user_id}` - 删除用户 +- `GET /api/v1/users/{user_id}` - 获取用户详情 +- `PUT /api/v1/users/{user_id}` - 更新用户信息 +- `POST /api/v1/users/{user_id}/activate` - 激活用户 +- `POST /api/v1/users/{user_id}/deactivate` - 停用用户 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联平台治理: `../../platform/governance-observability` +- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/identity-access/users/SKILL.md b/.github/skills/business/identity-access/users/SKILL.md new file mode 100644 index 0000000..464a5ba --- /dev/null +++ b/.github/skills/business/identity-access/users/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-business-identity-access-users +description: business/identity-access 场景下 users 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **users** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/getallusers/` - 获取所有用户 +- `GET /api/v1/getuser/` - 获取单个用户 +- `GET /api/v1/getuserschema/` - 获取用户模式 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联平台治理: `../../platform/governance-observability` +- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/network-assets/SKILL.md b/.github/skills/business/network-assets/SKILL.md new file mode 100644 index 0000000..ec3d600 --- /dev/null +++ b/.github/skills/business/network-assets/SKILL.md @@ -0,0 +1,260 @@ +--- +name: api-operations-business-network-assets +description: 网络资产(节点/管段/设备)与空间拓扑接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **business/network-assets** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# Action Skills + +- `demands`: `demands/SKILL.md` +- `general`: `general/SKILL.md` +- `geometry`: `geometry/SKILL.md` +- `junctions`: `junctions/SKILL.md` +- `pipes`: `pipes/SKILL.md` +- `pumps`: `pumps/SKILL.md` +- `regions`: `regions/SKILL.md` +- `reservoirs`: `reservoirs/SKILL.md` +- `tags`: `tags/SKILL.md` +- `tanks`: `tanks/SKILL.md` +- `valves`: `valves/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `demands` +- 详情技能:`demands/SKILL.md` +- `GET /api/v1/calculatedemandtonetwork/` - 计算需水量到整网分配 +- `GET /api/v1/calculatedemandtonodes/` - 计算需水量到节点分配 +- `GET /api/v1/calculatedemandtoregion/` - 计算需水量到区域分配 +- `GET /api/v1/getdemandproperties/` - 获取需水量属性 +- `GET /api/v1/getdemandschema` - 获取需水量属性架构 +- `POST /api/v1/setdemandproperties/` - 设置需水量属性 + +## Action: `general` +- 详情技能:`general/SKILL.md` +- `POST /api/v1/deletelink/` - 删除管线 +- `POST /api/v1/deletenode/` - 删除节点 +- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA点属性 +- `GET /api/v1/getelementproperties/` - 获取元素属性 +- `GET /api/v1/getelementpropertieswithtype/` - 获取指定类型元素属性 +- `GET /api/v1/getelementtype/` - 获取元素类型 +- `GET /api/v1/getelementtypevalue/` - 获取元素类型值 +- `GET /api/v1/getlinkproperties/` - 获取管线属性 +- `GET /api/v1/getlinks/` - 获取所有管线 +- `GET /api/v1/getlinktype/` - 获取管线类型 +- `GET /api/v1/getnodelinks/` - 获取节点的关联管线 +- `GET /api/v1/getnodeproperties/` - 获取节点属性 +- `GET /api/v1/getnodes/` - 获取所有节点 +- `GET /api/v1/getnodetype/` - 获取节点类型 +- `GET /api/v1/getscadaproperties/` - 获取SCADA点属性 +- `GET /api/v1/getstatus/` - 获取管线状态 +- `GET /api/v1/getstatusschema` - 获取状态属性架构 +- `GET /api/v1/gettitle/` - 获取水网标题属性 +- `GET /api/v1/gettitleschema/` - 获取标题属性架构 +- `GET /api/v1/isjunction/` - 检查是否为接点 +- `GET /api/v1/islink/` - 检查管线有效性 +- `GET /api/v1/isnode/` - 检查节点有效性 +- `GET /api/v1/ispipe/` - 检查是否为管道 +- `GET /api/v1/ispump/` - 检查是否为泵 +- `GET /api/v1/isreservoir/` - 检查是否为水源 +- `GET /api/v1/istank/` - 检查是否为蓄水池 +- `GET /api/v1/isvalve/` - 检查是否为阀门 +- `POST /api/v1/setstatus/` - 设置管线状态 +- `GET /api/v1/settitle/` - 设置水网标题属性 + +## Action: `geometry` +- 详情技能:`geometry/SKILL.md` +- `GET /api/v1/getmajornodecoords/` - 获取主要节点坐标 +- `GET /api/v1/getmajorpipenodes/` - 获取主要管道节点 +- `GET /api/v1/getnetworkgeometries/` - 获取完整网络几何信息 +- `GET /api/v1/getnetworkinextent/` - 获取范围内的网络元素 +- `GET /api/v1/getnetworklinknodes/` - 获取网络管线节点 +- `GET /api/v1/getnodecoord/` - 获取节点坐标 + +## Action: `junctions` +- 详情技能:`junctions/SKILL.md` +- `POST /api/v1/addjunction/` - 添加节点 +- `POST /api/v1/deletejunction/` - 删除节点 +- `GET /api/v1/getalljunctionproperties/` - 获取所有节点属性 +- `GET /api/v1/getjunctioncoord/` - 获取节点坐标 +- `GET /api/v1/getjunctiondemand/` - 获取节点需水量 +- `GET /api/v1/getjunctionelevation/` - 获取节点标高 +- `GET /api/v1/getjunctionpattern/` - 获取节点需水模式 +- `GET /api/v1/getjunctionproperties/` - 获取节点属性 +- `GET /api/v1/getjunctionschema` - 获取节点架构 +- `GET /api/v1/getjunctionx/` - 获取节点 X 坐标 +- `GET /api/v1/getjunctiony/` - 获取节点 Y 坐标 +- `POST /api/v1/setjunctioncoord/` - 设置节点坐标 +- `POST /api/v1/setjunctiondemand/` - 设置节点需水量 +- `POST /api/v1/setjunctionelevation/` - 设置节点标高 +- `POST /api/v1/setjunctionpattern/` - 设置节点需水模式 +- `POST /api/v1/setjunctionproperties/` - 批量设置节点属性 +- `POST /api/v1/setjunctionx/` - 设置节点 X 坐标 +- `POST /api/v1/setjunctiony/` - 设置节点 Y 坐标 + +## Action: `pipes` +- 详情技能:`pipes/SKILL.md` +- `POST /api/v1/addpipe/` - 添加管道 +- `POST /api/v1/deletepipe/` - 删除管道 +- `GET /api/v1/getallpipeproperties/` - 获取所有管道属性 +- `GET /api/v1/getpipediameter/` - 获取管道管径 +- `GET /api/v1/getpipelength/` - 获取管道长度 +- `GET /api/v1/getpipeminorloss/` - 获取管道局部阻力系数 +- `GET /api/v1/getpipenode1/` - 获取管道起始节点 +- `GET /api/v1/getpipenode2/` - 获取管道终止节点 +- `GET /api/v1/getpipeproperties/` - 获取管道属性 +- `GET /api/v1/getpiperoughness/` - 获取管道粗糙度 +- `GET /api/v1/getpipeschema` - 获取管道模式 +- `GET /api/v1/getpipestatus/` - 获取管道状态 +- `POST /api/v1/setpipediameter/` - 设置管道管径 +- `POST /api/v1/setpipelength/` - 设置管道长度 +- `POST /api/v1/setpipeminorloss/` - 设置管道局部阻力系数 +- `POST /api/v1/setpipenode1/` - 设置管道起始节点 +- `POST /api/v1/setpipenode2/` - 设置管道终止节点 +- `POST /api/v1/setpipeproperties/` - 设置管道属性 +- `POST /api/v1/setpiperoughness/` - 设置管道粗糙度 +- `POST /api/v1/setpipestatus/` - 设置管道状态 + +## Action: `pumps` +- 详情技能:`pumps/SKILL.md` +- `POST /api/v1/addpump/` - 添加水泵 +- `POST /api/v1/deletepump/` - 删除水泵 +- `GET /api/v1/getallpumpproperties/` - 获取所有水泵属性 +- `GET /api/v1/getpumpnode1/` - 获取水泵起始节点 +- `GET /api/v1/getpumpnode2/` - 获取水泵终止节点 +- `GET /api/v1/getpumpproperties/` - 获取水泵属性 +- `GET /api/v1/getpumpschema` - 获取水泵模式 +- `POST /api/v1/setpumpnode1/` - 设置水泵起始节点 +- `POST /api/v1/setpumpnode2/` - 设置水泵终止节点 +- `POST /api/v1/setpumpproperties/` - 设置水泵属性 + +## Action: `regions` +- 详情技能:`regions/SKILL.md` +- `POST /api/v1/adddistrictmeteringarea/` - 添加新DMA +- `POST /api/v1/addregion/` - 添加新区域 +- `POST /api/v1/addservicearea/` - 添加新服务区 +- `POST /api/v1/addvirtualdistrict/` - 添加新虚拟分区 +- `GET /api/v1/calculatedistrictmeteringarea/` - 计算DMA分区 +- `GET /api/v1/calculatedistrictmeteringareafornetwork/` - 计算整网DMA分区 +- `GET /api/v1/calculatedistrictmeteringareafornodes/` - 计算节点DMA分区 +- `GET /api/v1/calculatedistrictmeteringareaforregion/` - 计算区域内DMA分区 +- `GET /api/v1/calculateregion/` - 计算区域 +- `GET /api/v1/calculateservicearea/` - 计算服务区 +- `GET /api/v1/calculatevirtualdistrict/` - 计算虚拟分区 +- `POST /api/v1/deletedistrictmeteringarea/` - 删除DMA +- `POST /api/v1/deleteregion/` - 删除区域 +- `POST /api/v1/deleteservicearea/` - 删除服务区 +- `POST /api/v1/deletevirtualdistrict/` - 删除虚拟分区 +- `POST /api/v1/generatedistrictmeteringarea/` - 生成DMA分区 +- `POST /api/v1/generateregion/` - 生成区域分区 +- `POST /api/v1/generateservicearea/` - 生成服务区分区 +- `POST /api/v1/generatesubdistrictmeteringarea/` - 生成DMA子分区 +- `POST /api/v1/generatevirtualdistrict/` - 生成虚拟分区 +- `GET /api/v1/getalldistrictmeteringareaids/` - 获取所有DMA ID +- `GET /api/v1/getalldistrictmeteringareas/` - 获取所有DMA +- `GET /api/v1/getallregions/` - 获取所有区域 +- `GET /api/v1/getallserviceareas/` - 获取所有服务区 +- `GET /api/v1/getallvirtualdistrict/` - 获取所有虚拟分区 +- `GET /api/v1/getdistrictmeteringarea/` - 获取DMA信息 +- `GET /api/v1/getdistrictmeteringareaschema/` - 获取DMA属性架构 +- `GET /api/v1/getregion/` - 获取区域信息 +- `GET /api/v1/getregionschema/` - 获取区域属性架构 +- `GET /api/v1/getservicearea/` - 获取服务区信息 +- `GET /api/v1/getserviceareaschema/` - 获取服务区属性架构 +- `GET /api/v1/getvirtualdistrict/` - 获取虚拟分区信息 +- `GET /api/v1/getvirtualdistrictschema/` - 获取虚拟分区属性架构 +- `POST /api/v1/setdistrictmeteringarea/` - 设置DMA属性 +- `POST /api/v1/setregion/` - 设置区域属性 +- `POST /api/v1/setservicearea/` - 设置服务区属性 +- `POST /api/v1/setvirtualdistrict/` - 设置虚拟分区属性 + +## Action: `reservoirs` +- 详情技能:`reservoirs/SKILL.md` +- `POST /api/v1/addreservoir/` - 添加水库 +- `POST /api/v1/deletereservoir/` - 删除水库 +- `GET /api/v1/getallreservoirproperties/` - 获取所有水库属性 +- `GET /api/v1/getreservoircoord/` - 获取水库坐标 +- `GET /api/v1/getreservoirhead/` - 获取水库水头 +- `GET /api/v1/getreservoirpattern/` - 获取水库模式 +- `GET /api/v1/getreservoirproperties/` - 获取水库属性 +- `GET /api/v1/getreservoirschema` - 获取水库模式 +- `GET /api/v1/getreservoirx/` - 获取水库X坐标 +- `GET /api/v1/getreservoiry/` - 获取水库Y坐标 +- `POST /api/v1/setreservoircoord/` - 设置水库坐标 +- `POST /api/v1/setreservoirhead/` - 设置水库水头 +- `POST /api/v1/setreservoirpattern/` - 设置水库模式 +- `POST /api/v1/setreservoirproperties/` - 设置水库属性 +- `POST /api/v1/setreservoirx/` - 设置水库X坐标 +- `POST /api/v1/setreservoiry/` - 设置水库Y坐标 + +## Action: `tags` +- 详情技能:`tags/SKILL.md` +- `GET /api/v1/gettag/` - 获取标签信息 +- `GET /api/v1/gettags/` - 获取所有标签 +- `GET /api/v1/gettagschema/` - 获取标签属性架构 +- `POST /api/v1/settag/` - 设置标签 + +## Action: `tanks` +- 详情技能:`tanks/SKILL.md` +- `POST /api/v1/addtank/` - 新增水箱 +- `POST /api/v1/deletetank/` - 删除水箱 +- `GET /api/v1/getalltankproperties/` - 获取所有水箱属性 +- `GET /api/v1/gettankcoord/` - 获取水箱坐标 +- `GET /api/v1/gettankdiameter/` - 获取水箱直径 +- `GET /api/v1/gettankelevation/` - 获取水箱标高 +- `GET /api/v1/gettankinitlevel/` - 获取水箱初始水位 +- `GET /api/v1/gettankmaxlevel/` - 获取水箱最大水位 +- `GET /api/v1/gettankminlevel/` - 获取水箱最小水位 +- `GET /api/v1/gettankminvol/` - 获取水箱最小体积 +- `GET /api/v1/gettankoverflow/` - 获取水箱溢流口 +- `GET /api/v1/gettankproperties/` - 获取水箱属性 +- `GET /api/v1/gettankschema` - 获取水箱模式 +- `GET /api/v1/gettankvolcurve/` - 获取水箱容积曲线 +- `GET /api/v1/gettankx/` - 获取水箱X坐标 +- `GET /api/v1/gettanky/` - 获取水箱Y坐标 +- `POST /api/v1/settankcoord/` - 设置水箱坐标 +- `POST /api/v1/settankdiameter/` - 设置水箱直径 +- `POST /api/v1/settankelevation/` - 设置水箱标高 +- `POST /api/v1/settankinitlevel/` - 设置水箱初始水位 +- `POST /api/v1/settankmaxlevel/` - 设置水箱最大水位 +- `POST /api/v1/settankminlevel/` - 设置水箱最小水位 +- `POST /api/v1/settankminvol/` - 设置水箱最小体积 +- `POST /api/v1/settankoverflow/` - 设置水箱溢流口 +- `POST /api/v1/settankproperties/` - 设置水箱属性 +- `POST /api/v1/settankvolcurve/` - 设置水箱容积曲线 +- `POST /api/v1/settankx/` - 设置水箱X坐标 +- `POST /api/v1/settanky/` - 设置水箱Y坐标 + +## Action: `valves` +- 详情技能:`valves/SKILL.md` +- `POST /api/v1/addvalve/` - 添加阀门 +- `POST /api/v1/deletevalve/` - 删除阀门 +- `GET /api/v1/getallvalveproperties/` - 获取所有阀门属性 +- `GET /api/v1/getvalvediameter/` - 获取阀门直径 +- `GET /api/v1/getvalveminorloss/` - 获取阀门损失系数 +- `GET /api/v1/getvalvenode1/` - 获取阀门起点节点 +- `GET /api/v1/getvalvenode2/` - 获取阀门终点节点 +- `GET /api/v1/getvalveproperties/` - 获取阀门所有属性 +- `GET /api/v1/getvalveschema` - 获取阀门架构 +- `GET /api/v1/getvalvesetting/` - 获取阀门开度 +- `GET /api/v1/getvalvetype/` - 获取阀门类型 +- `POST /api/v1/setvalvenode1/` - 设置阀门起点节点 +- `POST /api/v1/setvalvenode2/` - 设置阀门终点节点 +- `POST /api/v1/setvalvenodediameter/` - 设置阀门直径 +- `POST /api/v1/setvalveproperties/` - 批量设置阀门属性 +- `POST /api/v1/setvalvesetting/` - 设置阀门开度 +- `POST /api/v1/setvalvetype/` - 设置阀门类型 + +# See Also + +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/demands/SKILL.md b/.github/skills/business/network-assets/demands/SKILL.md new file mode 100644 index 0000000..b982089 --- /dev/null +++ b/.github/skills/business/network-assets/demands/SKILL.md @@ -0,0 +1,31 @@ +--- +name: api-operations-business-network-assets-demands +description: business/network-assets 场景下 demands 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **demands** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/calculatedemandtonetwork/` - 计算需水量到整网分配 +- `GET /api/v1/calculatedemandtonodes/` - 计算需水量到节点分配 +- `GET /api/v1/calculatedemandtoregion/` - 计算需水量到区域分配 +- `GET /api/v1/getdemandproperties/` - 获取需水量属性 +- `GET /api/v1/getdemandschema` - 获取需水量属性架构 +- `POST /api/v1/setdemandproperties/` - 设置需水量属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/general/SKILL.md b/.github/skills/business/network-assets/general/SKILL.md new file mode 100644 index 0000000..fbb9158 --- /dev/null +++ b/.github/skills/business/network-assets/general/SKILL.md @@ -0,0 +1,54 @@ +--- +name: api-operations-business-network-assets-general +description: business/network-assets 场景下 general 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **general** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/deletelink/` - 删除管线 +- `POST /api/v1/deletenode/` - 删除节点 +- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA点属性 +- `GET /api/v1/getelementproperties/` - 获取元素属性 +- `GET /api/v1/getelementpropertieswithtype/` - 获取指定类型元素属性 +- `GET /api/v1/getelementtype/` - 获取元素类型 +- `GET /api/v1/getelementtypevalue/` - 获取元素类型值 +- `GET /api/v1/getlinkproperties/` - 获取管线属性 +- `GET /api/v1/getlinks/` - 获取所有管线 +- `GET /api/v1/getlinktype/` - 获取管线类型 +- `GET /api/v1/getnodelinks/` - 获取节点的关联管线 +- `GET /api/v1/getnodeproperties/` - 获取节点属性 +- `GET /api/v1/getnodes/` - 获取所有节点 +- `GET /api/v1/getnodetype/` - 获取节点类型 +- `GET /api/v1/getscadaproperties/` - 获取SCADA点属性 +- `GET /api/v1/getstatus/` - 获取管线状态 +- `GET /api/v1/getstatusschema` - 获取状态属性架构 +- `GET /api/v1/gettitle/` - 获取水网标题属性 +- `GET /api/v1/gettitleschema/` - 获取标题属性架构 +- `GET /api/v1/isjunction/` - 检查是否为接点 +- `GET /api/v1/islink/` - 检查管线有效性 +- `GET /api/v1/isnode/` - 检查节点有效性 +- `GET /api/v1/ispipe/` - 检查是否为管道 +- `GET /api/v1/ispump/` - 检查是否为泵 +- `GET /api/v1/isreservoir/` - 检查是否为水源 +- `GET /api/v1/istank/` - 检查是否为蓄水池 +- `GET /api/v1/isvalve/` - 检查是否为阀门 +- `POST /api/v1/setstatus/` - 设置管线状态 +- `GET /api/v1/settitle/` - 设置水网标题属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/geometry/SKILL.md b/.github/skills/business/network-assets/geometry/SKILL.md new file mode 100644 index 0000000..4557179 --- /dev/null +++ b/.github/skills/business/network-assets/geometry/SKILL.md @@ -0,0 +1,31 @@ +--- +name: api-operations-business-network-assets-geometry +description: business/network-assets 场景下 geometry 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **geometry** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/getmajornodecoords/` - 获取主要节点坐标 +- `GET /api/v1/getmajorpipenodes/` - 获取主要管道节点 +- `GET /api/v1/getnetworkgeometries/` - 获取完整网络几何信息 +- `GET /api/v1/getnetworkinextent/` - 获取范围内的网络元素 +- `GET /api/v1/getnetworklinknodes/` - 获取网络管线节点 +- `GET /api/v1/getnodecoord/` - 获取节点坐标 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/junctions/SKILL.md b/.github/skills/business/network-assets/junctions/SKILL.md new file mode 100644 index 0000000..6d3d480 --- /dev/null +++ b/.github/skills/business/network-assets/junctions/SKILL.md @@ -0,0 +1,43 @@ +--- +name: api-operations-business-network-assets-junctions +description: business/network-assets 场景下 junctions 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **junctions** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addjunction/` - 添加节点 +- `POST /api/v1/deletejunction/` - 删除节点 +- `GET /api/v1/getalljunctionproperties/` - 获取所有节点属性 +- `GET /api/v1/getjunctioncoord/` - 获取节点坐标 +- `GET /api/v1/getjunctiondemand/` - 获取节点需水量 +- `GET /api/v1/getjunctionelevation/` - 获取节点标高 +- `GET /api/v1/getjunctionpattern/` - 获取节点需水模式 +- `GET /api/v1/getjunctionproperties/` - 获取节点属性 +- `GET /api/v1/getjunctionschema` - 获取节点架构 +- `GET /api/v1/getjunctionx/` - 获取节点 X 坐标 +- `GET /api/v1/getjunctiony/` - 获取节点 Y 坐标 +- `POST /api/v1/setjunctioncoord/` - 设置节点坐标 +- `POST /api/v1/setjunctiondemand/` - 设置节点需水量 +- `POST /api/v1/setjunctionelevation/` - 设置节点标高 +- `POST /api/v1/setjunctionpattern/` - 设置节点需水模式 +- `POST /api/v1/setjunctionproperties/` - 批量设置节点属性 +- `POST /api/v1/setjunctionx/` - 设置节点 X 坐标 +- `POST /api/v1/setjunctiony/` - 设置节点 Y 坐标 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/pipes/SKILL.md b/.github/skills/business/network-assets/pipes/SKILL.md new file mode 100644 index 0000000..91e57d0 --- /dev/null +++ b/.github/skills/business/network-assets/pipes/SKILL.md @@ -0,0 +1,45 @@ +--- +name: api-operations-business-network-assets-pipes +description: business/network-assets 场景下 pipes 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **pipes** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addpipe/` - 添加管道 +- `POST /api/v1/deletepipe/` - 删除管道 +- `GET /api/v1/getallpipeproperties/` - 获取所有管道属性 +- `GET /api/v1/getpipediameter/` - 获取管道管径 +- `GET /api/v1/getpipelength/` - 获取管道长度 +- `GET /api/v1/getpipeminorloss/` - 获取管道局部阻力系数 +- `GET /api/v1/getpipenode1/` - 获取管道起始节点 +- `GET /api/v1/getpipenode2/` - 获取管道终止节点 +- `GET /api/v1/getpipeproperties/` - 获取管道属性 +- `GET /api/v1/getpiperoughness/` - 获取管道粗糙度 +- `GET /api/v1/getpipeschema` - 获取管道模式 +- `GET /api/v1/getpipestatus/` - 获取管道状态 +- `POST /api/v1/setpipediameter/` - 设置管道管径 +- `POST /api/v1/setpipelength/` - 设置管道长度 +- `POST /api/v1/setpipeminorloss/` - 设置管道局部阻力系数 +- `POST /api/v1/setpipenode1/` - 设置管道起始节点 +- `POST /api/v1/setpipenode2/` - 设置管道终止节点 +- `POST /api/v1/setpipeproperties/` - 设置管道属性 +- `POST /api/v1/setpiperoughness/` - 设置管道粗糙度 +- `POST /api/v1/setpipestatus/` - 设置管道状态 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/pumps/SKILL.md b/.github/skills/business/network-assets/pumps/SKILL.md new file mode 100644 index 0000000..906b1fe --- /dev/null +++ b/.github/skills/business/network-assets/pumps/SKILL.md @@ -0,0 +1,35 @@ +--- +name: api-operations-business-network-assets-pumps +description: business/network-assets 场景下 pumps 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **pumps** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addpump/` - 添加水泵 +- `POST /api/v1/deletepump/` - 删除水泵 +- `GET /api/v1/getallpumpproperties/` - 获取所有水泵属性 +- `GET /api/v1/getpumpnode1/` - 获取水泵起始节点 +- `GET /api/v1/getpumpnode2/` - 获取水泵终止节点 +- `GET /api/v1/getpumpproperties/` - 获取水泵属性 +- `GET /api/v1/getpumpschema` - 获取水泵模式 +- `POST /api/v1/setpumpnode1/` - 设置水泵起始节点 +- `POST /api/v1/setpumpnode2/` - 设置水泵终止节点 +- `POST /api/v1/setpumpproperties/` - 设置水泵属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/regions/SKILL.md b/.github/skills/business/network-assets/regions/SKILL.md new file mode 100644 index 0000000..3775233 --- /dev/null +++ b/.github/skills/business/network-assets/regions/SKILL.md @@ -0,0 +1,62 @@ +--- +name: api-operations-business-network-assets-regions +description: business/network-assets 场景下 regions 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **regions** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/adddistrictmeteringarea/` - 添加新DMA +- `POST /api/v1/addregion/` - 添加新区域 +- `POST /api/v1/addservicearea/` - 添加新服务区 +- `POST /api/v1/addvirtualdistrict/` - 添加新虚拟分区 +- `GET /api/v1/calculatedistrictmeteringarea/` - 计算DMA分区 +- `GET /api/v1/calculatedistrictmeteringareafornetwork/` - 计算整网DMA分区 +- `GET /api/v1/calculatedistrictmeteringareafornodes/` - 计算节点DMA分区 +- `GET /api/v1/calculatedistrictmeteringareaforregion/` - 计算区域内DMA分区 +- `GET /api/v1/calculateregion/` - 计算区域 +- `GET /api/v1/calculateservicearea/` - 计算服务区 +- `GET /api/v1/calculatevirtualdistrict/` - 计算虚拟分区 +- `POST /api/v1/deletedistrictmeteringarea/` - 删除DMA +- `POST /api/v1/deleteregion/` - 删除区域 +- `POST /api/v1/deleteservicearea/` - 删除服务区 +- `POST /api/v1/deletevirtualdistrict/` - 删除虚拟分区 +- `POST /api/v1/generatedistrictmeteringarea/` - 生成DMA分区 +- `POST /api/v1/generateregion/` - 生成区域分区 +- `POST /api/v1/generateservicearea/` - 生成服务区分区 +- `POST /api/v1/generatesubdistrictmeteringarea/` - 生成DMA子分区 +- `POST /api/v1/generatevirtualdistrict/` - 生成虚拟分区 +- `GET /api/v1/getalldistrictmeteringareaids/` - 获取所有DMA ID +- `GET /api/v1/getalldistrictmeteringareas/` - 获取所有DMA +- `GET /api/v1/getallregions/` - 获取所有区域 +- `GET /api/v1/getallserviceareas/` - 获取所有服务区 +- `GET /api/v1/getallvirtualdistrict/` - 获取所有虚拟分区 +- `GET /api/v1/getdistrictmeteringarea/` - 获取DMA信息 +- `GET /api/v1/getdistrictmeteringareaschema/` - 获取DMA属性架构 +- `GET /api/v1/getregion/` - 获取区域信息 +- `GET /api/v1/getregionschema/` - 获取区域属性架构 +- `GET /api/v1/getservicearea/` - 获取服务区信息 +- `GET /api/v1/getserviceareaschema/` - 获取服务区属性架构 +- `GET /api/v1/getvirtualdistrict/` - 获取虚拟分区信息 +- `GET /api/v1/getvirtualdistrictschema/` - 获取虚拟分区属性架构 +- `POST /api/v1/setdistrictmeteringarea/` - 设置DMA属性 +- `POST /api/v1/setregion/` - 设置区域属性 +- `POST /api/v1/setservicearea/` - 设置服务区属性 +- `POST /api/v1/setvirtualdistrict/` - 设置虚拟分区属性 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/reservoirs/SKILL.md b/.github/skills/business/network-assets/reservoirs/SKILL.md new file mode 100644 index 0000000..98903db --- /dev/null +++ b/.github/skills/business/network-assets/reservoirs/SKILL.md @@ -0,0 +1,41 @@ +--- +name: api-operations-business-network-assets-reservoirs +description: business/network-assets 场景下 reservoirs 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **reservoirs** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addreservoir/` - 添加水库 +- `POST /api/v1/deletereservoir/` - 删除水库 +- `GET /api/v1/getallreservoirproperties/` - 获取所有水库属性 +- `GET /api/v1/getreservoircoord/` - 获取水库坐标 +- `GET /api/v1/getreservoirhead/` - 获取水库水头 +- `GET /api/v1/getreservoirpattern/` - 获取水库模式 +- `GET /api/v1/getreservoirproperties/` - 获取水库属性 +- `GET /api/v1/getreservoirschema` - 获取水库模式 +- `GET /api/v1/getreservoirx/` - 获取水库X坐标 +- `GET /api/v1/getreservoiry/` - 获取水库Y坐标 +- `POST /api/v1/setreservoircoord/` - 设置水库坐标 +- `POST /api/v1/setreservoirhead/` - 设置水库水头 +- `POST /api/v1/setreservoirpattern/` - 设置水库模式 +- `POST /api/v1/setreservoirproperties/` - 设置水库属性 +- `POST /api/v1/setreservoirx/` - 设置水库X坐标 +- `POST /api/v1/setreservoiry/` - 设置水库Y坐标 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/tags/SKILL.md b/.github/skills/business/network-assets/tags/SKILL.md new file mode 100644 index 0000000..8380506 --- /dev/null +++ b/.github/skills/business/network-assets/tags/SKILL.md @@ -0,0 +1,29 @@ +--- +name: api-operations-business-network-assets-tags +description: business/network-assets 场景下 tags 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **tags** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/gettag/` - 获取标签信息 +- `GET /api/v1/gettags/` - 获取所有标签 +- `GET /api/v1/gettagschema/` - 获取标签属性架构 +- `POST /api/v1/settag/` - 设置标签 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/tanks/SKILL.md b/.github/skills/business/network-assets/tanks/SKILL.md new file mode 100644 index 0000000..94d4b6e --- /dev/null +++ b/.github/skills/business/network-assets/tanks/SKILL.md @@ -0,0 +1,53 @@ +--- +name: api-operations-business-network-assets-tanks +description: business/network-assets 场景下 tanks 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **tanks** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addtank/` - 新增水箱 +- `POST /api/v1/deletetank/` - 删除水箱 +- `GET /api/v1/getalltankproperties/` - 获取所有水箱属性 +- `GET /api/v1/gettankcoord/` - 获取水箱坐标 +- `GET /api/v1/gettankdiameter/` - 获取水箱直径 +- `GET /api/v1/gettankelevation/` - 获取水箱标高 +- `GET /api/v1/gettankinitlevel/` - 获取水箱初始水位 +- `GET /api/v1/gettankmaxlevel/` - 获取水箱最大水位 +- `GET /api/v1/gettankminlevel/` - 获取水箱最小水位 +- `GET /api/v1/gettankminvol/` - 获取水箱最小体积 +- `GET /api/v1/gettankoverflow/` - 获取水箱溢流口 +- `GET /api/v1/gettankproperties/` - 获取水箱属性 +- `GET /api/v1/gettankschema` - 获取水箱模式 +- `GET /api/v1/gettankvolcurve/` - 获取水箱容积曲线 +- `GET /api/v1/gettankx/` - 获取水箱X坐标 +- `GET /api/v1/gettanky/` - 获取水箱Y坐标 +- `POST /api/v1/settankcoord/` - 设置水箱坐标 +- `POST /api/v1/settankdiameter/` - 设置水箱直径 +- `POST /api/v1/settankelevation/` - 设置水箱标高 +- `POST /api/v1/settankinitlevel/` - 设置水箱初始水位 +- `POST /api/v1/settankmaxlevel/` - 设置水箱最大水位 +- `POST /api/v1/settankminlevel/` - 设置水箱最小水位 +- `POST /api/v1/settankminvol/` - 设置水箱最小体积 +- `POST /api/v1/settankoverflow/` - 设置水箱溢流口 +- `POST /api/v1/settankproperties/` - 设置水箱属性 +- `POST /api/v1/settankvolcurve/` - 设置水箱容积曲线 +- `POST /api/v1/settankx/` - 设置水箱X坐标 +- `POST /api/v1/settanky/` - 设置水箱Y坐标 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/valves/SKILL.md b/.github/skills/business/network-assets/valves/SKILL.md new file mode 100644 index 0000000..abce12d --- /dev/null +++ b/.github/skills/business/network-assets/valves/SKILL.md @@ -0,0 +1,42 @@ +--- +name: api-operations-business-network-assets-valves +description: business/network-assets 场景下 valves 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **valves** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/addvalve/` - 添加阀门 +- `POST /api/v1/deletevalve/` - 删除阀门 +- `GET /api/v1/getallvalveproperties/` - 获取所有阀门属性 +- `GET /api/v1/getvalvediameter/` - 获取阀门直径 +- `GET /api/v1/getvalveminorloss/` - 获取阀门损失系数 +- `GET /api/v1/getvalvenode1/` - 获取阀门起点节点 +- `GET /api/v1/getvalvenode2/` - 获取阀门终点节点 +- `GET /api/v1/getvalveproperties/` - 获取阀门所有属性 +- `GET /api/v1/getvalveschema` - 获取阀门架构 +- `GET /api/v1/getvalvesetting/` - 获取阀门开度 +- `GET /api/v1/getvalvetype/` - 获取阀门类型 +- `POST /api/v1/setvalvenode1/` - 设置阀门起点节点 +- `POST /api/v1/setvalvenode2/` - 设置阀门终点节点 +- `POST /api/v1/setvalvenodediameter/` - 设置阀门直径 +- `POST /api/v1/setvalveproperties/` - 批量设置阀门属性 +- `POST /api/v1/setvalvesetting/` - 设置阀门开度 +- `POST /api/v1/setvalvetype/` - 设置阀门类型 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联组件配置: `../component-config` +- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/project-workspace/SKILL.md b/.github/skills/business/project-workspace/SKILL.md new file mode 100644 index 0000000..4b35d16 --- /dev/null +++ b/.github/skills/business/project-workspace/SKILL.md @@ -0,0 +1,113 @@ +--- +name: api-operations-business-project-workspace +description: 项目、方案、快照和项目数据接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **business/project-workspace** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# Action Skills + +- `extension`: `extension/SKILL.md` +- `misc`: `misc/SKILL.md` +- `project`: `project/SKILL.md` +- `project_data`: `project_data/SKILL.md` +- `schemes`: `schemes/SKILL.md` +- `snapshots`: `snapshots/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `extension` +- 详情技能:`extension/SKILL.md` +- `GET /api/v1/getallextensiondata/` - 获取所有扩展数据 +- `GET /api/v1/getallextensiondatakeys/` - 获取所有扩展数据键 +- `GET /api/v1/getextensiondata/` - 获取指定扩展数据 +- `POST /api/v1/setextensiondata/` - 设置扩展数据 + +## Action: `misc` +- 详情技能:`misc/SKILL.md` +- `GET /api/v1/getallburstlocateresults/` - 获取所有爆管定位结果 +- `GET /api/v1/getallsensorplacements/` - 获取所有传感器位置 +- `GET /api/v1/getjson/` - 获取JSON示例 +- `GET /api/v1/getrealtimedata/` - 获取实时数据 +- `GET /api/v1/getsimulationresult/` - 获取模拟结果 +- `POST /api/v1/test_dict/` - 测试字典处理 + +## Action: `project` +- 详情技能:`project/SKILL.md` +- `POST /api/v1/closeproject/` - 关闭项目 +- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 +- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 +- `POST /api/v1/copyproject/` - 复制项目 +- `POST /api/v1/createproject/` - 创建新项目 +- `POST /api/v1/deleteproject/` - 删除项目 +- `GET /api/v1/downloadinp/` - 下载 INP 文件 +- `GET /api/v1/downloadinp/` - 下载 INP 文件 +- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 +- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 +- `GET /api/v1/exportinp/` - 导出项目为 ChangeSet +- `GET /api/v1/haveproject/` - 检查项目是否存在 +- `POST /api/v1/importinp/` - 导入 INP 文件内容 +- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 +- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 +- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 +- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 +- `GET /api/v1/isprojectopen/` - 检查项目是否已打开 +- `GET /api/v1/listprojects/` - 获取项目列表 +- `POST /api/v1/lockproject/` - 锁定项目 +- `POST /api/v1/lockproject/` - 锁定项目 +- `POST /api/v1/openproject/` - 打开项目 +- `GET /api/v1/project_info/` - 获取项目信息 +- `POST /api/v1/readinp/` - 读取 INP 文件到项目 +- `POST /api/v1/readinp/` - 读取 INP 文件到项目 +- `POST /api/v1/unlockproject/` - 解锁项目 +- `POST /api/v1/unlockproject/` - 解锁项目 +- `POST /api/v1/uploadinp/` - 上传 INP 文件 +- `POST /api/v1/uploadinp/` - 上传 INP 文件 + +## Action: `project_data` +- 详情技能:`project_data/SKILL.md` +- `GET /api/v1/burst-locate-result` - 获取爆管定位结果 +- `GET /api/v1/burst-locate-result/{burst_incident}` - 按事件查询爆管定位结果 +- `GET /api/v1/scada-info` - 获取SCADA信息 +- `GET /api/v1/scheme-list` - 获取方案列表 + +## Action: `schemes` +- 详情技能:`schemes/SKILL.md` +- `GET /api/v1/getallschemes/` - 获取所有方案 +- `GET /api/v1/getscheme/` - 获取单个方案 +- `GET /api/v1/getschemeschema/` - 获取方案模式 + +## Action: `snapshots` +- 详情技能:`snapshots/SKILL.md` +- `POST /api/v1/batch/` - 执行批量命令 +- `POST /api/v1/compressedbatch/` - 执行压缩批量命令 +- `GET /api/v1/getcurrentoperationid/` - 获取当前操作ID +- `GET /api/v1/getrestoreoperation/` - 获取恢复操作ID +- `GET /api/v1/getsnapshots/` - 获取快照列表 +- `GET /api/v1/havesnapshot/` - 检查快照是否存在 +- `GET /api/v1/havesnapshotforcurrentoperation/` - 检查当前操作快照是否存在 +- `GET /api/v1/havesnapshotforoperation/` - 检查操作快照是否存在 +- `POST /api/v1/pickoperation/` - 选择操作 +- `POST /api/v1/picksnapshot/` - 选择快照 +- `POST /api/v1/redo/` - 重做操作 +- `POST /api/v1/setrestoreoperation/` - 设置恢复操作ID +- `GET /api/v1/syncwithserver/` - 与服务器同步 +- `POST /api/v1/takenapshotforcurrentoperation` - 为当前操作创建快照(兼容模式) +- `POST /api/v1/takesnapshot/` - 创建快照 +- `POST /api/v1/takesnapshotforcurrentoperation` - 为当前操作创建快照 +- `POST /api/v1/takesnapshotforoperation/` - 为操作创建快照 +- `POST /api/v1/undo/` - 撤销操作 + +# See Also + +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/extension/SKILL.md b/.github/skills/business/project-workspace/extension/SKILL.md new file mode 100644 index 0000000..acd8e42 --- /dev/null +++ b/.github/skills/business/project-workspace/extension/SKILL.md @@ -0,0 +1,29 @@ +--- +name: api-operations-business-project-workspace-extension +description: business/project-workspace 场景下 extension 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **extension** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/getallextensiondata/` - 获取所有扩展数据 +- `GET /api/v1/getallextensiondatakeys/` - 获取所有扩展数据键 +- `GET /api/v1/getextensiondata/` - 获取指定扩展数据 +- `POST /api/v1/setextensiondata/` - 设置扩展数据 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/misc/SKILL.md b/.github/skills/business/project-workspace/misc/SKILL.md new file mode 100644 index 0000000..fb06950 --- /dev/null +++ b/.github/skills/business/project-workspace/misc/SKILL.md @@ -0,0 +1,31 @@ +--- +name: api-operations-business-project-workspace-misc +description: business/project-workspace 场景下 misc 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **misc** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `GET /api/v1/getallburstlocateresults/` - 获取所有爆管定位结果 +- `GET /api/v1/getallsensorplacements/` - 获取所有传感器位置 +- `GET /api/v1/getjson/` - 获取JSON示例 +- `GET /api/v1/getrealtimedata/` - 获取实时数据 +- `GET /api/v1/getsimulationresult/` - 获取模拟结果 +- `POST /api/v1/test_dict/` - 测试字典处理 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/project/SKILL.md b/.github/skills/business/project-workspace/project/SKILL.md new file mode 100644 index 0000000..1bc4638 --- /dev/null +++ b/.github/skills/business/project-workspace/project/SKILL.md @@ -0,0 +1,54 @@ +--- +name: api-operations-business-project-workspace-project +description: business/project-workspace 场景下 project 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **project** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/closeproject/` - 关闭项目 +- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 +- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 +- `POST /api/v1/copyproject/` - 复制项目 +- `POST /api/v1/createproject/` - 创建新项目 +- `POST /api/v1/deleteproject/` - 删除项目 +- `GET /api/v1/downloadinp/` - 下载 INP 文件 +- `GET /api/v1/downloadinp/` - 下载 INP 文件 +- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 +- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 +- `GET /api/v1/exportinp/` - 导出项目为 ChangeSet +- `GET /api/v1/haveproject/` - 检查项目是否存在 +- `POST /api/v1/importinp/` - 导入 INP 文件内容 +- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 +- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 +- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 +- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 +- `GET /api/v1/isprojectopen/` - 检查项目是否已打开 +- `GET /api/v1/listprojects/` - 获取项目列表 +- `POST /api/v1/lockproject/` - 锁定项目 +- `POST /api/v1/lockproject/` - 锁定项目 +- `POST /api/v1/openproject/` - 打开项目 +- `GET /api/v1/project_info/` - 获取项目信息 +- `POST /api/v1/readinp/` - 读取 INP 文件到项目 +- `POST /api/v1/readinp/` - 读取 INP 文件到项目 +- `POST /api/v1/unlockproject/` - 解锁项目 +- `POST /api/v1/unlockproject/` - 解锁项目 +- `POST /api/v1/uploadinp/` - 上传 INP 文件 +- `POST /api/v1/uploadinp/` - 上传 INP 文件 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/project_data/SKILL.md b/.github/skills/business/project-workspace/project_data/SKILL.md new file mode 100644 index 0000000..da50365 --- /dev/null +++ b/.github/skills/business/project-workspace/project_data/SKILL.md @@ -0,0 +1,29 @@ +--- +name: api-operations-business-project-workspace-project_data +description: business/project-workspace 场景下 project_data 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **project_data** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/burst-locate-result` - 获取爆管定位结果 +- `GET /api/v1/burst-locate-result/{burst_incident}` - 按事件查询爆管定位结果 +- `GET /api/v1/scada-info` - 获取SCADA信息 +- `GET /api/v1/scheme-list` - 获取方案列表 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/schemes/SKILL.md b/.github/skills/business/project-workspace/schemes/SKILL.md new file mode 100644 index 0000000..18ab871 --- /dev/null +++ b/.github/skills/business/project-workspace/schemes/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-business-project-workspace-schemes +description: business/project-workspace 场景下 schemes 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **schemes** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/getallschemes/` - 获取所有方案 +- `GET /api/v1/getscheme/` - 获取单个方案 +- `GET /api/v1/getschemeschema/` - 获取方案模式 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/snapshots/SKILL.md b/.github/skills/business/project-workspace/snapshots/SKILL.md new file mode 100644 index 0000000..f390f0d --- /dev/null +++ b/.github/skills/business/project-workspace/snapshots/SKILL.md @@ -0,0 +1,43 @@ +--- +name: api-operations-business-project-workspace-snapshots +description: business/project-workspace 场景下 snapshots 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **snapshots** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/batch/` - 执行批量命令 +- `POST /api/v1/compressedbatch/` - 执行压缩批量命令 +- `GET /api/v1/getcurrentoperationid/` - 获取当前操作ID +- `GET /api/v1/getrestoreoperation/` - 获取恢复操作ID +- `GET /api/v1/getsnapshots/` - 获取快照列表 +- `GET /api/v1/havesnapshot/` - 检查快照是否存在 +- `GET /api/v1/havesnapshotforcurrentoperation/` - 检查当前操作快照是否存在 +- `GET /api/v1/havesnapshotforoperation/` - 检查操作快照是否存在 +- `POST /api/v1/pickoperation/` - 选择操作 +- `POST /api/v1/picksnapshot/` - 选择快照 +- `POST /api/v1/redo/` - 重做操作 +- `POST /api/v1/setrestoreoperation/` - 设置恢复操作ID +- `GET /api/v1/syncwithserver/` - 与服务器同步 +- `POST /api/v1/takenapshotforcurrentoperation` - 为当前操作创建快照(兼容模式) +- `POST /api/v1/takesnapshot/` - 创建快照 +- `POST /api/v1/takesnapshotforcurrentoperation` - 为当前操作创建快照 +- `POST /api/v1/takesnapshotforoperation/` - 为操作创建快照 +- `POST /api/v1/undo/` - 撤销操作 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联网络资产: `../network-assets` +- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/examples.md b/.github/skills/examples.md new file mode 100644 index 0000000..45467cb --- /dev/null +++ b/.github/skills/examples.md @@ -0,0 +1,21 @@ +# 示例 + +## 示例 1:按目录查找操作 + +用户目标:"我要改一个泵的属性,去哪个 skill?" + +建议路径: + +1. 打开 `SKILL.md` 查看目录导航。 +2. 进入 `business/network-assets/SKILL.md`。 +3. 在 `Action: pumps` 下选择对应接口(如 `setpumpproperties`)。 + +## 示例 2:按场景联调 + +用户目标:"排查 SCADA 历史数据接口异常。" + +建议路径: + +1. 进入 `analytics/scada-operations/SKILL.md`。 +2. 按 `Action` 定位 `scada` 或 `data_query`。 +3. 结合 `runbook.md` 做状态码与参数排查。 diff --git a/.github/skills/platform/governance-observability/SKILL.md b/.github/skills/platform/governance-observability/SKILL.md new file mode 100644 index 0000000..d64cb3a --- /dev/null +++ b/.github/skills/platform/governance-observability/SKILL.md @@ -0,0 +1,47 @@ +--- +name: api-operations-platform-governance-observability +description: 审计、健康检查和缓存运维接口集合。 +version: 2.1.0 +--- + +# 何时使用 + +当需求落在 **platform/governance-observability** 的接口范围时使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# Action Skills + +- `audit`: `audit/SKILL.md` +- `cache`: `cache/SKILL.md` +- `meta`: `meta/SKILL.md` + +# 操作目录(Domain -> Scenario -> Action) + +## Action: `audit` +- 详情技能:`audit/SKILL.md` +- `GET /api/v1/audit/logs` - 查询审计日志 +- `GET /api/v1/audit/logs/count` - 获取审计日志总数 +- `GET /api/v1/audit/logs/my` - 查询我的审计日志 + +## Action: `cache` +- 详情技能:`cache/SKILL.md` +- `POST /api/v1/clearallredis/` - 清除所有缓存 +- `POST /api/v1/clearrediskey/` - 清除单个缓存键 +- `POST /api/v1/clearrediskeys/` - 清除匹配的缓存键 +- `GET /api/v1/queryredis/` - 查询缓存键列表 + +## Action: `meta` +- 详情技能:`meta/SKILL.md` +- `GET /api/v1/meta/db/health` - 检查数据库健康状态 +- `GET /api/v1/meta/project` - 获取项目元数据 +- `GET /api/v1/meta/projects` - 列出用户项目 + +# See Also + +- 关联身份权限: `../../business/identity-access` +- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/platform/governance-observability/audit/SKILL.md b/.github/skills/platform/governance-observability/audit/SKILL.md new file mode 100644 index 0000000..a35b217 --- /dev/null +++ b/.github/skills/platform/governance-observability/audit/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-platform-governance-observability-audit +description: platform/governance-observability 场景下 audit 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **audit** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/audit/logs` - 查询审计日志 +- `GET /api/v1/audit/logs/count` - 获取审计日志总数 +- `GET /api/v1/audit/logs/my` - 查询我的审计日志 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联身份权限: `../../business/identity-access` +- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/platform/governance-observability/cache/SKILL.md b/.github/skills/platform/governance-observability/cache/SKILL.md new file mode 100644 index 0000000..6c7d5f9 --- /dev/null +++ b/.github/skills/platform/governance-observability/cache/SKILL.md @@ -0,0 +1,29 @@ +--- +name: api-operations-platform-governance-observability-cache +description: platform/governance-observability 场景下 cache 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **cache** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET`, `POST` + +# 操作列表 + +- `POST /api/v1/clearallredis/` - 清除所有缓存 +- `POST /api/v1/clearrediskey/` - 清除单个缓存键 +- `POST /api/v1/clearrediskeys/` - 清除匹配的缓存键 +- `GET /api/v1/queryredis/` - 查询缓存键列表 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联身份权限: `../../business/identity-access` +- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/platform/governance-observability/meta/SKILL.md b/.github/skills/platform/governance-observability/meta/SKILL.md new file mode 100644 index 0000000..da31bc2 --- /dev/null +++ b/.github/skills/platform/governance-observability/meta/SKILL.md @@ -0,0 +1,28 @@ +--- +name: api-operations-platform-governance-observability-meta +description: platform/governance-observability 场景下 meta 操作接口。 +version: 1.0.0 +--- + +# 何时使用 + +当你只需要处理 **meta** 相关接口时,使用本技能。 + +# 输入要求 + +- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) +- 可选:`AUTH_TOKEN`(按环境鉴权策略) +- 覆盖方法:`GET` + +# 操作列表 + +- `GET /api/v1/meta/db/health` - 检查数据库健康状态 +- `GET /api/v1/meta/project` - 获取项目元数据 +- `GET /api/v1/meta/projects` - 列出用户项目 + +# See Also + +- 关联场景: `../` +- 关联总览: `../../../SKILL.md` +- 关联身份权限: `../../business/identity-access` +- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/runbook.md b/.github/skills/runbook.md new file mode 100644 index 0000000..1775923 --- /dev/null +++ b/.github/skills/runbook.md @@ -0,0 +1,20 @@ +# API Skills 使用 Runbook + +## 标准流程 + +1. 先在 `SKILL.md` 选择领域与场景。 +2. 进入对应 `*//SKILL.md`,按 `Action` 找到接口。 +3. 组装请求:`$BASE_URL` + `path`,并按需带 `AUTH_TOKEN`。 +4. 记录请求参数、状态码、响应体。 + +## 异常处理 + +- `401/403`:检查 token 与角色权限。 +- `404`:检查路径前缀与路由配置是否一致。 +- `422`:检查 query/body 参数与字段类型。 +- `5xx`:记录响应体并关联后端日志排查。 + +## 重试建议 + +- 网络超时、`5xx` 可有限重试 1~2 次。 +- `4xx` 先修正参数与权限,不建议直接重试。 diff --git a/.github/skills/scripts/call-api.sh b/.github/skills/scripts/call-api.sh new file mode 100755 index 0000000..cf0a957 --- /dev/null +++ b/.github/skills/scripts/call-api.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:8000}" +NETWORK="${NETWORK:-tjwater}" +URL="${BASE_URL%/}/api/v1/burst-detection/schemes/?network=${NETWORK}" + +headers=(-H "Accept: application/json") +if [[ -n "${AUTH_TOKEN:-}" ]]; then + headers+=(-H "Authorization: Bearer ${AUTH_TOKEN}") +fi + +echo "[api-operations] GET ${URL}" >&2 +curl --silent --show-error --fail-with-body "${headers[@]}" "$URL" +echo diff --git a/app/api/v1/endpoints/data_query.py b/app/api/v1/endpoints/data_query.py deleted file mode 100644 index 2413ca3..0000000 --- a/app/api/v1/endpoints/data_query.py +++ /dev/null @@ -1,388 +0,0 @@ -from typing import Any, List, Dict, Optional -import logging -from datetime import datetime, timedelta, timezone, time as dt_time -import msgpack -from fastapi import APIRouter -from pydantic import BaseModel -from py_linq import Enumerable - -import app.infra.db.influxdb.api as influxdb_api -import app.services.time_api as time_api -from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime - -router = APIRouter() -logger = logging.getLogger(__name__) - -# Basic Node/Link Latest Record Queries - -@router.get("/querynodelatestrecordbyid/") -async def fastapi_query_node_latest_record_by_id(id: str) -> Any: - return influxdb_api.query_latest_record_by_ID(id, type="node") - -@router.get("/querylinklatestrecordbyid/") -async def fastapi_query_link_latest_record_by_id(id: str) -> Any: - return influxdb_api.query_latest_record_by_ID(id, type="link") - -@router.get("/queryscadalatestrecordbyid/") -async def fastapi_query_scada_latest_record_by_id(id: str) -> Any: - return influxdb_api.query_latest_record_by_ID(id, type="scada") - -# Time-based Queries - -@router.get("/queryallrecordsbytime/") -async def fastapi_query_all_records_by_time(querytime: str) -> dict[str, list]: - results: tuple = influxdb_api.query_all_records_by_time(query_time=querytime) - return {"nodes": results[0], "links": results[1]} - -@router.get("/queryallrecordsbytimeproperty/") -async def fastapi_query_all_record_by_time_property( - querytime: str, type: str, property: str, bucket: str = "realtime_simulation_result" -) -> dict[str, list]: - results: tuple = influxdb_api.query_all_record_by_time_property( - query_time=querytime, type=type, property=property, bucket=bucket - ) - return {"results": results} - -@router.get("/queryallschemerecordsbytimeproperty/") -async def fastapi_query_all_scheme_record_by_time_property( - querytime: str, - type: str, - property: str, - schemename: str, - bucket: str = "scheme_simulation_result", -) -> dict[str, list]: - """ - 查询指定方案某一时刻的所有记录,查询 'node' 或 'link' 的某一属性值 - """ - results: list = influxdb_api.query_all_scheme_record_by_time_property( - query_time=querytime, - type=type, - property=property, - scheme_name=schemename, - bucket=bucket, - ) - return {"results": results} - -@router.get("/querysimulationrecordsbyidtime/") -async def fastapi_query_simulation_record_by_ids_time( - id: str, querytime: str, type: str, bucket: str = "realtime_simulation_result" -) -> dict[str, list]: - results: tuple = influxdb_api.query_simulation_result_by_ID_time( - ID=id, type=type, query_time=querytime, bucket=bucket - ) - return {"results": results} - -@router.get("/queryschemesimulationrecordsbyidtime/") -async def fastapi_query_scheme_simulation_record_by_ids_time( - scheme_name: str, - id: str, - querytime: str, - type: str, - bucket: str = "scheme_simulation_result", -) -> dict[str, list]: - results: tuple = influxdb_api.query_scheme_simulation_result_by_ID_time( - scheme_name=scheme_name, ID=id, type=type, query_time=querytime, bucket=bucket - ) - return {"results": results} - -# Date-based Queries with Caching - -@router.get("/queryallrecordsbydate/") -async def fastapi_query_all_records_by_date(querydate: str) -> dict: - is_today_or_future = time_api.is_today_or_future(querydate) - logger.info(f"isToday or future: {is_today_or_future}") - - cache_key = f"queryallrecordsbydate_{querydate}" - - if not is_today_or_future: - data = redis_client.get(cache_key) - if data: - results = msgpack.unpackb(data, object_hook=decode_datetime) - logger.info("return from cache redis") - return results - - logger.info("query from influxdb") - nodes_links: tuple = influxdb_api.query_all_records_by_date(query_date=querydate) - results = {"nodes": nodes_links[0], "links": nodes_links[1]} - - if not is_today_or_future: - logger.info("save to cache redis") - redis_client.set(cache_key, msgpack.packb(results, default=encode_datetime)) - - logger.info("return results") - return results - -@router.get("/queryallrecordsbytimerange/") -async def fastapi_query_all_records_by_time_range( - starttime: str, endtime: str -) -> dict[str, list]: - cache_key = f"queryallrecordsbytimerange_{starttime}_{endtime}" - - if not time_api.is_today_or_future(starttime): - data = redis_client.get(cache_key) - if data: - loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime) - return loaded_dict - - nodes_links: tuple = influxdb_api.query_all_records_by_time_range( - starttime=starttime, endtime=endtime - ) - results = {"nodes": nodes_links[0], "links": nodes_links[1]} - - if not time_api.is_today_or_future(starttime): - redis_client.set(cache_key, msgpack.packb(results, default=encode_datetime)) - - return results - -@router.get("/queryallrecordsbydatewithtype/") -async def fastapi_query_all_records_by_date_with_type( - querydate: str, querytype: str -) -> list: - cache_key = f"queryallrecordsbydatewithtype_{querydate}_{querytype}" - data = redis_client.get(cache_key) - if data: - loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime) - return loaded_dict - - results = influxdb_api.query_all_records_by_date_with_type( - query_date=querydate, query_type=querytype - ) - - packed = msgpack.packb(results, default=encode_datetime) - redis_client.set(cache_key, packed) - - return results - -@router.get("/queryallrecordsbyidsdatetype/") -async def fastapi_query_all_records_by_ids_date_type( - ids: str, querydate: str, querytype: str -) -> list: - cache_key = f"queryallrecordsbydatewithtype_{querydate}_{querytype}" - data = redis_client.get(cache_key) - - results = [] - if data: - results = msgpack.unpackb(data, object_hook=decode_datetime) - else: - results = influxdb_api.query_all_records_by_date_with_type( - query_date=querydate, query_type=querytype - ) - packed = msgpack.packb(results, default=encode_datetime) - redis_client.set(cache_key, packed) - - query_ids = ids.split(",") - # Using Enumerable from py_linq as in original code - e_results = Enumerable(results) - lst_results = e_results.where(lambda x: x["ID"] in query_ids).to_list() - - return lst_results - -@router.get("/queryallrecordsbydateproperty/") -async def fastapi_query_all_records_by_date_property( - querydate: str, querytype: str, property: str -) -> list[dict]: - cache_key = f"queryallrecordsbydateproperty_{querydate}_{querytype}_{property}" - data = redis_client.get(cache_key) - if data: - loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime) - return loaded_dict - - result_dict = influxdb_api.query_all_record_by_date_property( - query_date=querydate, type=querytype, property=property - ) - packed = msgpack.packb(result_dict, default=encode_datetime) - redis_client.set(cache_key, packed) - - return result_dict - -# Curve Queries - -@router.get("/querynodecurvebyidpropertydaterange/") -async def fastapi_query_node_curve_by_id_property_daterange( - id: str, prop: str, startdate: str, enddate: str -): - return influxdb_api.query_curve_by_ID_property_daterange( - id, type="node", property=prop, start_date=startdate, end_date=enddate - ) - -@router.get("/querylinkcurvebyidpropertydaterange/") -async def fastapi_query_link_curve_by_id_property_daterange( - id: str, prop: str, startdate: str, enddate: str -): - return influxdb_api.query_curve_by_ID_property_daterange( - id, type="link", property=prop, start_date=startdate, end_date=enddate - ) - -# SCADA Data Queries - -@router.get("/queryscadadatabydeviceidandtime/") -async def fastapi_query_scada_data_by_device_id_and_time(ids: str, querytime: str): - query_ids = ids.split(",") - logger.info(querytime) - return influxdb_api.query_SCADA_data_by_device_ID_and_time( - query_ids_list=query_ids, query_time=querytime - ) - -@router.get("/queryscadadatabydeviceidandtimerange/") -async def fastapi_query_scada_data_by_device_id_and_time_range( - ids: str, starttime: str, endtime: str -): - print(f"query_ids: {ids}, starttime: {starttime}, endtime: {endtime}") - query_ids = ids.split(",") - return influxdb_api.query_SCADA_data_by_device_ID_and_timerange( - query_ids_list=query_ids, start_time=starttime, end_time=endtime - ) - -@router.get("/queryfillingscadadatabydeviceidandtimerange/") -async def fastapi_query_filling_scada_data_by_device_id_and_time_range( - ids: str, starttime: str, endtime: str -): - print(f"query_ids: {ids}, starttime: {starttime}, endtime: {endtime}") - query_ids = ids.split(",") - return influxdb_api.query_filling_SCADA_data_by_device_ID_and_timerange( - query_ids_list=query_ids, start_time=starttime, end_time=endtime - ) - -@router.get("/querycleaningscadadatabydeviceidandtimerange/") -async def fastapi_query_cleaning_scada_data_by_device_id_and_time_range( - ids: str, starttime: str, endtime: str -): - print(f"query_ids: {ids}, starttime: {starttime}, endtime: {endtime}") - query_ids = ids.split(",") - return influxdb_api.query_cleaning_SCADA_data_by_device_ID_and_timerange( - query_ids_list=query_ids, start_time=starttime, end_time=endtime - ) - -@router.get("/querysimulationscadadatabydeviceidandtimerange/") -async def fastapi_query_simulation_scada_data_by_device_id_and_time_range( - ids: str, starttime: str, endtime: str -): - print(f"query_ids: {ids}, starttime: {starttime}, endtime: {endtime}") - query_ids = ids.split(",") - return influxdb_api.query_simulation_SCADA_data_by_device_ID_and_timerange( - query_ids_list=query_ids, start_time=starttime, end_time=endtime - ) - -@router.get("/querycleanedscadadatabydeviceidandtimerange/") -async def fastapi_query_cleaned_scada_data_by_device_id_and_time_range( - ids: str, starttime: str, endtime: str -): - print(f"query_ids: {ids}, starttime: {starttime}, endtime: {endtime}") - query_ids = ids.split(",") - return influxdb_api.query_cleaned_SCADA_data_by_device_ID_and_timerange( - query_ids_list=query_ids, start_time=starttime, end_time=endtime - ) - -@router.get("/queryscadadatabydeviceidanddate/") -async def fastapi_query_scada_data_by_device_id_and_date(ids: str, querydate: str): - query_ids = ids.split(",") - return influxdb_api.query_SCADA_data_by_device_ID_and_date( - query_ids_list=query_ids, query_date=querydate - ) - -@router.get("/queryallscadarecordsbydate/") -async def fastapi_query_all_scada_records_by_date(querydate: str): - is_today_or_future = time_api.is_today_or_future(querydate) - logger.info(f"isToday or future: {is_today_or_future}") - - cache_key = f"queryallscadarecordsbydate_{querydate}" - - if not is_today_or_future: - data = redis_client.get(cache_key) - if data: - loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime) - logger.info("return from cache redis") - return loaded_dict - - logger.info("query from influxdb") - result_dict = influxdb_api.query_all_SCADA_records_by_date(query_date=querydate) - - if not is_today_or_future: - logger.info("save to cache redis") - packed = msgpack.packb(result_dict, default=encode_datetime) - redis_client.set(cache_key, packed) - - logger.info("return results") - return result_dict - -@router.get("/queryallschemeallrecords/") -async def fastapi_query_all_scheme_all_records( - schemetype: str, schemename: str, querydate: str -) -> tuple: - cache_key = f"queryallschemeallrecords_{schemetype}_{schemename}_{querydate}" - data = redis_client.get(cache_key) - if data: - loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime) - return loaded_dict - - results = influxdb_api.query_scheme_all_record( - scheme_type=schemetype, scheme_name=schemename, query_date=querydate - ) - packed = msgpack.packb(results, default=encode_datetime) - redis_client.set(cache_key, packed) - - return results - -@router.get("/queryschemeallrecordsproperty/") -async def fastapi_query_all_scheme_all_records_property( - schemetype: str, schemename: str, querydate: str, querytype: str, queryproperty: str -) -> Optional[List]: - cache_key = f"queryallschemeallrecords_{schemetype}_{schemename}_{querydate}" - data = redis_client.get(cache_key) - all_results = None - if data: - all_results = msgpack.unpackb(data, object_hook=decode_datetime) - else: - all_results = influxdb_api.query_scheme_all_record( - scheme_type=schemetype, scheme_name=schemename, query_date=querydate - ) - packed = msgpack.packb(all_results, default=encode_datetime) - redis_client.set(cache_key, packed) - - results = None - if querytype == "node": - results = all_results[0] - elif querytype == "link": - results = all_results[1] - - return results - -@router.get("/queryinfluxdbbuckets/") -async def fastapi_query_influxdb_buckets(): - return influxdb_api.query_buckets() - -@router.get("/queryinfluxdbbucketmeasurements/") -async def fastapi_query_influxdb_bucket_measurements(bucket: str): - return influxdb_api.query_measurements(bucket=bucket) - -############################################################ -# download history data -############################################################ - -class Download_History_Data_Manually(BaseModel): - """ - download_date:样式如 datetime(2025, 5, 4) - """ - - download_date: datetime - - -@router.post("/download_history_data_manually/") -async def fastapi_download_history_data_manually( - data: Download_History_Data_Manually, -) -> None: - item = data.dict() - tz = timezone(timedelta(hours=8)) - begin_dt = datetime.combine(item.get("download_date").date(), dt_time.min).replace( - tzinfo=tz - ) - end_dt = datetime.combine(item.get("download_date").date(), dt_time(23, 59, 59)).replace( - tzinfo=tz - ) - - begin_time = begin_dt.isoformat() - end_time = end_dt.isoformat() - - influxdb_api.download_history_data_manually( - begin_time=begin_time, end_time=end_time - ) From 93cbd7e7b393d90b8020ea45792a8937991e05d0 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 27 Mar 2026 13:52:12 +0800 Subject: [PATCH 07/93] =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20copilot=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 - .github/skills/SKILL.md | 36 - .github/skills/ai/copilot-assistant/SKILL.md | 30 - .../ai/copilot-assistant/copilot/SKILL.md | 26 - .../analytics/scada-operations/SKILL.md | 60 -- .../analytics/scada-operations/scada/SKILL.md | 56 -- .../analytics/simulation-analysis/SKILL.md | 89 --- .../burst_detection/SKILL.md | 28 - .../burst_location/SKILL.md | 28 - .../simulation-analysis/leakage/SKILL.md | 28 - .../simulation-analysis/risk/SKILL.md | 30 - .../simulation-analysis/simulation/SKILL.md | 55 -- .github/skills/api-spec.md | 671 ------------------ .../skills/business/component-config/SKILL.md | 121 ---- .../component-config/controls/SKILL.md | 31 - .../business/component-config/curves/SKILL.md | 32 - .../component-config/options/SKILL.md | 37 - .../component-config/patterns/SKILL.md | 32 - .../component-config/quality/SKILL.md | 50 -- .../component-config/visuals/SKILL.md | 40 -- .../skills/business/identity-access/SKILL.md | 51 -- .../business/identity-access/auth/SKILL.md | 30 - .../identity-access/user_management/SKILL.md | 31 - .../business/identity-access/users/SKILL.md | 28 - .../skills/business/network-assets/SKILL.md | 260 ------- .../business/network-assets/demands/SKILL.md | 31 - .../business/network-assets/general/SKILL.md | 54 -- .../business/network-assets/geometry/SKILL.md | 31 - .../network-assets/junctions/SKILL.md | 43 -- .../business/network-assets/pipes/SKILL.md | 45 -- .../business/network-assets/pumps/SKILL.md | 35 - .../business/network-assets/regions/SKILL.md | 62 -- .../network-assets/reservoirs/SKILL.md | 41 -- .../business/network-assets/tags/SKILL.md | 29 - .../business/network-assets/tanks/SKILL.md | 53 -- .../business/network-assets/valves/SKILL.md | 42 -- .../business/project-workspace/SKILL.md | 113 --- .../project-workspace/extension/SKILL.md | 29 - .../business/project-workspace/misc/SKILL.md | 31 - .../project-workspace/project/SKILL.md | 54 -- .../project-workspace/project_data/SKILL.md | 29 - .../project-workspace/schemes/SKILL.md | 28 - .../project-workspace/snapshots/SKILL.md | 43 -- .github/skills/examples.md | 21 - .../governance-observability/SKILL.md | 47 -- .../governance-observability/audit/SKILL.md | 28 - .../governance-observability/cache/SKILL.md | 29 - .../governance-observability/meta/SKILL.md | 28 - .github/skills/runbook.md | 20 - .github/skills/scripts/call-api.sh | 15 - app/api/v1/endpoints/copilot.py | 120 ---- app/api/v1/router.py | 6 +- app/core/config.py | 4 - app/infra/audit/middleware.py | 4 - copilot-sidecar/server.py | 193 ----- requirements.txt | 1 - scripts/run_server.py | 77 +- tests/api/test_copilot_chat_endpoint.py | 117 --- 58 files changed, 9 insertions(+), 3380 deletions(-) delete mode 100644 .github/skills/SKILL.md delete mode 100644 .github/skills/ai/copilot-assistant/SKILL.md delete mode 100644 .github/skills/ai/copilot-assistant/copilot/SKILL.md delete mode 100644 .github/skills/analytics/scada-operations/SKILL.md delete mode 100644 .github/skills/analytics/scada-operations/scada/SKILL.md delete mode 100644 .github/skills/analytics/simulation-analysis/SKILL.md delete mode 100644 .github/skills/analytics/simulation-analysis/burst_detection/SKILL.md delete mode 100644 .github/skills/analytics/simulation-analysis/burst_location/SKILL.md delete mode 100644 .github/skills/analytics/simulation-analysis/leakage/SKILL.md delete mode 100644 .github/skills/analytics/simulation-analysis/risk/SKILL.md delete mode 100644 .github/skills/analytics/simulation-analysis/simulation/SKILL.md delete mode 100644 .github/skills/api-spec.md delete mode 100644 .github/skills/business/component-config/SKILL.md delete mode 100644 .github/skills/business/component-config/controls/SKILL.md delete mode 100644 .github/skills/business/component-config/curves/SKILL.md delete mode 100644 .github/skills/business/component-config/options/SKILL.md delete mode 100644 .github/skills/business/component-config/patterns/SKILL.md delete mode 100644 .github/skills/business/component-config/quality/SKILL.md delete mode 100644 .github/skills/business/component-config/visuals/SKILL.md delete mode 100644 .github/skills/business/identity-access/SKILL.md delete mode 100644 .github/skills/business/identity-access/auth/SKILL.md delete mode 100644 .github/skills/business/identity-access/user_management/SKILL.md delete mode 100644 .github/skills/business/identity-access/users/SKILL.md delete mode 100644 .github/skills/business/network-assets/SKILL.md delete mode 100644 .github/skills/business/network-assets/demands/SKILL.md delete mode 100644 .github/skills/business/network-assets/general/SKILL.md delete mode 100644 .github/skills/business/network-assets/geometry/SKILL.md delete mode 100644 .github/skills/business/network-assets/junctions/SKILL.md delete mode 100644 .github/skills/business/network-assets/pipes/SKILL.md delete mode 100644 .github/skills/business/network-assets/pumps/SKILL.md delete mode 100644 .github/skills/business/network-assets/regions/SKILL.md delete mode 100644 .github/skills/business/network-assets/reservoirs/SKILL.md delete mode 100644 .github/skills/business/network-assets/tags/SKILL.md delete mode 100644 .github/skills/business/network-assets/tanks/SKILL.md delete mode 100644 .github/skills/business/network-assets/valves/SKILL.md delete mode 100644 .github/skills/business/project-workspace/SKILL.md delete mode 100644 .github/skills/business/project-workspace/extension/SKILL.md delete mode 100644 .github/skills/business/project-workspace/misc/SKILL.md delete mode 100644 .github/skills/business/project-workspace/project/SKILL.md delete mode 100644 .github/skills/business/project-workspace/project_data/SKILL.md delete mode 100644 .github/skills/business/project-workspace/schemes/SKILL.md delete mode 100644 .github/skills/business/project-workspace/snapshots/SKILL.md delete mode 100644 .github/skills/examples.md delete mode 100644 .github/skills/platform/governance-observability/SKILL.md delete mode 100644 .github/skills/platform/governance-observability/audit/SKILL.md delete mode 100644 .github/skills/platform/governance-observability/cache/SKILL.md delete mode 100644 .github/skills/platform/governance-observability/meta/SKILL.md delete mode 100644 .github/skills/runbook.md delete mode 100755 .github/skills/scripts/call-api.sh delete mode 100644 app/api/v1/endpoints/copilot.py delete mode 100644 copilot-sidecar/server.py delete mode 100644 tests/api/test_copilot_chat_endpoint.py diff --git a/.env.example b/.env.example index 90d3d1b..f11f581 100644 --- a/.env.example +++ b/.env.example @@ -48,9 +48,3 @@ METADATA_DB_PASSWORD="password" KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_AUDIENCE="account" - -# ============================================ -# Copilot Python Sidecar -# ============================================ -COPILOT_SIDECAR_URL="http://127.0.0.1:8787" -COPILOT_STREAM_TIMEOUT_SECONDS=120 diff --git a/.github/skills/SKILL.md b/.github/skills/SKILL.md deleted file mode 100644 index 4b7e5e5..0000000 --- a/.github/skills/SKILL.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: api-operations-overview -description: 按“领域 -> 场景 -> 操作”组织 TJWater API Skills,快速定位可调用接口。 -version: 2.0.0 ---- - -# 何时使用 - -当你需要按业务语义快速定位 API,或希望在联调时按分层目录检索接口。 - -# 分层结构(<=3 层) - -- 领域(Domain) -- 场景(Scenario) -- 操作(Action) - -# 目录导航 - -- `business/identity-access` -- `business/project-workspace` -- `business/network-assets` -- `business/component-config` -- `analytics/simulation-analysis` -- `analytics/scada-operations` -- `data/timeseries-access` -- `platform/governance-observability` -- `ai/copilot-assistant` - -完整操作清单:`api-spec.md` - -# See Also - -- 关联示例: `examples.md` -- 关联运行手册: `runbook.md` - -Action Skills 总数:`39` diff --git a/.github/skills/ai/copilot-assistant/SKILL.md b/.github/skills/ai/copilot-assistant/SKILL.md deleted file mode 100644 index 820bf95..0000000 --- a/.github/skills/ai/copilot-assistant/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: api-operations-ai-copilot-assistant -description: Copilot 助手接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **ai/copilot-assistant** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`POST` - -# Action Skills - -- `copilot`: `copilot/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `copilot` -- 详情技能:`copilot/SKILL.md` -- `POST /api/v1/copilot/chat/stream` - Copilot 聊天流式响应 - -# See Also - -- 关联项目空间: `../../business/project-workspace` -- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/ai/copilot-assistant/copilot/SKILL.md b/.github/skills/ai/copilot-assistant/copilot/SKILL.md deleted file mode 100644 index a3c54eb..0000000 --- a/.github/skills/ai/copilot-assistant/copilot/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: api-operations-ai-copilot-assistant-copilot -description: ai/copilot-assistant 场景下 copilot 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **copilot** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`POST` - -# 操作列表 - -- `POST /api/v1/copilot/chat/stream` - Copilot 聊天流式响应 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联项目空间: `../../business/project-workspace` -- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/analytics/scada-operations/SKILL.md b/.github/skills/analytics/scada-operations/SKILL.md deleted file mode 100644 index 35a5b84..0000000 --- a/.github/skills/analytics/scada-operations/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: api-operations-analytics-scada-operations -description: SCADA 数据读写与历史查询接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **analytics/scada-operations** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`DELETE`, `GET`, `PATCH`, `POST` - -# Action Skills - -- `scada`: `scada/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `scada` -- 详情技能:`scada/SKILL.md` -- `POST /api/v1/addscadadevice/` - 添加SCADA设备 -- `POST /api/v1/addscadadevicedata/` - 添加SCADA设备数据 -- `POST /api/v1/addscadaelement/` - 添加SCADA元素映射 -- `POST /api/v1/cleanscadadevice/` - 清空SCADA设备表 -- `POST /api/v1/cleanscadadevicedata/` - 清空SCADA设备数据表 -- `POST /api/v1/cleanscadaelement/` - 清空SCADA元素映射表 -- `POST /api/v1/deletescadadevice/` - 删除SCADA设备 -- `POST /api/v1/deletescadadevicedata/` - 删除SCADA设备数据 -- `POST /api/v1/deletescadaelement/` - 删除SCADA元素映射 -- `GET /api/v1/getallscadadeviceids/` - 获取所有SCADA设备ID -- `GET /api/v1/getallscadadevices/` - 获取所有SCADA设备 -- `GET /api/v1/getallscadainfo/` - 获取所有SCADA信息 -- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA属性 -- `GET /api/v1/getscadadevice/` - 获取SCADA设备 -- `GET /api/v1/getscadadevicedata/` - 获取SCADA设备数据 -- `GET /api/v1/getscadadevicedataschema/` - 获取SCADA设备数据架构 -- `GET /api/v1/getscadadeviceschema/` - 获取SCADA设备架构 -- `GET /api/v1/getscadaelement/` - 获取单个SCADA元素映射 -- `GET /api/v1/getscadaelements/` - 获取所有SCADA元素映射 -- `GET /api/v1/getscadaelementschema/` - 获取SCADA元素架构 -- `GET /api/v1/getscadainfo/` - 获取SCADA信息 -- `GET /api/v1/getscadainfoschema/` - 获取SCADA信息架构 -- `GET /api/v1/getscadaproperties/` - 获取SCADA属性 -- `POST /api/v1/scada/batch` - 批量插入SCADA监测数据 -- `DELETE /api/v1/scada/by-id-time-range` - 按设备ID和时间范围删除SCADA数据 -- `GET /api/v1/scada/by-ids-field-time-range` - 按设备ID、字段和时间范围查询SCADA数据 -- `GET /api/v1/scada/by-ids-time-range` - 按设备ID和时间范围查询SCADA数据 -- `PATCH /api/v1/scada/{device_id}/field` - 更新SCADA设备字段 -- `POST /api/v1/setscadadevice/` - 更新SCADA设备 -- `POST /api/v1/setscadadevicedata/` - 更新SCADA设备数据 -- `POST /api/v1/setscadaelement/` - 更新SCADA元素映射 - -# See Also - -- 关联时序数据: `../../data/timeseries-access` -- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/analytics/scada-operations/scada/SKILL.md b/.github/skills/analytics/scada-operations/scada/SKILL.md deleted file mode 100644 index 3236ec5..0000000 --- a/.github/skills/analytics/scada-operations/scada/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: api-operations-analytics-scada-operations-scada -description: analytics/scada-operations 场景下 scada 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **scada** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`DELETE`, `GET`, `PATCH`, `POST` - -# 操作列表 - -- `POST /api/v1/addscadadevice/` - 添加SCADA设备 -- `POST /api/v1/addscadadevicedata/` - 添加SCADA设备数据 -- `POST /api/v1/addscadaelement/` - 添加SCADA元素映射 -- `POST /api/v1/cleanscadadevice/` - 清空SCADA设备表 -- `POST /api/v1/cleanscadadevicedata/` - 清空SCADA设备数据表 -- `POST /api/v1/cleanscadaelement/` - 清空SCADA元素映射表 -- `POST /api/v1/deletescadadevice/` - 删除SCADA设备 -- `POST /api/v1/deletescadadevicedata/` - 删除SCADA设备数据 -- `POST /api/v1/deletescadaelement/` - 删除SCADA元素映射 -- `GET /api/v1/getallscadadeviceids/` - 获取所有SCADA设备ID -- `GET /api/v1/getallscadadevices/` - 获取所有SCADA设备 -- `GET /api/v1/getallscadainfo/` - 获取所有SCADA信息 -- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA属性 -- `GET /api/v1/getscadadevice/` - 获取SCADA设备 -- `GET /api/v1/getscadadevicedata/` - 获取SCADA设备数据 -- `GET /api/v1/getscadadevicedataschema/` - 获取SCADA设备数据架构 -- `GET /api/v1/getscadadeviceschema/` - 获取SCADA设备架构 -- `GET /api/v1/getscadaelement/` - 获取单个SCADA元素映射 -- `GET /api/v1/getscadaelements/` - 获取所有SCADA元素映射 -- `GET /api/v1/getscadaelementschema/` - 获取SCADA元素架构 -- `GET /api/v1/getscadainfo/` - 获取SCADA信息 -- `GET /api/v1/getscadainfoschema/` - 获取SCADA信息架构 -- `GET /api/v1/getscadaproperties/` - 获取SCADA属性 -- `POST /api/v1/scada/batch` - 批量插入SCADA监测数据 -- `DELETE /api/v1/scada/by-id-time-range` - 按设备ID和时间范围删除SCADA数据 -- `GET /api/v1/scada/by-ids-field-time-range` - 按设备ID、字段和时间范围查询SCADA数据 -- `GET /api/v1/scada/by-ids-time-range` - 按设备ID和时间范围查询SCADA数据 -- `PATCH /api/v1/scada/{device_id}/field` - 更新SCADA设备字段 -- `POST /api/v1/setscadadevice/` - 更新SCADA设备 -- `POST /api/v1/setscadadevicedata/` - 更新SCADA设备数据 -- `POST /api/v1/setscadaelement/` - 更新SCADA元素映射 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联时序数据: `../../data/timeseries-access` -- 关联平台治理: `../../platform/governance-observability` diff --git a/.github/skills/analytics/simulation-analysis/SKILL.md b/.github/skills/analytics/simulation-analysis/SKILL.md deleted file mode 100644 index e00455e..0000000 --- a/.github/skills/analytics/simulation-analysis/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: api-operations-analytics-simulation-analysis -description: 仿真、风险、漏损与爆管分析接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **analytics/simulation-analysis** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# Action Skills - -- `burst_detection`: `burst_detection/SKILL.md` -- `burst_location`: `burst_location/SKILL.md` -- `leakage`: `leakage/SKILL.md` -- `risk`: `risk/SKILL.md` -- `simulation`: `simulation/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `burst_detection` -- 详情技能:`burst_detection/SKILL.md` -- `POST /api/v1/burst-detection/detect/` - 执行爆管检测 -- `GET /api/v1/burst-detection/schemes/` - 查询爆管检测方案列表 -- `GET /api/v1/burst-detection/schemes/{scheme_name}` - 获取爆管检测方案详情 - -## Action: `burst_location` -- 详情技能:`burst_location/SKILL.md` -- `POST /api/v1/burst-location/locate/` - 执行爆管定位 -- `GET /api/v1/burst-location/schemes/` - 查询爆管定位方案列表 -- `GET /api/v1/burst-location/schemes/{scheme_name}` - 获取爆管定位方案详情 - -## Action: `leakage` -- 详情技能:`leakage/SKILL.md` -- `POST /api/v1/leakage/identify/` - 执行漏损识别 -- `GET /api/v1/leakage/schemes/` - 查询漏损识别方案列表 -- `GET /api/v1/leakage/schemes/{scheme_name}` - 获取漏损识别方案详情 - -## Action: `risk` -- 详情技能:`risk/SKILL.md` -- `GET /api/v1/getnetworkpiperiskprobabilitynow/` - 获取整个网络的管道风险概率 -- `GET /api/v1/getpiperiskprobability/` - 获取管道风险概率历史 -- `GET /api/v1/getpiperiskprobabilitygeometries/` - 获取管道风险几何信息 -- `GET /api/v1/getpiperiskprobabilitynow/` - 获取管道当前风险概率 -- `GET /api/v1/getpipesriskprobability/` - 批量获取多条管道风险概率 - -## Action: `simulation` -- 详情技能:`simulation/SKILL.md` -- `GET /api/v1/age_analysis/` - 水龄分析(高级) -- `GET /api/v1/ageanalysis/` - 水龄分析(基础) -- `GET /api/v1/burst_analysis/` - 爆管分析(高级) -- `GET /api/v1/burstanalysis/` - 爆管分析(基础) -- `GET /api/v1/contaminant_simulation/` - 污染物模拟 -- `POST /api/v1/daily_scheduling_analysis/` - 日排程分析 -- `GET /api/v1/dumpoutput/` - 导出模拟输出 -- `GET /api/v1/flushing_analysis/` - 冲洗分析(高级) -- `GET /api/v1/flushinganalysis/` - 冲洗分析(基础) -- `POST /api/v1/network_project/` - 导入网络项目 -- `POST /api/v1/network_update/` - 管网更新(高级) -- `GET /api/v1/networkupdate/` - 管网更新(基础) -- `POST /api/v1/pressure_regulation/` - 压力调节(高级) -- `POST /api/v1/pressure_sensor_placement_kmeans/` - 压力传感器放置-KMeans聚类分析(高级) -- `POST /api/v1/pressure_sensor_placement_sensitivity/` - 压力传感器放置-灵敏度分析(高级) -- `GET /api/v1/pressureregulation/` - 压力调节(基础) -- `GET /api/v1/pressuresensorplacementkmeans/` - 压力传感器放置-KMeans聚类分析(基础) -- `GET /api/v1/pressuresensorplacementsensitivity/` - 压力传感器放置-灵敏度分析(基础) -- `POST /api/v1/project_management/` - 项目管理(高级) -- `GET /api/v1/projectmanagement/` - 项目管理(基础) -- `POST /api/v1/pump_failure/` - 泵故障管理 -- `GET /api/v1/runinp/` - 运行INP文件 -- `GET /api/v1/runproject/` - 运行项目模拟 -- `GET /api/v1/runprojectreturndict/` - 运行项目模拟(返回字典) -- `POST /api/v1/runsimulationmanuallybydate/` - 手动运行日期指定模拟 -- `POST /api/v1/scheduling_analysis/` - 排程分析 -- `POST /api/v1/sensorplacementscheme/create` - 传感器放置方案创建 -- `GET /api/v1/valve_close_analysis/` - 阀门关闭分析(高级) -- `GET /api/v1/valve_isolation_analysis/` - 阀门隔离分析 -- `GET /api/v1/valvecloseanalysis/` - 阀门关闭分析(基础) - -# See Also - -- 关联网络资产: `../../business/network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/burst_detection/SKILL.md b/.github/skills/analytics/simulation-analysis/burst_detection/SKILL.md deleted file mode 100644 index 38f5db5..0000000 --- a/.github/skills/analytics/simulation-analysis/burst_detection/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-analytics-simulation-analysis-burst-detection -description: analytics/simulation-analysis 场景下 burst-detection 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **burst_detection** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/burst-detection/detect/` - 执行爆管检测 -- `GET /api/v1/burst-detection/schemes/` - 查询爆管检测方案列表 -- `GET /api/v1/burst-detection/schemes/{scheme_name}` - 获取爆管检测方案详情 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../../business/network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/burst_location/SKILL.md b/.github/skills/analytics/simulation-analysis/burst_location/SKILL.md deleted file mode 100644 index 21f8d3e..0000000 --- a/.github/skills/analytics/simulation-analysis/burst_location/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-analytics-simulation-analysis-burst-location -description: analytics/simulation-analysis 场景下 burst-location 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **burst_location** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/burst-location/locate/` - 执行爆管定位 -- `GET /api/v1/burst-location/schemes/` - 查询爆管定位方案列表 -- `GET /api/v1/burst-location/schemes/{scheme_name}` - 获取爆管定位方案详情 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../../business/network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/leakage/SKILL.md b/.github/skills/analytics/simulation-analysis/leakage/SKILL.md deleted file mode 100644 index d0b948e..0000000 --- a/.github/skills/analytics/simulation-analysis/leakage/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-analytics-simulation-analysis-leakage -description: analytics/simulation-analysis 场景下 leakage 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **leakage** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/leakage/identify/` - 执行漏损识别 -- `GET /api/v1/leakage/schemes/` - 查询漏损识别方案列表 -- `GET /api/v1/leakage/schemes/{scheme_name}` - 获取漏损识别方案详情 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../../business/network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/risk/SKILL.md b/.github/skills/analytics/simulation-analysis/risk/SKILL.md deleted file mode 100644 index 506af82..0000000 --- a/.github/skills/analytics/simulation-analysis/risk/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: api-operations-analytics-simulation-analysis-risk -description: analytics/simulation-analysis 场景下 risk 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **risk** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/getnetworkpiperiskprobabilitynow/` - 获取整个网络的管道风险概率 -- `GET /api/v1/getpiperiskprobability/` - 获取管道风险概率历史 -- `GET /api/v1/getpiperiskprobabilitygeometries/` - 获取管道风险几何信息 -- `GET /api/v1/getpiperiskprobabilitynow/` - 获取管道当前风险概率 -- `GET /api/v1/getpipesriskprobability/` - 批量获取多条管道风险概率 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../../business/network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/analytics/simulation-analysis/simulation/SKILL.md b/.github/skills/analytics/simulation-analysis/simulation/SKILL.md deleted file mode 100644 index 393ab5e..0000000 --- a/.github/skills/analytics/simulation-analysis/simulation/SKILL.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: api-operations-analytics-simulation-analysis-simulation -description: analytics/simulation-analysis 场景下 simulation 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **simulation** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/age_analysis/` - 水龄分析(高级) -- `GET /api/v1/ageanalysis/` - 水龄分析(基础) -- `GET /api/v1/burst_analysis/` - 爆管分析(高级) -- `GET /api/v1/burstanalysis/` - 爆管分析(基础) -- `GET /api/v1/contaminant_simulation/` - 污染物模拟 -- `POST /api/v1/daily_scheduling_analysis/` - 日排程分析 -- `GET /api/v1/dumpoutput/` - 导出模拟输出 -- `GET /api/v1/flushing_analysis/` - 冲洗分析(高级) -- `GET /api/v1/flushinganalysis/` - 冲洗分析(基础) -- `POST /api/v1/network_project/` - 导入网络项目 -- `POST /api/v1/network_update/` - 管网更新(高级) -- `GET /api/v1/networkupdate/` - 管网更新(基础) -- `POST /api/v1/pressure_regulation/` - 压力调节(高级) -- `POST /api/v1/pressure_sensor_placement_kmeans/` - 压力传感器放置-KMeans聚类分析(高级) -- `POST /api/v1/pressure_sensor_placement_sensitivity/` - 压力传感器放置-灵敏度分析(高级) -- `GET /api/v1/pressureregulation/` - 压力调节(基础) -- `GET /api/v1/pressuresensorplacementkmeans/` - 压力传感器放置-KMeans聚类分析(基础) -- `GET /api/v1/pressuresensorplacementsensitivity/` - 压力传感器放置-灵敏度分析(基础) -- `POST /api/v1/project_management/` - 项目管理(高级) -- `GET /api/v1/projectmanagement/` - 项目管理(基础) -- `POST /api/v1/pump_failure/` - 泵故障管理 -- `GET /api/v1/runinp/` - 运行INP文件 -- `GET /api/v1/runproject/` - 运行项目模拟 -- `GET /api/v1/runprojectreturndict/` - 运行项目模拟(返回字典) -- `POST /api/v1/runsimulationmanuallybydate/` - 手动运行日期指定模拟 -- `POST /api/v1/scheduling_analysis/` - 排程分析 -- `POST /api/v1/sensorplacementscheme/create` - 传感器放置方案创建 -- `GET /api/v1/valve_close_analysis/` - 阀门关闭分析(高级) -- `GET /api/v1/valve_isolation_analysis/` - 阀门隔离分析 -- `GET /api/v1/valvecloseanalysis/` - 阀门关闭分析(基础) - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../../business/network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/api-spec.md b/.github/skills/api-spec.md deleted file mode 100644 index 7395a1d..0000000 --- a/.github/skills/api-spec.md +++ /dev/null @@ -1,671 +0,0 @@ -# API Skills 索引(领域 -> 场景 -> 操作) - -说明:操作层 Action 以 endpoint 模块为单位。 - -## ai/copilot-assistant - -### Action: `copilot` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/copilot/chat/stream` | Copilot 聊天流式响应 | - -## analytics/scada-operations - -### Action: `scada` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addscadadevice/` | 添加SCADA设备 | -| POST | `/api/v1/addscadadevicedata/` | 添加SCADA设备数据 | -| POST | `/api/v1/addscadaelement/` | 添加SCADA元素映射 | -| POST | `/api/v1/cleanscadadevice/` | 清空SCADA设备表 | -| POST | `/api/v1/cleanscadadevicedata/` | 清空SCADA设备数据表 | -| POST | `/api/v1/cleanscadaelement/` | 清空SCADA元素映射表 | -| POST | `/api/v1/deletescadadevice/` | 删除SCADA设备 | -| POST | `/api/v1/deletescadadevicedata/` | 删除SCADA设备数据 | -| POST | `/api/v1/deletescadaelement/` | 删除SCADA元素映射 | -| GET | `/api/v1/getallscadadeviceids/` | 获取所有SCADA设备ID | -| GET | `/api/v1/getallscadadevices/` | 获取所有SCADA设备 | -| GET | `/api/v1/getallscadainfo/` | 获取所有SCADA信息 | -| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA属性 | -| GET | `/api/v1/getscadadevice/` | 获取SCADA设备 | -| GET | `/api/v1/getscadadevicedata/` | 获取SCADA设备数据 | -| GET | `/api/v1/getscadadevicedataschema/` | 获取SCADA设备数据架构 | -| GET | `/api/v1/getscadadeviceschema/` | 获取SCADA设备架构 | -| GET | `/api/v1/getscadaelement/` | 获取单个SCADA元素映射 | -| GET | `/api/v1/getscadaelements/` | 获取所有SCADA元素映射 | -| GET | `/api/v1/getscadaelementschema/` | 获取SCADA元素架构 | -| GET | `/api/v1/getscadainfo/` | 获取SCADA信息 | -| GET | `/api/v1/getscadainfoschema/` | 获取SCADA信息架构 | -| GET | `/api/v1/getscadaproperties/` | 获取SCADA属性 | -| POST | `/api/v1/scada/batch` | 批量插入SCADA监测数据 | -| DELETE | `/api/v1/scada/by-id-time-range` | 按设备ID和时间范围删除SCADA数据 | -| GET | `/api/v1/scada/by-ids-field-time-range` | 按设备ID、字段和时间范围查询SCADA数据 | -| GET | `/api/v1/scada/by-ids-time-range` | 按设备ID和时间范围查询SCADA数据 | -| PATCH | `/api/v1/scada/{device_id}/field` | 更新SCADA设备字段 | -| POST | `/api/v1/setscadadevice/` | 更新SCADA设备 | -| POST | `/api/v1/setscadadevicedata/` | 更新SCADA设备数据 | -| POST | `/api/v1/setscadaelement/` | 更新SCADA元素映射 | - -## analytics/simulation-analysis - -### Action: `burst_detection` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/burst-detection/detect/` | 执行爆管检测 | -| GET | `/api/v1/burst-detection/schemes/` | 查询爆管检测方案列表 | -| GET | `/api/v1/burst-detection/schemes/{scheme_name}` | 获取爆管检测方案详情 | - -### Action: `burst_location` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/burst-location/locate/` | 执行爆管定位 | -| GET | `/api/v1/burst-location/schemes/` | 查询爆管定位方案列表 | -| GET | `/api/v1/burst-location/schemes/{scheme_name}` | 获取爆管定位方案详情 | - -### Action: `leakage` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/leakage/identify/` | 执行漏损识别 | -| GET | `/api/v1/leakage/schemes/` | 查询漏损识别方案列表 | -| GET | `/api/v1/leakage/schemes/{scheme_name}` | 获取漏损识别方案详情 | - -### Action: `risk` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getnetworkpiperiskprobabilitynow/` | 获取整个网络的管道风险概率 | -| GET | `/api/v1/getpiperiskprobability/` | 获取管道风险概率历史 | -| GET | `/api/v1/getpiperiskprobabilitygeometries/` | 获取管道风险几何信息 | -| GET | `/api/v1/getpiperiskprobabilitynow/` | 获取管道当前风险概率 | -| GET | `/api/v1/getpipesriskprobability/` | 批量获取多条管道风险概率 | - -### Action: `simulation` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/age_analysis/` | 水龄分析(高级) | -| GET | `/api/v1/ageanalysis/` | 水龄分析(基础) | -| GET | `/api/v1/burst_analysis/` | 爆管分析(高级) | -| GET | `/api/v1/burstanalysis/` | 爆管分析(基础) | -| GET | `/api/v1/contaminant_simulation/` | 污染物模拟 | -| POST | `/api/v1/daily_scheduling_analysis/` | 日排程分析 | -| GET | `/api/v1/dumpoutput/` | 导出模拟输出 | -| GET | `/api/v1/flushing_analysis/` | 冲洗分析(高级) | -| GET | `/api/v1/flushinganalysis/` | 冲洗分析(基础) | -| POST | `/api/v1/network_project/` | 导入网络项目 | -| POST | `/api/v1/network_update/` | 管网更新(高级) | -| GET | `/api/v1/networkupdate/` | 管网更新(基础) | -| POST | `/api/v1/pressure_regulation/` | 压力调节(高级) | -| POST | `/api/v1/pressure_sensor_placement_kmeans/` | 压力传感器放置-KMeans聚类分析(高级) | -| POST | `/api/v1/pressure_sensor_placement_sensitivity/` | 压力传感器放置-灵敏度分析(高级) | -| GET | `/api/v1/pressureregulation/` | 压力调节(基础) | -| GET | `/api/v1/pressuresensorplacementkmeans/` | 压力传感器放置-KMeans聚类分析(基础) | -| GET | `/api/v1/pressuresensorplacementsensitivity/` | 压力传感器放置-灵敏度分析(基础) | -| POST | `/api/v1/project_management/` | 项目管理(高级) | -| GET | `/api/v1/projectmanagement/` | 项目管理(基础) | -| POST | `/api/v1/pump_failure/` | 泵故障管理 | -| GET | `/api/v1/runinp/` | 运行INP文件 | -| GET | `/api/v1/runproject/` | 运行项目模拟 | -| GET | `/api/v1/runprojectreturndict/` | 运行项目模拟(返回字典) | -| POST | `/api/v1/runsimulationmanuallybydate/` | 手动运行日期指定模拟 | -| POST | `/api/v1/scheduling_analysis/` | 排程分析 | -| POST | `/api/v1/sensorplacementscheme/create` | 传感器放置方案创建 | -| GET | `/api/v1/valve_close_analysis/` | 阀门关闭分析(高级) | -| GET | `/api/v1/valve_isolation_analysis/` | 阀门隔离分析 | -| GET | `/api/v1/valvecloseanalysis/` | 阀门关闭分析(基础) | - -## business/component-config - -### Action: `controls` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getcontrolproperties/` | 获取控制属性 | -| GET | `/api/v1/getcontrolschema/` | 获取控制架构 | -| GET | `/api/v1/getruleproperties/` | 获取规则属性 | -| GET | `/api/v1/getruleschema/` | 获取规则架构 | -| POST | `/api/v1/setcontrolproperties/` | 设置控制属性 | -| POST | `/api/v1/setruleproperties/` | 设置规则属性 | - -### Action: `curves` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addcurve/` | 添加曲线 | -| POST | `/api/v1/deletecurve/` | 删除曲线 | -| GET | `/api/v1/getcurveproperties/` | 获取曲线属性 | -| GET | `/api/v1/getcurves/` | 获取所有曲线 | -| GET | `/api/v1/getcurveschema` | 获取曲线架构 | -| GET | `/api/v1/iscurve/` | 检查曲线存在性 | -| POST | `/api/v1/setcurveproperties/` | 设置曲线属性 | - -### Action: `options` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getenergyproperties/` | 获取能耗选项属性 | -| GET | `/api/v1/getenergyschema/` | 获取能耗选项架构 | -| GET | `/api/v1/getoptionproperties/` | 获取选项属性 | -| GET | `/api/v1/getoptionschema/` | 获取选项架构 | -| GET | `/api/v1/getpumpenergyproperties/` | 获取泵能耗属性 | -| GET | `/api/v1/getpumpenergyschema/` | 获取泵能耗选项架构 | -| GET | `/api/v1/gettimeproperties/` | 获取时间选项属性 | -| GET | `/api/v1/gettimeschema` | 获取时间选项架构 | -| POST | `/api/v1/setenergyproperties/` | 设置能耗选项属性 | -| POST | `/api/v1/setoptionproperties/` | 设置选项属性 | -| GET | `/api/v1/setpumpenergyproperties/` | 设置泵能耗属性 | -| POST | `/api/v1/settimeproperties/` | 设置时间选项属性 | - -### Action: `patterns` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addpattern/` | 添加模式 | -| POST | `/api/v1/deletepattern/` | 删除模式 | -| GET | `/api/v1/getpatternproperties/` | 获取模式属性 | -| GET | `/api/v1/getpatterns/` | 获取所有模式 | -| GET | `/api/v1/getpatternschema` | 获取模式架构 | -| GET | `/api/v1/ispattern/` | 检查模式存在性 | -| POST | `/api/v1/setpatternproperties/` | 设置模式属性 | - -### Action: `quality` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addmixing/` | 添加混合 | -| POST | `/api/v1/addsource/` | 添加水源 | -| POST | `/api/v1/deletemixing/` | 删除混合 | -| POST | `/api/v1/deletesource/` | 删除水源 | -| GET | `/api/v1/getemitterproperties/` | 获取发射器属性 | -| GET | `/api/v1/getemitterschema` | 获取发射器架构 | -| GET | `/api/v1/getmixing/` | 获取混合属性 | -| GET | `/api/v1/getmixingschema/` | 获取混合架构 | -| GET | `/api/v1/getpipereaction/` | 获取管道反应属性 | -| GET | `/api/v1/getpipereactionschema/` | 获取管道反应架构 | -| GET | `/api/v1/getqualityproperties/` | 获取水质属性 | -| GET | `/api/v1/getqualityschema/` | 获取水质架构 | -| GET | `/api/v1/getreaction/` | 获取反应属性 | -| GET | `/api/v1/getreactionschema/` | 获取反应架构 | -| GET | `/api/v1/getsource/` | 获取水源属性 | -| GET | `/api/v1/getsourcechema/` | 获取水源架构 | -| GET | `/api/v1/gettankreaction/` | 获取水池反应属性 | -| GET | `/api/v1/gettankreactionschema/` | 获取水池反应架构 | -| POST | `/api/v1/setemitterproperties/` | 设置发射器属性 | -| POST | `/api/v1/setmixing/` | 设置混合属性 | -| POST | `/api/v1/setpipereaction/` | 设置管道反应属性 | -| POST | `/api/v1/setqualityproperties/` | 设置水质属性 | -| POST | `/api/v1/setreaction/` | 设置反应属性 | -| POST | `/api/v1/setsource/` | 设置水源属性 | -| POST | `/api/v1/settankreaction/` | 设置水池反应属性 | - -### Action: `visuals` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addlabel/` | 添加标签 | -| POST | `/api/v1/addvertex/` | 添加图形元素 | -| POST | `/api/v1/deletelabel/` | 删除标签 | -| POST | `/api/v1/deletevertex/` | 删除图形元素 | -| GET | `/api/v1/getallvertexlinks/` | 获取所有图形元素链接 | -| GET | `/api/v1/getallvertices/` | 获取所有图形元素 | -| GET | `/api/v1/getbackdropproperties/` | 获取背景属性 | -| GET | `/api/v1/getbackdropschema/` | 获取背景架构 | -| GET | `/api/v1/getlabelproperties/` | 获取标签属性 | -| GET | `/api/v1/getlabelschema/` | 获取标签架构 | -| GET | `/api/v1/getvertexproperties/` | 获取图形元素属性 | -| GET | `/api/v1/getvertexschema/` | 获取图形元素架构 | -| POST | `/api/v1/setbackdropproperties/` | 设置背景属性 | -| POST | `/api/v1/setlabelproperties/` | 设置标签属性 | -| POST | `/api/v1/setvertexproperties/` | 设置图形元素属性 | - -## business/identity-access - -### Action: `auth` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/auth/login` | login | -| POST | `/api/v1/auth/login/simple` | login_simple | -| GET | `/api/v1/auth/me` | get_current_user_info | -| POST | `/api/v1/auth/refresh` | refresh_token | -| POST | `/api/v1/auth/register` | register | - -### Action: `user_management` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/users/` | 列出所有用户 | -| DELETE | `/api/v1/users/{user_id}` | 删除用户 | -| GET | `/api/v1/users/{user_id}` | 获取用户详情 | -| PUT | `/api/v1/users/{user_id}` | 更新用户信息 | -| POST | `/api/v1/users/{user_id}/activate` | 激活用户 | -| POST | `/api/v1/users/{user_id}/deactivate` | 停用用户 | - -### Action: `users` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getallusers/` | 获取所有用户 | -| GET | `/api/v1/getuser/` | 获取单个用户 | -| GET | `/api/v1/getuserschema/` | 获取用户模式 | - -## business/network-assets - -### Action: `demands` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/calculatedemandtonetwork/` | 计算需水量到整网分配 | -| GET | `/api/v1/calculatedemandtonodes/` | 计算需水量到节点分配 | -| GET | `/api/v1/calculatedemandtoregion/` | 计算需水量到区域分配 | -| GET | `/api/v1/getdemandproperties/` | 获取需水量属性 | -| GET | `/api/v1/getdemandschema` | 获取需水量属性架构 | -| POST | `/api/v1/setdemandproperties/` | 设置需水量属性 | - -### Action: `general` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/deletelink/` | 删除管线 | -| POST | `/api/v1/deletenode/` | 删除节点 | -| GET | `/api/v1/getallscadaproperties/` | 获取所有SCADA点属性 | -| GET | `/api/v1/getelementproperties/` | 获取元素属性 | -| GET | `/api/v1/getelementpropertieswithtype/` | 获取指定类型元素属性 | -| GET | `/api/v1/getelementtype/` | 获取元素类型 | -| GET | `/api/v1/getelementtypevalue/` | 获取元素类型值 | -| GET | `/api/v1/getlinkproperties/` | 获取管线属性 | -| GET | `/api/v1/getlinks/` | 获取所有管线 | -| GET | `/api/v1/getlinktype/` | 获取管线类型 | -| GET | `/api/v1/getnodelinks/` | 获取节点的关联管线 | -| GET | `/api/v1/getnodeproperties/` | 获取节点属性 | -| GET | `/api/v1/getnodes/` | 获取所有节点 | -| GET | `/api/v1/getnodetype/` | 获取节点类型 | -| GET | `/api/v1/getscadaproperties/` | 获取SCADA点属性 | -| GET | `/api/v1/getstatus/` | 获取管线状态 | -| GET | `/api/v1/getstatusschema` | 获取状态属性架构 | -| GET | `/api/v1/gettitle/` | 获取水网标题属性 | -| GET | `/api/v1/gettitleschema/` | 获取标题属性架构 | -| GET | `/api/v1/isjunction/` | 检查是否为接点 | -| GET | `/api/v1/islink/` | 检查管线有效性 | -| GET | `/api/v1/isnode/` | 检查节点有效性 | -| GET | `/api/v1/ispipe/` | 检查是否为管道 | -| GET | `/api/v1/ispump/` | 检查是否为泵 | -| GET | `/api/v1/isreservoir/` | 检查是否为水源 | -| GET | `/api/v1/istank/` | 检查是否为蓄水池 | -| GET | `/api/v1/isvalve/` | 检查是否为阀门 | -| POST | `/api/v1/setstatus/` | 设置管线状态 | -| GET | `/api/v1/settitle/` | 设置水网标题属性 | - -### Action: `geometry` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getmajornodecoords/` | 获取主要节点坐标 | -| GET | `/api/v1/getmajorpipenodes/` | 获取主要管道节点 | -| GET | `/api/v1/getnetworkgeometries/` | 获取完整网络几何信息 | -| GET | `/api/v1/getnetworkinextent/` | 获取范围内的网络元素 | -| GET | `/api/v1/getnetworklinknodes/` | 获取网络管线节点 | -| GET | `/api/v1/getnodecoord/` | 获取节点坐标 | - -### Action: `junctions` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addjunction/` | 添加节点 | -| POST | `/api/v1/deletejunction/` | 删除节点 | -| GET | `/api/v1/getalljunctionproperties/` | 获取所有节点属性 | -| GET | `/api/v1/getjunctioncoord/` | 获取节点坐标 | -| GET | `/api/v1/getjunctiondemand/` | 获取节点需水量 | -| GET | `/api/v1/getjunctionelevation/` | 获取节点标高 | -| GET | `/api/v1/getjunctionpattern/` | 获取节点需水模式 | -| GET | `/api/v1/getjunctionproperties/` | 获取节点属性 | -| GET | `/api/v1/getjunctionschema` | 获取节点架构 | -| GET | `/api/v1/getjunctionx/` | 获取节点 X 坐标 | -| GET | `/api/v1/getjunctiony/` | 获取节点 Y 坐标 | -| POST | `/api/v1/setjunctioncoord/` | 设置节点坐标 | -| POST | `/api/v1/setjunctiondemand/` | 设置节点需水量 | -| POST | `/api/v1/setjunctionelevation/` | 设置节点标高 | -| POST | `/api/v1/setjunctionpattern/` | 设置节点需水模式 | -| POST | `/api/v1/setjunctionproperties/` | 批量设置节点属性 | -| POST | `/api/v1/setjunctionx/` | 设置节点 X 坐标 | -| POST | `/api/v1/setjunctiony/` | 设置节点 Y 坐标 | - -### Action: `pipes` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addpipe/` | 添加管道 | -| POST | `/api/v1/deletepipe/` | 删除管道 | -| GET | `/api/v1/getallpipeproperties/` | 获取所有管道属性 | -| GET | `/api/v1/getpipediameter/` | 获取管道管径 | -| GET | `/api/v1/getpipelength/` | 获取管道长度 | -| GET | `/api/v1/getpipeminorloss/` | 获取管道局部阻力系数 | -| GET | `/api/v1/getpipenode1/` | 获取管道起始节点 | -| GET | `/api/v1/getpipenode2/` | 获取管道终止节点 | -| GET | `/api/v1/getpipeproperties/` | 获取管道属性 | -| GET | `/api/v1/getpiperoughness/` | 获取管道粗糙度 | -| GET | `/api/v1/getpipeschema` | 获取管道模式 | -| GET | `/api/v1/getpipestatus/` | 获取管道状态 | -| POST | `/api/v1/setpipediameter/` | 设置管道管径 | -| POST | `/api/v1/setpipelength/` | 设置管道长度 | -| POST | `/api/v1/setpipeminorloss/` | 设置管道局部阻力系数 | -| POST | `/api/v1/setpipenode1/` | 设置管道起始节点 | -| POST | `/api/v1/setpipenode2/` | 设置管道终止节点 | -| POST | `/api/v1/setpipeproperties/` | 设置管道属性 | -| POST | `/api/v1/setpiperoughness/` | 设置管道粗糙度 | -| POST | `/api/v1/setpipestatus/` | 设置管道状态 | - -### Action: `pumps` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addpump/` | 添加水泵 | -| POST | `/api/v1/deletepump/` | 删除水泵 | -| GET | `/api/v1/getallpumpproperties/` | 获取所有水泵属性 | -| GET | `/api/v1/getpumpnode1/` | 获取水泵起始节点 | -| GET | `/api/v1/getpumpnode2/` | 获取水泵终止节点 | -| GET | `/api/v1/getpumpproperties/` | 获取水泵属性 | -| GET | `/api/v1/getpumpschema` | 获取水泵模式 | -| POST | `/api/v1/setpumpnode1/` | 设置水泵起始节点 | -| POST | `/api/v1/setpumpnode2/` | 设置水泵终止节点 | -| POST | `/api/v1/setpumpproperties/` | 设置水泵属性 | - -### Action: `regions` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/adddistrictmeteringarea/` | 添加新DMA | -| POST | `/api/v1/addregion/` | 添加新区域 | -| POST | `/api/v1/addservicearea/` | 添加新服务区 | -| POST | `/api/v1/addvirtualdistrict/` | 添加新虚拟分区 | -| GET | `/api/v1/calculatedistrictmeteringarea/` | 计算DMA分区 | -| GET | `/api/v1/calculatedistrictmeteringareafornetwork/` | 计算整网DMA分区 | -| GET | `/api/v1/calculatedistrictmeteringareafornodes/` | 计算节点DMA分区 | -| GET | `/api/v1/calculatedistrictmeteringareaforregion/` | 计算区域内DMA分区 | -| GET | `/api/v1/calculateregion/` | 计算区域 | -| GET | `/api/v1/calculateservicearea/` | 计算服务区 | -| GET | `/api/v1/calculatevirtualdistrict/` | 计算虚拟分区 | -| POST | `/api/v1/deletedistrictmeteringarea/` | 删除DMA | -| POST | `/api/v1/deleteregion/` | 删除区域 | -| POST | `/api/v1/deleteservicearea/` | 删除服务区 | -| POST | `/api/v1/deletevirtualdistrict/` | 删除虚拟分区 | -| POST | `/api/v1/generatedistrictmeteringarea/` | 生成DMA分区 | -| POST | `/api/v1/generateregion/` | 生成区域分区 | -| POST | `/api/v1/generateservicearea/` | 生成服务区分区 | -| POST | `/api/v1/generatesubdistrictmeteringarea/` | 生成DMA子分区 | -| POST | `/api/v1/generatevirtualdistrict/` | 生成虚拟分区 | -| GET | `/api/v1/getalldistrictmeteringareaids/` | 获取所有DMA ID | -| GET | `/api/v1/getalldistrictmeteringareas/` | 获取所有DMA | -| GET | `/api/v1/getallregions/` | 获取所有区域 | -| GET | `/api/v1/getallserviceareas/` | 获取所有服务区 | -| GET | `/api/v1/getallvirtualdistrict/` | 获取所有虚拟分区 | -| GET | `/api/v1/getdistrictmeteringarea/` | 获取DMA信息 | -| GET | `/api/v1/getdistrictmeteringareaschema/` | 获取DMA属性架构 | -| GET | `/api/v1/getregion/` | 获取区域信息 | -| GET | `/api/v1/getregionschema/` | 获取区域属性架构 | -| GET | `/api/v1/getservicearea/` | 获取服务区信息 | -| GET | `/api/v1/getserviceareaschema/` | 获取服务区属性架构 | -| GET | `/api/v1/getvirtualdistrict/` | 获取虚拟分区信息 | -| GET | `/api/v1/getvirtualdistrictschema/` | 获取虚拟分区属性架构 | -| POST | `/api/v1/setdistrictmeteringarea/` | 设置DMA属性 | -| POST | `/api/v1/setregion/` | 设置区域属性 | -| POST | `/api/v1/setservicearea/` | 设置服务区属性 | -| POST | `/api/v1/setvirtualdistrict/` | 设置虚拟分区属性 | - -### Action: `reservoirs` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addreservoir/` | 添加水库 | -| POST | `/api/v1/deletereservoir/` | 删除水库 | -| GET | `/api/v1/getallreservoirproperties/` | 获取所有水库属性 | -| GET | `/api/v1/getreservoircoord/` | 获取水库坐标 | -| GET | `/api/v1/getreservoirhead/` | 获取水库水头 | -| GET | `/api/v1/getreservoirpattern/` | 获取水库模式 | -| GET | `/api/v1/getreservoirproperties/` | 获取水库属性 | -| GET | `/api/v1/getreservoirschema` | 获取水库模式 | -| GET | `/api/v1/getreservoirx/` | 获取水库X坐标 | -| GET | `/api/v1/getreservoiry/` | 获取水库Y坐标 | -| POST | `/api/v1/setreservoircoord/` | 设置水库坐标 | -| POST | `/api/v1/setreservoirhead/` | 设置水库水头 | -| POST | `/api/v1/setreservoirpattern/` | 设置水库模式 | -| POST | `/api/v1/setreservoirproperties/` | 设置水库属性 | -| POST | `/api/v1/setreservoirx/` | 设置水库X坐标 | -| POST | `/api/v1/setreservoiry/` | 设置水库Y坐标 | - -### Action: `tags` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/gettag/` | 获取标签信息 | -| GET | `/api/v1/gettags/` | 获取所有标签 | -| GET | `/api/v1/gettagschema/` | 获取标签属性架构 | -| POST | `/api/v1/settag/` | 设置标签 | - -### Action: `tanks` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addtank/` | 新增水箱 | -| POST | `/api/v1/deletetank/` | 删除水箱 | -| GET | `/api/v1/getalltankproperties/` | 获取所有水箱属性 | -| GET | `/api/v1/gettankcoord/` | 获取水箱坐标 | -| GET | `/api/v1/gettankdiameter/` | 获取水箱直径 | -| GET | `/api/v1/gettankelevation/` | 获取水箱标高 | -| GET | `/api/v1/gettankinitlevel/` | 获取水箱初始水位 | -| GET | `/api/v1/gettankmaxlevel/` | 获取水箱最大水位 | -| GET | `/api/v1/gettankminlevel/` | 获取水箱最小水位 | -| GET | `/api/v1/gettankminvol/` | 获取水箱最小体积 | -| GET | `/api/v1/gettankoverflow/` | 获取水箱溢流口 | -| GET | `/api/v1/gettankproperties/` | 获取水箱属性 | -| GET | `/api/v1/gettankschema` | 获取水箱模式 | -| GET | `/api/v1/gettankvolcurve/` | 获取水箱容积曲线 | -| GET | `/api/v1/gettankx/` | 获取水箱X坐标 | -| GET | `/api/v1/gettanky/` | 获取水箱Y坐标 | -| POST | `/api/v1/settankcoord/` | 设置水箱坐标 | -| POST | `/api/v1/settankdiameter/` | 设置水箱直径 | -| POST | `/api/v1/settankelevation/` | 设置水箱标高 | -| POST | `/api/v1/settankinitlevel/` | 设置水箱初始水位 | -| POST | `/api/v1/settankmaxlevel/` | 设置水箱最大水位 | -| POST | `/api/v1/settankminlevel/` | 设置水箱最小水位 | -| POST | `/api/v1/settankminvol/` | 设置水箱最小体积 | -| POST | `/api/v1/settankoverflow/` | 设置水箱溢流口 | -| POST | `/api/v1/settankproperties/` | 设置水箱属性 | -| POST | `/api/v1/settankvolcurve/` | 设置水箱容积曲线 | -| POST | `/api/v1/settankx/` | 设置水箱X坐标 | -| POST | `/api/v1/settanky/` | 设置水箱Y坐标 | - -### Action: `valves` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/addvalve/` | 添加阀门 | -| POST | `/api/v1/deletevalve/` | 删除阀门 | -| GET | `/api/v1/getallvalveproperties/` | 获取所有阀门属性 | -| GET | `/api/v1/getvalvediameter/` | 获取阀门直径 | -| GET | `/api/v1/getvalveminorloss/` | 获取阀门损失系数 | -| GET | `/api/v1/getvalvenode1/` | 获取阀门起点节点 | -| GET | `/api/v1/getvalvenode2/` | 获取阀门终点节点 | -| GET | `/api/v1/getvalveproperties/` | 获取阀门所有属性 | -| GET | `/api/v1/getvalveschema` | 获取阀门架构 | -| GET | `/api/v1/getvalvesetting/` | 获取阀门开度 | -| GET | `/api/v1/getvalvetype/` | 获取阀门类型 | -| POST | `/api/v1/setvalvenode1/` | 设置阀门起点节点 | -| POST | `/api/v1/setvalvenode2/` | 设置阀门终点节点 | -| POST | `/api/v1/setvalvenodediameter/` | 设置阀门直径 | -| POST | `/api/v1/setvalveproperties/` | 批量设置阀门属性 | -| POST | `/api/v1/setvalvesetting/` | 设置阀门开度 | -| POST | `/api/v1/setvalvetype/` | 设置阀门类型 | - -## business/project-workspace - -### Action: `extension` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getallextensiondata/` | 获取所有扩展数据 | -| GET | `/api/v1/getallextensiondatakeys/` | 获取所有扩展数据键 | -| GET | `/api/v1/getextensiondata/` | 获取指定扩展数据 | -| POST | `/api/v1/setextensiondata/` | 设置扩展数据 | - -### Action: `misc` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getallburstlocateresults/` | 获取所有爆管定位结果 | -| GET | `/api/v1/getallsensorplacements/` | 获取所有传感器位置 | -| GET | `/api/v1/getjson/` | 获取JSON示例 | -| GET | `/api/v1/getrealtimedata/` | 获取实时数据 | -| GET | `/api/v1/getsimulationresult/` | 获取模拟结果 | -| POST | `/api/v1/test_dict/` | 测试字典处理 | - -### Action: `project` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/closeproject/` | 关闭项目 | -| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | -| GET | `/api/v1/convertv3tov2/` | 转换 INP V3 为 V2 | -| POST | `/api/v1/copyproject/` | 复制项目 | -| POST | `/api/v1/createproject/` | 创建新项目 | -| POST | `/api/v1/deleteproject/` | 删除项目 | -| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | -| GET | `/api/v1/downloadinp/` | 下载 INP 文件 | -| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | -| GET | `/api/v1/dumpinp/` | 导出项目到 INP 文件 | -| GET | `/api/v1/exportinp/` | 导出项目为 ChangeSet | -| GET | `/api/v1/haveproject/` | 检查项目是否存在 | -| POST | `/api/v1/importinp/` | 导入 INP 文件内容 | -| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | -| GET | `/api/v1/isprojectlocked/` | 检查项目是否被锁定 | -| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | -| GET | `/api/v1/isprojectlockedbyme/` | 检查项目是否被当前用户锁定 | -| GET | `/api/v1/isprojectopen/` | 检查项目是否已打开 | -| GET | `/api/v1/listprojects/` | 获取项目列表 | -| POST | `/api/v1/lockproject/` | 锁定项目 | -| POST | `/api/v1/lockproject/` | 锁定项目 | -| POST | `/api/v1/openproject/` | 打开项目 | -| GET | `/api/v1/project_info/` | 获取项目信息 | -| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | -| POST | `/api/v1/readinp/` | 读取 INP 文件到项目 | -| POST | `/api/v1/unlockproject/` | 解锁项目 | -| POST | `/api/v1/unlockproject/` | 解锁项目 | -| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | -| POST | `/api/v1/uploadinp/` | 上传 INP 文件 | - -### Action: `project_data` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/burst-locate-result` | 获取爆管定位结果 | -| GET | `/api/v1/burst-locate-result/{burst_incident}` | 按事件查询爆管定位结果 | -| GET | `/api/v1/scada-info` | 获取SCADA信息 | -| GET | `/api/v1/scheme-list` | 获取方案列表 | - -### Action: `schemes` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/getallschemes/` | 获取所有方案 | -| GET | `/api/v1/getscheme/` | 获取单个方案 | -| GET | `/api/v1/getschemeschema/` | 获取方案模式 | - -### Action: `snapshots` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/batch/` | 执行批量命令 | -| POST | `/api/v1/compressedbatch/` | 执行压缩批量命令 | -| GET | `/api/v1/getcurrentoperationid/` | 获取当前操作ID | -| GET | `/api/v1/getrestoreoperation/` | 获取恢复操作ID | -| GET | `/api/v1/getsnapshots/` | 获取快照列表 | -| GET | `/api/v1/havesnapshot/` | 检查快照是否存在 | -| GET | `/api/v1/havesnapshotforcurrentoperation/` | 检查当前操作快照是否存在 | -| GET | `/api/v1/havesnapshotforoperation/` | 检查操作快照是否存在 | -| POST | `/api/v1/pickoperation/` | 选择操作 | -| POST | `/api/v1/picksnapshot/` | 选择快照 | -| POST | `/api/v1/redo/` | 重做操作 | -| POST | `/api/v1/setrestoreoperation/` | 设置恢复操作ID | -| GET | `/api/v1/syncwithserver/` | 与服务器同步 | -| POST | `/api/v1/takenapshotforcurrentoperation` | 为当前操作创建快照(兼容模式) | -| POST | `/api/v1/takesnapshot/` | 创建快照 | -| POST | `/api/v1/takesnapshotforcurrentoperation` | 为当前操作创建快照 | -| POST | `/api/v1/takesnapshotforoperation/` | 为操作创建快照 | -| POST | `/api/v1/undo/` | 撤销操作 | - -## data/timeseries-access - -### Action: `composite` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/composite/clean-scada` | 清洗SCADA监测数据 | -| GET | `/api/v1/composite/element-scada` | 获取管网元素关联的SCADA监测数据 | -| GET | `/api/v1/composite/element-simulation` | 获取管网元素的模拟数据 | -| GET | `/api/v1/composite/pipeline-health-prediction` | 预测管道健康状况 | -| GET | `/api/v1/composite/scada-simulation` | 获取SCADA关联的模拟数据 | - -### Action: `realtime` - -| Method | Path | Summary | -|---|---|---| -| DELETE | `/api/v1/realtime/links` | 删除实时管道数据 | -| GET | `/api/v1/realtime/links` | 查询实时管道数据 | -| POST | `/api/v1/realtime/links/batch` | 批量插入实时管道数据 | -| PATCH | `/api/v1/realtime/links/{link_id}/field` | 更新实时管道字段 | -| DELETE | `/api/v1/realtime/nodes` | 删除实时节点数据 | -| GET | `/api/v1/realtime/nodes` | 查询实时节点数据 | -| POST | `/api/v1/realtime/nodes/batch` | 批量插入实时节点数据 | -| GET | `/api/v1/realtime/query/by-id-time` | 按ID和时间查询实时模拟数据 | -| GET | `/api/v1/realtime/query/by-time-property` | 按时间和属性查询实时数据 | -| POST | `/api/v1/realtime/simulation/store` | 存储实时模拟结果 | - -### Action: `scheme` - -| Method | Path | Summary | -|---|---|---| -| DELETE | `/api/v1/scheme/links` | 删除方案管道数据 | -| GET | `/api/v1/scheme/links` | 查询方案管道数据 | -| POST | `/api/v1/scheme/links/batch` | 批量插入方案管道数据 | -| GET | `/api/v1/scheme/links/{link_id}/field` | 查询方案管道字段数据 | -| PATCH | `/api/v1/scheme/links/{link_id}/field` | 更新方案管道字段 | -| DELETE | `/api/v1/scheme/nodes` | 删除方案节点数据 | -| POST | `/api/v1/scheme/nodes/batch` | 批量插入方案节点数据 | -| GET | `/api/v1/scheme/nodes/{node_id}/field` | 查询方案节点字段数据 | -| PATCH | `/api/v1/scheme/nodes/{node_id}/field` | 更新方案节点字段 | -| GET | `/api/v1/scheme/query/by-id-time` | 按ID和时间查询方案模拟数据 | -| GET | `/api/v1/scheme/query/by-scheme-time-property` | 按方案、时间和属性查询数据 | -| POST | `/api/v1/scheme/simulation/store` | 存储方案模拟结果 | - -## platform/governance-observability - -### Action: `audit` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/audit/logs` | 查询审计日志 | -| GET | `/api/v1/audit/logs/count` | 获取审计日志总数 | -| GET | `/api/v1/audit/logs/my` | 查询我的审计日志 | - -### Action: `cache` - -| Method | Path | Summary | -|---|---|---| -| POST | `/api/v1/clearallredis/` | 清除所有缓存 | -| POST | `/api/v1/clearrediskey/` | 清除单个缓存键 | -| POST | `/api/v1/clearrediskeys/` | 清除匹配的缓存键 | -| GET | `/api/v1/queryredis/` | 查询缓存键列表 | - -### Action: `meta` - -| Method | Path | Summary | -|---|---|---| -| GET | `/api/v1/meta/db/health` | 检查数据库健康状态 | -| GET | `/api/v1/meta/project` | 获取项目元数据 | -| GET | `/api/v1/meta/projects` | 列出用户项目 | - diff --git a/.github/skills/business/component-config/SKILL.md b/.github/skills/business/component-config/SKILL.md deleted file mode 100644 index f8fb63d..0000000 --- a/.github/skills/business/component-config/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: api-operations-business-component-config -description: 组件参数、控制规则、水质和可视化接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **business/component-config** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# Action Skills - -- `controls`: `controls/SKILL.md` -- `curves`: `curves/SKILL.md` -- `options`: `options/SKILL.md` -- `patterns`: `patterns/SKILL.md` -- `quality`: `quality/SKILL.md` -- `visuals`: `visuals/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `controls` -- 详情技能:`controls/SKILL.md` -- `GET /api/v1/getcontrolproperties/` - 获取控制属性 -- `GET /api/v1/getcontrolschema/` - 获取控制架构 -- `GET /api/v1/getruleproperties/` - 获取规则属性 -- `GET /api/v1/getruleschema/` - 获取规则架构 -- `POST /api/v1/setcontrolproperties/` - 设置控制属性 -- `POST /api/v1/setruleproperties/` - 设置规则属性 - -## Action: `curves` -- 详情技能:`curves/SKILL.md` -- `POST /api/v1/addcurve/` - 添加曲线 -- `POST /api/v1/deletecurve/` - 删除曲线 -- `GET /api/v1/getcurveproperties/` - 获取曲线属性 -- `GET /api/v1/getcurves/` - 获取所有曲线 -- `GET /api/v1/getcurveschema` - 获取曲线架构 -- `GET /api/v1/iscurve/` - 检查曲线存在性 -- `POST /api/v1/setcurveproperties/` - 设置曲线属性 - -## Action: `options` -- 详情技能:`options/SKILL.md` -- `GET /api/v1/getenergyproperties/` - 获取能耗选项属性 -- `GET /api/v1/getenergyschema/` - 获取能耗选项架构 -- `GET /api/v1/getoptionproperties/` - 获取选项属性 -- `GET /api/v1/getoptionschema/` - 获取选项架构 -- `GET /api/v1/getpumpenergyproperties/` - 获取泵能耗属性 -- `GET /api/v1/getpumpenergyschema/` - 获取泵能耗选项架构 -- `GET /api/v1/gettimeproperties/` - 获取时间选项属性 -- `GET /api/v1/gettimeschema` - 获取时间选项架构 -- `POST /api/v1/setenergyproperties/` - 设置能耗选项属性 -- `POST /api/v1/setoptionproperties/` - 设置选项属性 -- `GET /api/v1/setpumpenergyproperties/` - 设置泵能耗属性 -- `POST /api/v1/settimeproperties/` - 设置时间选项属性 - -## Action: `patterns` -- 详情技能:`patterns/SKILL.md` -- `POST /api/v1/addpattern/` - 添加模式 -- `POST /api/v1/deletepattern/` - 删除模式 -- `GET /api/v1/getpatternproperties/` - 获取模式属性 -- `GET /api/v1/getpatterns/` - 获取所有模式 -- `GET /api/v1/getpatternschema` - 获取模式架构 -- `GET /api/v1/ispattern/` - 检查模式存在性 -- `POST /api/v1/setpatternproperties/` - 设置模式属性 - -## Action: `quality` -- 详情技能:`quality/SKILL.md` -- `POST /api/v1/addmixing/` - 添加混合 -- `POST /api/v1/addsource/` - 添加水源 -- `POST /api/v1/deletemixing/` - 删除混合 -- `POST /api/v1/deletesource/` - 删除水源 -- `GET /api/v1/getemitterproperties/` - 获取发射器属性 -- `GET /api/v1/getemitterschema` - 获取发射器架构 -- `GET /api/v1/getmixing/` - 获取混合属性 -- `GET /api/v1/getmixingschema/` - 获取混合架构 -- `GET /api/v1/getpipereaction/` - 获取管道反应属性 -- `GET /api/v1/getpipereactionschema/` - 获取管道反应架构 -- `GET /api/v1/getqualityproperties/` - 获取水质属性 -- `GET /api/v1/getqualityschema/` - 获取水质架构 -- `GET /api/v1/getreaction/` - 获取反应属性 -- `GET /api/v1/getreactionschema/` - 获取反应架构 -- `GET /api/v1/getsource/` - 获取水源属性 -- `GET /api/v1/getsourcechema/` - 获取水源架构 -- `GET /api/v1/gettankreaction/` - 获取水池反应属性 -- `GET /api/v1/gettankreactionschema/` - 获取水池反应架构 -- `POST /api/v1/setemitterproperties/` - 设置发射器属性 -- `POST /api/v1/setmixing/` - 设置混合属性 -- `POST /api/v1/setpipereaction/` - 设置管道反应属性 -- `POST /api/v1/setqualityproperties/` - 设置水质属性 -- `POST /api/v1/setreaction/` - 设置反应属性 -- `POST /api/v1/setsource/` - 设置水源属性 -- `POST /api/v1/settankreaction/` - 设置水池反应属性 - -## Action: `visuals` -- 详情技能:`visuals/SKILL.md` -- `POST /api/v1/addlabel/` - 添加标签 -- `POST /api/v1/addvertex/` - 添加图形元素 -- `POST /api/v1/deletelabel/` - 删除标签 -- `POST /api/v1/deletevertex/` - 删除图形元素 -- `GET /api/v1/getallvertexlinks/` - 获取所有图形元素链接 -- `GET /api/v1/getallvertices/` - 获取所有图形元素 -- `GET /api/v1/getbackdropproperties/` - 获取背景属性 -- `GET /api/v1/getbackdropschema/` - 获取背景架构 -- `GET /api/v1/getlabelproperties/` - 获取标签属性 -- `GET /api/v1/getlabelschema/` - 获取标签架构 -- `GET /api/v1/getvertexproperties/` - 获取图形元素属性 -- `GET /api/v1/getvertexschema/` - 获取图形元素架构 -- `POST /api/v1/setbackdropproperties/` - 设置背景属性 -- `POST /api/v1/setlabelproperties/` - 设置标签属性 -- `POST /api/v1/setvertexproperties/` - 设置图形元素属性 - -# See Also - -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/controls/SKILL.md b/.github/skills/business/component-config/controls/SKILL.md deleted file mode 100644 index 14a8abc..0000000 --- a/.github/skills/business/component-config/controls/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: api-operations-business-component-config-controls -description: business/component-config 场景下 controls 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **controls** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/getcontrolproperties/` - 获取控制属性 -- `GET /api/v1/getcontrolschema/` - 获取控制架构 -- `GET /api/v1/getruleproperties/` - 获取规则属性 -- `GET /api/v1/getruleschema/` - 获取规则架构 -- `POST /api/v1/setcontrolproperties/` - 设置控制属性 -- `POST /api/v1/setruleproperties/` - 设置规则属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/curves/SKILL.md b/.github/skills/business/component-config/curves/SKILL.md deleted file mode 100644 index a863cbc..0000000 --- a/.github/skills/business/component-config/curves/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: api-operations-business-component-config-curves -description: business/component-config 场景下 curves 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **curves** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addcurve/` - 添加曲线 -- `POST /api/v1/deletecurve/` - 删除曲线 -- `GET /api/v1/getcurveproperties/` - 获取曲线属性 -- `GET /api/v1/getcurves/` - 获取所有曲线 -- `GET /api/v1/getcurveschema` - 获取曲线架构 -- `GET /api/v1/iscurve/` - 检查曲线存在性 -- `POST /api/v1/setcurveproperties/` - 设置曲线属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/options/SKILL.md b/.github/skills/business/component-config/options/SKILL.md deleted file mode 100644 index e852892..0000000 --- a/.github/skills/business/component-config/options/SKILL.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: api-operations-business-component-config-options -description: business/component-config 场景下 options 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **options** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/getenergyproperties/` - 获取能耗选项属性 -- `GET /api/v1/getenergyschema/` - 获取能耗选项架构 -- `GET /api/v1/getoptionproperties/` - 获取选项属性 -- `GET /api/v1/getoptionschema/` - 获取选项架构 -- `GET /api/v1/getpumpenergyproperties/` - 获取泵能耗属性 -- `GET /api/v1/getpumpenergyschema/` - 获取泵能耗选项架构 -- `GET /api/v1/gettimeproperties/` - 获取时间选项属性 -- `GET /api/v1/gettimeschema` - 获取时间选项架构 -- `POST /api/v1/setenergyproperties/` - 设置能耗选项属性 -- `POST /api/v1/setoptionproperties/` - 设置选项属性 -- `GET /api/v1/setpumpenergyproperties/` - 设置泵能耗属性 -- `POST /api/v1/settimeproperties/` - 设置时间选项属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/patterns/SKILL.md b/.github/skills/business/component-config/patterns/SKILL.md deleted file mode 100644 index c879de0..0000000 --- a/.github/skills/business/component-config/patterns/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: api-operations-business-component-config-patterns -description: business/component-config 场景下 patterns 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **patterns** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addpattern/` - 添加模式 -- `POST /api/v1/deletepattern/` - 删除模式 -- `GET /api/v1/getpatternproperties/` - 获取模式属性 -- `GET /api/v1/getpatterns/` - 获取所有模式 -- `GET /api/v1/getpatternschema` - 获取模式架构 -- `GET /api/v1/ispattern/` - 检查模式存在性 -- `POST /api/v1/setpatternproperties/` - 设置模式属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/quality/SKILL.md b/.github/skills/business/component-config/quality/SKILL.md deleted file mode 100644 index 1ddaf2d..0000000 --- a/.github/skills/business/component-config/quality/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: api-operations-business-component-config-quality -description: business/component-config 场景下 quality 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **quality** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addmixing/` - 添加混合 -- `POST /api/v1/addsource/` - 添加水源 -- `POST /api/v1/deletemixing/` - 删除混合 -- `POST /api/v1/deletesource/` - 删除水源 -- `GET /api/v1/getemitterproperties/` - 获取发射器属性 -- `GET /api/v1/getemitterschema` - 获取发射器架构 -- `GET /api/v1/getmixing/` - 获取混合属性 -- `GET /api/v1/getmixingschema/` - 获取混合架构 -- `GET /api/v1/getpipereaction/` - 获取管道反应属性 -- `GET /api/v1/getpipereactionschema/` - 获取管道反应架构 -- `GET /api/v1/getqualityproperties/` - 获取水质属性 -- `GET /api/v1/getqualityschema/` - 获取水质架构 -- `GET /api/v1/getreaction/` - 获取反应属性 -- `GET /api/v1/getreactionschema/` - 获取反应架构 -- `GET /api/v1/getsource/` - 获取水源属性 -- `GET /api/v1/getsourcechema/` - 获取水源架构 -- `GET /api/v1/gettankreaction/` - 获取水池反应属性 -- `GET /api/v1/gettankreactionschema/` - 获取水池反应架构 -- `POST /api/v1/setemitterproperties/` - 设置发射器属性 -- `POST /api/v1/setmixing/` - 设置混合属性 -- `POST /api/v1/setpipereaction/` - 设置管道反应属性 -- `POST /api/v1/setqualityproperties/` - 设置水质属性 -- `POST /api/v1/setreaction/` - 设置反应属性 -- `POST /api/v1/setsource/` - 设置水源属性 -- `POST /api/v1/settankreaction/` - 设置水池反应属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/component-config/visuals/SKILL.md b/.github/skills/business/component-config/visuals/SKILL.md deleted file mode 100644 index e45ae0f..0000000 --- a/.github/skills/business/component-config/visuals/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: api-operations-business-component-config-visuals -description: business/component-config 场景下 visuals 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **visuals** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addlabel/` - 添加标签 -- `POST /api/v1/addvertex/` - 添加图形元素 -- `POST /api/v1/deletelabel/` - 删除标签 -- `POST /api/v1/deletevertex/` - 删除图形元素 -- `GET /api/v1/getallvertexlinks/` - 获取所有图形元素链接 -- `GET /api/v1/getallvertices/` - 获取所有图形元素 -- `GET /api/v1/getbackdropproperties/` - 获取背景属性 -- `GET /api/v1/getbackdropschema/` - 获取背景架构 -- `GET /api/v1/getlabelproperties/` - 获取标签属性 -- `GET /api/v1/getlabelschema/` - 获取标签架构 -- `GET /api/v1/getvertexproperties/` - 获取图形元素属性 -- `GET /api/v1/getvertexschema/` - 获取图形元素架构 -- `POST /api/v1/setbackdropproperties/` - 设置背景属性 -- `POST /api/v1/setlabelproperties/` - 设置标签属性 -- `POST /api/v1/setvertexproperties/` - 设置图形元素属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/identity-access/SKILL.md b/.github/skills/business/identity-access/SKILL.md deleted file mode 100644 index e706592..0000000 --- a/.github/skills/business/identity-access/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: api-operations-business-identity-access -description: 认证、授权与用户管理接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **business/identity-access** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`DELETE`, `GET`, `POST`, `PUT` - -# Action Skills - -- `auth`: `auth/SKILL.md` -- `user_management`: `user_management/SKILL.md` -- `users`: `users/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `auth` -- 详情技能:`auth/SKILL.md` -- `POST /api/v1/auth/login` - login -- `POST /api/v1/auth/login/simple` - login_simple -- `GET /api/v1/auth/me` - get_current_user_info -- `POST /api/v1/auth/refresh` - refresh_token -- `POST /api/v1/auth/register` - register - -## Action: `user_management` -- 详情技能:`user_management/SKILL.md` -- `GET /api/v1/users/` - 列出所有用户 -- `DELETE /api/v1/users/{user_id}` - 删除用户 -- `GET /api/v1/users/{user_id}` - 获取用户详情 -- `PUT /api/v1/users/{user_id}` - 更新用户信息 -- `POST /api/v1/users/{user_id}/activate` - 激活用户 -- `POST /api/v1/users/{user_id}/deactivate` - 停用用户 - -## Action: `users` -- 详情技能:`users/SKILL.md` -- `GET /api/v1/getallusers/` - 获取所有用户 -- `GET /api/v1/getuser/` - 获取单个用户 -- `GET /api/v1/getuserschema/` - 获取用户模式 - -# See Also - -- 关联平台治理: `../../platform/governance-observability` -- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/identity-access/auth/SKILL.md b/.github/skills/business/identity-access/auth/SKILL.md deleted file mode 100644 index 4a5a391..0000000 --- a/.github/skills/business/identity-access/auth/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: api-operations-business-identity-access-auth -description: business/identity-access 场景下 auth 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **auth** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/auth/login` - login -- `POST /api/v1/auth/login/simple` - login_simple -- `GET /api/v1/auth/me` - get_current_user_info -- `POST /api/v1/auth/refresh` - refresh_token -- `POST /api/v1/auth/register` - register - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联平台治理: `../../platform/governance-observability` -- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/identity-access/user_management/SKILL.md b/.github/skills/business/identity-access/user_management/SKILL.md deleted file mode 100644 index faaf7ab..0000000 --- a/.github/skills/business/identity-access/user_management/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: api-operations-business-identity-access-user_management -description: business/identity-access 场景下 user_management 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **user_management** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`DELETE`, `GET`, `POST`, `PUT` - -# 操作列表 - -- `GET /api/v1/users/` - 列出所有用户 -- `DELETE /api/v1/users/{user_id}` - 删除用户 -- `GET /api/v1/users/{user_id}` - 获取用户详情 -- `PUT /api/v1/users/{user_id}` - 更新用户信息 -- `POST /api/v1/users/{user_id}/activate` - 激活用户 -- `POST /api/v1/users/{user_id}/deactivate` - 停用用户 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联平台治理: `../../platform/governance-observability` -- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/identity-access/users/SKILL.md b/.github/skills/business/identity-access/users/SKILL.md deleted file mode 100644 index 464a5ba..0000000 --- a/.github/skills/business/identity-access/users/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-business-identity-access-users -description: business/identity-access 场景下 users 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **users** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/getallusers/` - 获取所有用户 -- `GET /api/v1/getuser/` - 获取单个用户 -- `GET /api/v1/getuserschema/` - 获取用户模式 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联平台治理: `../../platform/governance-observability` -- 关联项目空间: `../project-workspace` diff --git a/.github/skills/business/network-assets/SKILL.md b/.github/skills/business/network-assets/SKILL.md deleted file mode 100644 index ec3d600..0000000 --- a/.github/skills/business/network-assets/SKILL.md +++ /dev/null @@ -1,260 +0,0 @@ ---- -name: api-operations-business-network-assets -description: 网络资产(节点/管段/设备)与空间拓扑接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **business/network-assets** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# Action Skills - -- `demands`: `demands/SKILL.md` -- `general`: `general/SKILL.md` -- `geometry`: `geometry/SKILL.md` -- `junctions`: `junctions/SKILL.md` -- `pipes`: `pipes/SKILL.md` -- `pumps`: `pumps/SKILL.md` -- `regions`: `regions/SKILL.md` -- `reservoirs`: `reservoirs/SKILL.md` -- `tags`: `tags/SKILL.md` -- `tanks`: `tanks/SKILL.md` -- `valves`: `valves/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `demands` -- 详情技能:`demands/SKILL.md` -- `GET /api/v1/calculatedemandtonetwork/` - 计算需水量到整网分配 -- `GET /api/v1/calculatedemandtonodes/` - 计算需水量到节点分配 -- `GET /api/v1/calculatedemandtoregion/` - 计算需水量到区域分配 -- `GET /api/v1/getdemandproperties/` - 获取需水量属性 -- `GET /api/v1/getdemandschema` - 获取需水量属性架构 -- `POST /api/v1/setdemandproperties/` - 设置需水量属性 - -## Action: `general` -- 详情技能:`general/SKILL.md` -- `POST /api/v1/deletelink/` - 删除管线 -- `POST /api/v1/deletenode/` - 删除节点 -- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA点属性 -- `GET /api/v1/getelementproperties/` - 获取元素属性 -- `GET /api/v1/getelementpropertieswithtype/` - 获取指定类型元素属性 -- `GET /api/v1/getelementtype/` - 获取元素类型 -- `GET /api/v1/getelementtypevalue/` - 获取元素类型值 -- `GET /api/v1/getlinkproperties/` - 获取管线属性 -- `GET /api/v1/getlinks/` - 获取所有管线 -- `GET /api/v1/getlinktype/` - 获取管线类型 -- `GET /api/v1/getnodelinks/` - 获取节点的关联管线 -- `GET /api/v1/getnodeproperties/` - 获取节点属性 -- `GET /api/v1/getnodes/` - 获取所有节点 -- `GET /api/v1/getnodetype/` - 获取节点类型 -- `GET /api/v1/getscadaproperties/` - 获取SCADA点属性 -- `GET /api/v1/getstatus/` - 获取管线状态 -- `GET /api/v1/getstatusschema` - 获取状态属性架构 -- `GET /api/v1/gettitle/` - 获取水网标题属性 -- `GET /api/v1/gettitleschema/` - 获取标题属性架构 -- `GET /api/v1/isjunction/` - 检查是否为接点 -- `GET /api/v1/islink/` - 检查管线有效性 -- `GET /api/v1/isnode/` - 检查节点有效性 -- `GET /api/v1/ispipe/` - 检查是否为管道 -- `GET /api/v1/ispump/` - 检查是否为泵 -- `GET /api/v1/isreservoir/` - 检查是否为水源 -- `GET /api/v1/istank/` - 检查是否为蓄水池 -- `GET /api/v1/isvalve/` - 检查是否为阀门 -- `POST /api/v1/setstatus/` - 设置管线状态 -- `GET /api/v1/settitle/` - 设置水网标题属性 - -## Action: `geometry` -- 详情技能:`geometry/SKILL.md` -- `GET /api/v1/getmajornodecoords/` - 获取主要节点坐标 -- `GET /api/v1/getmajorpipenodes/` - 获取主要管道节点 -- `GET /api/v1/getnetworkgeometries/` - 获取完整网络几何信息 -- `GET /api/v1/getnetworkinextent/` - 获取范围内的网络元素 -- `GET /api/v1/getnetworklinknodes/` - 获取网络管线节点 -- `GET /api/v1/getnodecoord/` - 获取节点坐标 - -## Action: `junctions` -- 详情技能:`junctions/SKILL.md` -- `POST /api/v1/addjunction/` - 添加节点 -- `POST /api/v1/deletejunction/` - 删除节点 -- `GET /api/v1/getalljunctionproperties/` - 获取所有节点属性 -- `GET /api/v1/getjunctioncoord/` - 获取节点坐标 -- `GET /api/v1/getjunctiondemand/` - 获取节点需水量 -- `GET /api/v1/getjunctionelevation/` - 获取节点标高 -- `GET /api/v1/getjunctionpattern/` - 获取节点需水模式 -- `GET /api/v1/getjunctionproperties/` - 获取节点属性 -- `GET /api/v1/getjunctionschema` - 获取节点架构 -- `GET /api/v1/getjunctionx/` - 获取节点 X 坐标 -- `GET /api/v1/getjunctiony/` - 获取节点 Y 坐标 -- `POST /api/v1/setjunctioncoord/` - 设置节点坐标 -- `POST /api/v1/setjunctiondemand/` - 设置节点需水量 -- `POST /api/v1/setjunctionelevation/` - 设置节点标高 -- `POST /api/v1/setjunctionpattern/` - 设置节点需水模式 -- `POST /api/v1/setjunctionproperties/` - 批量设置节点属性 -- `POST /api/v1/setjunctionx/` - 设置节点 X 坐标 -- `POST /api/v1/setjunctiony/` - 设置节点 Y 坐标 - -## Action: `pipes` -- 详情技能:`pipes/SKILL.md` -- `POST /api/v1/addpipe/` - 添加管道 -- `POST /api/v1/deletepipe/` - 删除管道 -- `GET /api/v1/getallpipeproperties/` - 获取所有管道属性 -- `GET /api/v1/getpipediameter/` - 获取管道管径 -- `GET /api/v1/getpipelength/` - 获取管道长度 -- `GET /api/v1/getpipeminorloss/` - 获取管道局部阻力系数 -- `GET /api/v1/getpipenode1/` - 获取管道起始节点 -- `GET /api/v1/getpipenode2/` - 获取管道终止节点 -- `GET /api/v1/getpipeproperties/` - 获取管道属性 -- `GET /api/v1/getpiperoughness/` - 获取管道粗糙度 -- `GET /api/v1/getpipeschema` - 获取管道模式 -- `GET /api/v1/getpipestatus/` - 获取管道状态 -- `POST /api/v1/setpipediameter/` - 设置管道管径 -- `POST /api/v1/setpipelength/` - 设置管道长度 -- `POST /api/v1/setpipeminorloss/` - 设置管道局部阻力系数 -- `POST /api/v1/setpipenode1/` - 设置管道起始节点 -- `POST /api/v1/setpipenode2/` - 设置管道终止节点 -- `POST /api/v1/setpipeproperties/` - 设置管道属性 -- `POST /api/v1/setpiperoughness/` - 设置管道粗糙度 -- `POST /api/v1/setpipestatus/` - 设置管道状态 - -## Action: `pumps` -- 详情技能:`pumps/SKILL.md` -- `POST /api/v1/addpump/` - 添加水泵 -- `POST /api/v1/deletepump/` - 删除水泵 -- `GET /api/v1/getallpumpproperties/` - 获取所有水泵属性 -- `GET /api/v1/getpumpnode1/` - 获取水泵起始节点 -- `GET /api/v1/getpumpnode2/` - 获取水泵终止节点 -- `GET /api/v1/getpumpproperties/` - 获取水泵属性 -- `GET /api/v1/getpumpschema` - 获取水泵模式 -- `POST /api/v1/setpumpnode1/` - 设置水泵起始节点 -- `POST /api/v1/setpumpnode2/` - 设置水泵终止节点 -- `POST /api/v1/setpumpproperties/` - 设置水泵属性 - -## Action: `regions` -- 详情技能:`regions/SKILL.md` -- `POST /api/v1/adddistrictmeteringarea/` - 添加新DMA -- `POST /api/v1/addregion/` - 添加新区域 -- `POST /api/v1/addservicearea/` - 添加新服务区 -- `POST /api/v1/addvirtualdistrict/` - 添加新虚拟分区 -- `GET /api/v1/calculatedistrictmeteringarea/` - 计算DMA分区 -- `GET /api/v1/calculatedistrictmeteringareafornetwork/` - 计算整网DMA分区 -- `GET /api/v1/calculatedistrictmeteringareafornodes/` - 计算节点DMA分区 -- `GET /api/v1/calculatedistrictmeteringareaforregion/` - 计算区域内DMA分区 -- `GET /api/v1/calculateregion/` - 计算区域 -- `GET /api/v1/calculateservicearea/` - 计算服务区 -- `GET /api/v1/calculatevirtualdistrict/` - 计算虚拟分区 -- `POST /api/v1/deletedistrictmeteringarea/` - 删除DMA -- `POST /api/v1/deleteregion/` - 删除区域 -- `POST /api/v1/deleteservicearea/` - 删除服务区 -- `POST /api/v1/deletevirtualdistrict/` - 删除虚拟分区 -- `POST /api/v1/generatedistrictmeteringarea/` - 生成DMA分区 -- `POST /api/v1/generateregion/` - 生成区域分区 -- `POST /api/v1/generateservicearea/` - 生成服务区分区 -- `POST /api/v1/generatesubdistrictmeteringarea/` - 生成DMA子分区 -- `POST /api/v1/generatevirtualdistrict/` - 生成虚拟分区 -- `GET /api/v1/getalldistrictmeteringareaids/` - 获取所有DMA ID -- `GET /api/v1/getalldistrictmeteringareas/` - 获取所有DMA -- `GET /api/v1/getallregions/` - 获取所有区域 -- `GET /api/v1/getallserviceareas/` - 获取所有服务区 -- `GET /api/v1/getallvirtualdistrict/` - 获取所有虚拟分区 -- `GET /api/v1/getdistrictmeteringarea/` - 获取DMA信息 -- `GET /api/v1/getdistrictmeteringareaschema/` - 获取DMA属性架构 -- `GET /api/v1/getregion/` - 获取区域信息 -- `GET /api/v1/getregionschema/` - 获取区域属性架构 -- `GET /api/v1/getservicearea/` - 获取服务区信息 -- `GET /api/v1/getserviceareaschema/` - 获取服务区属性架构 -- `GET /api/v1/getvirtualdistrict/` - 获取虚拟分区信息 -- `GET /api/v1/getvirtualdistrictschema/` - 获取虚拟分区属性架构 -- `POST /api/v1/setdistrictmeteringarea/` - 设置DMA属性 -- `POST /api/v1/setregion/` - 设置区域属性 -- `POST /api/v1/setservicearea/` - 设置服务区属性 -- `POST /api/v1/setvirtualdistrict/` - 设置虚拟分区属性 - -## Action: `reservoirs` -- 详情技能:`reservoirs/SKILL.md` -- `POST /api/v1/addreservoir/` - 添加水库 -- `POST /api/v1/deletereservoir/` - 删除水库 -- `GET /api/v1/getallreservoirproperties/` - 获取所有水库属性 -- `GET /api/v1/getreservoircoord/` - 获取水库坐标 -- `GET /api/v1/getreservoirhead/` - 获取水库水头 -- `GET /api/v1/getreservoirpattern/` - 获取水库模式 -- `GET /api/v1/getreservoirproperties/` - 获取水库属性 -- `GET /api/v1/getreservoirschema` - 获取水库模式 -- `GET /api/v1/getreservoirx/` - 获取水库X坐标 -- `GET /api/v1/getreservoiry/` - 获取水库Y坐标 -- `POST /api/v1/setreservoircoord/` - 设置水库坐标 -- `POST /api/v1/setreservoirhead/` - 设置水库水头 -- `POST /api/v1/setreservoirpattern/` - 设置水库模式 -- `POST /api/v1/setreservoirproperties/` - 设置水库属性 -- `POST /api/v1/setreservoirx/` - 设置水库X坐标 -- `POST /api/v1/setreservoiry/` - 设置水库Y坐标 - -## Action: `tags` -- 详情技能:`tags/SKILL.md` -- `GET /api/v1/gettag/` - 获取标签信息 -- `GET /api/v1/gettags/` - 获取所有标签 -- `GET /api/v1/gettagschema/` - 获取标签属性架构 -- `POST /api/v1/settag/` - 设置标签 - -## Action: `tanks` -- 详情技能:`tanks/SKILL.md` -- `POST /api/v1/addtank/` - 新增水箱 -- `POST /api/v1/deletetank/` - 删除水箱 -- `GET /api/v1/getalltankproperties/` - 获取所有水箱属性 -- `GET /api/v1/gettankcoord/` - 获取水箱坐标 -- `GET /api/v1/gettankdiameter/` - 获取水箱直径 -- `GET /api/v1/gettankelevation/` - 获取水箱标高 -- `GET /api/v1/gettankinitlevel/` - 获取水箱初始水位 -- `GET /api/v1/gettankmaxlevel/` - 获取水箱最大水位 -- `GET /api/v1/gettankminlevel/` - 获取水箱最小水位 -- `GET /api/v1/gettankminvol/` - 获取水箱最小体积 -- `GET /api/v1/gettankoverflow/` - 获取水箱溢流口 -- `GET /api/v1/gettankproperties/` - 获取水箱属性 -- `GET /api/v1/gettankschema` - 获取水箱模式 -- `GET /api/v1/gettankvolcurve/` - 获取水箱容积曲线 -- `GET /api/v1/gettankx/` - 获取水箱X坐标 -- `GET /api/v1/gettanky/` - 获取水箱Y坐标 -- `POST /api/v1/settankcoord/` - 设置水箱坐标 -- `POST /api/v1/settankdiameter/` - 设置水箱直径 -- `POST /api/v1/settankelevation/` - 设置水箱标高 -- `POST /api/v1/settankinitlevel/` - 设置水箱初始水位 -- `POST /api/v1/settankmaxlevel/` - 设置水箱最大水位 -- `POST /api/v1/settankminlevel/` - 设置水箱最小水位 -- `POST /api/v1/settankminvol/` - 设置水箱最小体积 -- `POST /api/v1/settankoverflow/` - 设置水箱溢流口 -- `POST /api/v1/settankproperties/` - 设置水箱属性 -- `POST /api/v1/settankvolcurve/` - 设置水箱容积曲线 -- `POST /api/v1/settankx/` - 设置水箱X坐标 -- `POST /api/v1/settanky/` - 设置水箱Y坐标 - -## Action: `valves` -- 详情技能:`valves/SKILL.md` -- `POST /api/v1/addvalve/` - 添加阀门 -- `POST /api/v1/deletevalve/` - 删除阀门 -- `GET /api/v1/getallvalveproperties/` - 获取所有阀门属性 -- `GET /api/v1/getvalvediameter/` - 获取阀门直径 -- `GET /api/v1/getvalveminorloss/` - 获取阀门损失系数 -- `GET /api/v1/getvalvenode1/` - 获取阀门起点节点 -- `GET /api/v1/getvalvenode2/` - 获取阀门终点节点 -- `GET /api/v1/getvalveproperties/` - 获取阀门所有属性 -- `GET /api/v1/getvalveschema` - 获取阀门架构 -- `GET /api/v1/getvalvesetting/` - 获取阀门开度 -- `GET /api/v1/getvalvetype/` - 获取阀门类型 -- `POST /api/v1/setvalvenode1/` - 设置阀门起点节点 -- `POST /api/v1/setvalvenode2/` - 设置阀门终点节点 -- `POST /api/v1/setvalvenodediameter/` - 设置阀门直径 -- `POST /api/v1/setvalveproperties/` - 批量设置阀门属性 -- `POST /api/v1/setvalvesetting/` - 设置阀门开度 -- `POST /api/v1/setvalvetype/` - 设置阀门类型 - -# See Also - -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/demands/SKILL.md b/.github/skills/business/network-assets/demands/SKILL.md deleted file mode 100644 index b982089..0000000 --- a/.github/skills/business/network-assets/demands/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: api-operations-business-network-assets-demands -description: business/network-assets 场景下 demands 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **demands** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/calculatedemandtonetwork/` - 计算需水量到整网分配 -- `GET /api/v1/calculatedemandtonodes/` - 计算需水量到节点分配 -- `GET /api/v1/calculatedemandtoregion/` - 计算需水量到区域分配 -- `GET /api/v1/getdemandproperties/` - 获取需水量属性 -- `GET /api/v1/getdemandschema` - 获取需水量属性架构 -- `POST /api/v1/setdemandproperties/` - 设置需水量属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/general/SKILL.md b/.github/skills/business/network-assets/general/SKILL.md deleted file mode 100644 index fbb9158..0000000 --- a/.github/skills/business/network-assets/general/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: api-operations-business-network-assets-general -description: business/network-assets 场景下 general 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **general** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/deletelink/` - 删除管线 -- `POST /api/v1/deletenode/` - 删除节点 -- `GET /api/v1/getallscadaproperties/` - 获取所有SCADA点属性 -- `GET /api/v1/getelementproperties/` - 获取元素属性 -- `GET /api/v1/getelementpropertieswithtype/` - 获取指定类型元素属性 -- `GET /api/v1/getelementtype/` - 获取元素类型 -- `GET /api/v1/getelementtypevalue/` - 获取元素类型值 -- `GET /api/v1/getlinkproperties/` - 获取管线属性 -- `GET /api/v1/getlinks/` - 获取所有管线 -- `GET /api/v1/getlinktype/` - 获取管线类型 -- `GET /api/v1/getnodelinks/` - 获取节点的关联管线 -- `GET /api/v1/getnodeproperties/` - 获取节点属性 -- `GET /api/v1/getnodes/` - 获取所有节点 -- `GET /api/v1/getnodetype/` - 获取节点类型 -- `GET /api/v1/getscadaproperties/` - 获取SCADA点属性 -- `GET /api/v1/getstatus/` - 获取管线状态 -- `GET /api/v1/getstatusschema` - 获取状态属性架构 -- `GET /api/v1/gettitle/` - 获取水网标题属性 -- `GET /api/v1/gettitleschema/` - 获取标题属性架构 -- `GET /api/v1/isjunction/` - 检查是否为接点 -- `GET /api/v1/islink/` - 检查管线有效性 -- `GET /api/v1/isnode/` - 检查节点有效性 -- `GET /api/v1/ispipe/` - 检查是否为管道 -- `GET /api/v1/ispump/` - 检查是否为泵 -- `GET /api/v1/isreservoir/` - 检查是否为水源 -- `GET /api/v1/istank/` - 检查是否为蓄水池 -- `GET /api/v1/isvalve/` - 检查是否为阀门 -- `POST /api/v1/setstatus/` - 设置管线状态 -- `GET /api/v1/settitle/` - 设置水网标题属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/geometry/SKILL.md b/.github/skills/business/network-assets/geometry/SKILL.md deleted file mode 100644 index 4557179..0000000 --- a/.github/skills/business/network-assets/geometry/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: api-operations-business-network-assets-geometry -description: business/network-assets 场景下 geometry 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **geometry** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/getmajornodecoords/` - 获取主要节点坐标 -- `GET /api/v1/getmajorpipenodes/` - 获取主要管道节点 -- `GET /api/v1/getnetworkgeometries/` - 获取完整网络几何信息 -- `GET /api/v1/getnetworkinextent/` - 获取范围内的网络元素 -- `GET /api/v1/getnetworklinknodes/` - 获取网络管线节点 -- `GET /api/v1/getnodecoord/` - 获取节点坐标 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/junctions/SKILL.md b/.github/skills/business/network-assets/junctions/SKILL.md deleted file mode 100644 index 6d3d480..0000000 --- a/.github/skills/business/network-assets/junctions/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: api-operations-business-network-assets-junctions -description: business/network-assets 场景下 junctions 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **junctions** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addjunction/` - 添加节点 -- `POST /api/v1/deletejunction/` - 删除节点 -- `GET /api/v1/getalljunctionproperties/` - 获取所有节点属性 -- `GET /api/v1/getjunctioncoord/` - 获取节点坐标 -- `GET /api/v1/getjunctiondemand/` - 获取节点需水量 -- `GET /api/v1/getjunctionelevation/` - 获取节点标高 -- `GET /api/v1/getjunctionpattern/` - 获取节点需水模式 -- `GET /api/v1/getjunctionproperties/` - 获取节点属性 -- `GET /api/v1/getjunctionschema` - 获取节点架构 -- `GET /api/v1/getjunctionx/` - 获取节点 X 坐标 -- `GET /api/v1/getjunctiony/` - 获取节点 Y 坐标 -- `POST /api/v1/setjunctioncoord/` - 设置节点坐标 -- `POST /api/v1/setjunctiondemand/` - 设置节点需水量 -- `POST /api/v1/setjunctionelevation/` - 设置节点标高 -- `POST /api/v1/setjunctionpattern/` - 设置节点需水模式 -- `POST /api/v1/setjunctionproperties/` - 批量设置节点属性 -- `POST /api/v1/setjunctionx/` - 设置节点 X 坐标 -- `POST /api/v1/setjunctiony/` - 设置节点 Y 坐标 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/pipes/SKILL.md b/.github/skills/business/network-assets/pipes/SKILL.md deleted file mode 100644 index 91e57d0..0000000 --- a/.github/skills/business/network-assets/pipes/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: api-operations-business-network-assets-pipes -description: business/network-assets 场景下 pipes 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **pipes** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addpipe/` - 添加管道 -- `POST /api/v1/deletepipe/` - 删除管道 -- `GET /api/v1/getallpipeproperties/` - 获取所有管道属性 -- `GET /api/v1/getpipediameter/` - 获取管道管径 -- `GET /api/v1/getpipelength/` - 获取管道长度 -- `GET /api/v1/getpipeminorloss/` - 获取管道局部阻力系数 -- `GET /api/v1/getpipenode1/` - 获取管道起始节点 -- `GET /api/v1/getpipenode2/` - 获取管道终止节点 -- `GET /api/v1/getpipeproperties/` - 获取管道属性 -- `GET /api/v1/getpiperoughness/` - 获取管道粗糙度 -- `GET /api/v1/getpipeschema` - 获取管道模式 -- `GET /api/v1/getpipestatus/` - 获取管道状态 -- `POST /api/v1/setpipediameter/` - 设置管道管径 -- `POST /api/v1/setpipelength/` - 设置管道长度 -- `POST /api/v1/setpipeminorloss/` - 设置管道局部阻力系数 -- `POST /api/v1/setpipenode1/` - 设置管道起始节点 -- `POST /api/v1/setpipenode2/` - 设置管道终止节点 -- `POST /api/v1/setpipeproperties/` - 设置管道属性 -- `POST /api/v1/setpiperoughness/` - 设置管道粗糙度 -- `POST /api/v1/setpipestatus/` - 设置管道状态 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/pumps/SKILL.md b/.github/skills/business/network-assets/pumps/SKILL.md deleted file mode 100644 index 906b1fe..0000000 --- a/.github/skills/business/network-assets/pumps/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: api-operations-business-network-assets-pumps -description: business/network-assets 场景下 pumps 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **pumps** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addpump/` - 添加水泵 -- `POST /api/v1/deletepump/` - 删除水泵 -- `GET /api/v1/getallpumpproperties/` - 获取所有水泵属性 -- `GET /api/v1/getpumpnode1/` - 获取水泵起始节点 -- `GET /api/v1/getpumpnode2/` - 获取水泵终止节点 -- `GET /api/v1/getpumpproperties/` - 获取水泵属性 -- `GET /api/v1/getpumpschema` - 获取水泵模式 -- `POST /api/v1/setpumpnode1/` - 设置水泵起始节点 -- `POST /api/v1/setpumpnode2/` - 设置水泵终止节点 -- `POST /api/v1/setpumpproperties/` - 设置水泵属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/regions/SKILL.md b/.github/skills/business/network-assets/regions/SKILL.md deleted file mode 100644 index 3775233..0000000 --- a/.github/skills/business/network-assets/regions/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: api-operations-business-network-assets-regions -description: business/network-assets 场景下 regions 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **regions** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/adddistrictmeteringarea/` - 添加新DMA -- `POST /api/v1/addregion/` - 添加新区域 -- `POST /api/v1/addservicearea/` - 添加新服务区 -- `POST /api/v1/addvirtualdistrict/` - 添加新虚拟分区 -- `GET /api/v1/calculatedistrictmeteringarea/` - 计算DMA分区 -- `GET /api/v1/calculatedistrictmeteringareafornetwork/` - 计算整网DMA分区 -- `GET /api/v1/calculatedistrictmeteringareafornodes/` - 计算节点DMA分区 -- `GET /api/v1/calculatedistrictmeteringareaforregion/` - 计算区域内DMA分区 -- `GET /api/v1/calculateregion/` - 计算区域 -- `GET /api/v1/calculateservicearea/` - 计算服务区 -- `GET /api/v1/calculatevirtualdistrict/` - 计算虚拟分区 -- `POST /api/v1/deletedistrictmeteringarea/` - 删除DMA -- `POST /api/v1/deleteregion/` - 删除区域 -- `POST /api/v1/deleteservicearea/` - 删除服务区 -- `POST /api/v1/deletevirtualdistrict/` - 删除虚拟分区 -- `POST /api/v1/generatedistrictmeteringarea/` - 生成DMA分区 -- `POST /api/v1/generateregion/` - 生成区域分区 -- `POST /api/v1/generateservicearea/` - 生成服务区分区 -- `POST /api/v1/generatesubdistrictmeteringarea/` - 生成DMA子分区 -- `POST /api/v1/generatevirtualdistrict/` - 生成虚拟分区 -- `GET /api/v1/getalldistrictmeteringareaids/` - 获取所有DMA ID -- `GET /api/v1/getalldistrictmeteringareas/` - 获取所有DMA -- `GET /api/v1/getallregions/` - 获取所有区域 -- `GET /api/v1/getallserviceareas/` - 获取所有服务区 -- `GET /api/v1/getallvirtualdistrict/` - 获取所有虚拟分区 -- `GET /api/v1/getdistrictmeteringarea/` - 获取DMA信息 -- `GET /api/v1/getdistrictmeteringareaschema/` - 获取DMA属性架构 -- `GET /api/v1/getregion/` - 获取区域信息 -- `GET /api/v1/getregionschema/` - 获取区域属性架构 -- `GET /api/v1/getservicearea/` - 获取服务区信息 -- `GET /api/v1/getserviceareaschema/` - 获取服务区属性架构 -- `GET /api/v1/getvirtualdistrict/` - 获取虚拟分区信息 -- `GET /api/v1/getvirtualdistrictschema/` - 获取虚拟分区属性架构 -- `POST /api/v1/setdistrictmeteringarea/` - 设置DMA属性 -- `POST /api/v1/setregion/` - 设置区域属性 -- `POST /api/v1/setservicearea/` - 设置服务区属性 -- `POST /api/v1/setvirtualdistrict/` - 设置虚拟分区属性 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/reservoirs/SKILL.md b/.github/skills/business/network-assets/reservoirs/SKILL.md deleted file mode 100644 index 98903db..0000000 --- a/.github/skills/business/network-assets/reservoirs/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: api-operations-business-network-assets-reservoirs -description: business/network-assets 场景下 reservoirs 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **reservoirs** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addreservoir/` - 添加水库 -- `POST /api/v1/deletereservoir/` - 删除水库 -- `GET /api/v1/getallreservoirproperties/` - 获取所有水库属性 -- `GET /api/v1/getreservoircoord/` - 获取水库坐标 -- `GET /api/v1/getreservoirhead/` - 获取水库水头 -- `GET /api/v1/getreservoirpattern/` - 获取水库模式 -- `GET /api/v1/getreservoirproperties/` - 获取水库属性 -- `GET /api/v1/getreservoirschema` - 获取水库模式 -- `GET /api/v1/getreservoirx/` - 获取水库X坐标 -- `GET /api/v1/getreservoiry/` - 获取水库Y坐标 -- `POST /api/v1/setreservoircoord/` - 设置水库坐标 -- `POST /api/v1/setreservoirhead/` - 设置水库水头 -- `POST /api/v1/setreservoirpattern/` - 设置水库模式 -- `POST /api/v1/setreservoirproperties/` - 设置水库属性 -- `POST /api/v1/setreservoirx/` - 设置水库X坐标 -- `POST /api/v1/setreservoiry/` - 设置水库Y坐标 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/tags/SKILL.md b/.github/skills/business/network-assets/tags/SKILL.md deleted file mode 100644 index 8380506..0000000 --- a/.github/skills/business/network-assets/tags/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: api-operations-business-network-assets-tags -description: business/network-assets 场景下 tags 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **tags** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/gettag/` - 获取标签信息 -- `GET /api/v1/gettags/` - 获取所有标签 -- `GET /api/v1/gettagschema/` - 获取标签属性架构 -- `POST /api/v1/settag/` - 设置标签 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/tanks/SKILL.md b/.github/skills/business/network-assets/tanks/SKILL.md deleted file mode 100644 index 94d4b6e..0000000 --- a/.github/skills/business/network-assets/tanks/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: api-operations-business-network-assets-tanks -description: business/network-assets 场景下 tanks 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **tanks** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addtank/` - 新增水箱 -- `POST /api/v1/deletetank/` - 删除水箱 -- `GET /api/v1/getalltankproperties/` - 获取所有水箱属性 -- `GET /api/v1/gettankcoord/` - 获取水箱坐标 -- `GET /api/v1/gettankdiameter/` - 获取水箱直径 -- `GET /api/v1/gettankelevation/` - 获取水箱标高 -- `GET /api/v1/gettankinitlevel/` - 获取水箱初始水位 -- `GET /api/v1/gettankmaxlevel/` - 获取水箱最大水位 -- `GET /api/v1/gettankminlevel/` - 获取水箱最小水位 -- `GET /api/v1/gettankminvol/` - 获取水箱最小体积 -- `GET /api/v1/gettankoverflow/` - 获取水箱溢流口 -- `GET /api/v1/gettankproperties/` - 获取水箱属性 -- `GET /api/v1/gettankschema` - 获取水箱模式 -- `GET /api/v1/gettankvolcurve/` - 获取水箱容积曲线 -- `GET /api/v1/gettankx/` - 获取水箱X坐标 -- `GET /api/v1/gettanky/` - 获取水箱Y坐标 -- `POST /api/v1/settankcoord/` - 设置水箱坐标 -- `POST /api/v1/settankdiameter/` - 设置水箱直径 -- `POST /api/v1/settankelevation/` - 设置水箱标高 -- `POST /api/v1/settankinitlevel/` - 设置水箱初始水位 -- `POST /api/v1/settankmaxlevel/` - 设置水箱最大水位 -- `POST /api/v1/settankminlevel/` - 设置水箱最小水位 -- `POST /api/v1/settankminvol/` - 设置水箱最小体积 -- `POST /api/v1/settankoverflow/` - 设置水箱溢流口 -- `POST /api/v1/settankproperties/` - 设置水箱属性 -- `POST /api/v1/settankvolcurve/` - 设置水箱容积曲线 -- `POST /api/v1/settankx/` - 设置水箱X坐标 -- `POST /api/v1/settanky/` - 设置水箱Y坐标 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/network-assets/valves/SKILL.md b/.github/skills/business/network-assets/valves/SKILL.md deleted file mode 100644 index abce12d..0000000 --- a/.github/skills/business/network-assets/valves/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: api-operations-business-network-assets-valves -description: business/network-assets 场景下 valves 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **valves** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/addvalve/` - 添加阀门 -- `POST /api/v1/deletevalve/` - 删除阀门 -- `GET /api/v1/getallvalveproperties/` - 获取所有阀门属性 -- `GET /api/v1/getvalvediameter/` - 获取阀门直径 -- `GET /api/v1/getvalveminorloss/` - 获取阀门损失系数 -- `GET /api/v1/getvalvenode1/` - 获取阀门起点节点 -- `GET /api/v1/getvalvenode2/` - 获取阀门终点节点 -- `GET /api/v1/getvalveproperties/` - 获取阀门所有属性 -- `GET /api/v1/getvalveschema` - 获取阀门架构 -- `GET /api/v1/getvalvesetting/` - 获取阀门开度 -- `GET /api/v1/getvalvetype/` - 获取阀门类型 -- `POST /api/v1/setvalvenode1/` - 设置阀门起点节点 -- `POST /api/v1/setvalvenode2/` - 设置阀门终点节点 -- `POST /api/v1/setvalvenodediameter/` - 设置阀门直径 -- `POST /api/v1/setvalveproperties/` - 批量设置阀门属性 -- `POST /api/v1/setvalvesetting/` - 设置阀门开度 -- `POST /api/v1/setvalvetype/` - 设置阀门类型 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联组件配置: `../component-config` -- 关联仿真分析: `../../analytics/simulation-analysis` diff --git a/.github/skills/business/project-workspace/SKILL.md b/.github/skills/business/project-workspace/SKILL.md deleted file mode 100644 index 4b35d16..0000000 --- a/.github/skills/business/project-workspace/SKILL.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -name: api-operations-business-project-workspace -description: 项目、方案、快照和项目数据接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **business/project-workspace** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# Action Skills - -- `extension`: `extension/SKILL.md` -- `misc`: `misc/SKILL.md` -- `project`: `project/SKILL.md` -- `project_data`: `project_data/SKILL.md` -- `schemes`: `schemes/SKILL.md` -- `snapshots`: `snapshots/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `extension` -- 详情技能:`extension/SKILL.md` -- `GET /api/v1/getallextensiondata/` - 获取所有扩展数据 -- `GET /api/v1/getallextensiondatakeys/` - 获取所有扩展数据键 -- `GET /api/v1/getextensiondata/` - 获取指定扩展数据 -- `POST /api/v1/setextensiondata/` - 设置扩展数据 - -## Action: `misc` -- 详情技能:`misc/SKILL.md` -- `GET /api/v1/getallburstlocateresults/` - 获取所有爆管定位结果 -- `GET /api/v1/getallsensorplacements/` - 获取所有传感器位置 -- `GET /api/v1/getjson/` - 获取JSON示例 -- `GET /api/v1/getrealtimedata/` - 获取实时数据 -- `GET /api/v1/getsimulationresult/` - 获取模拟结果 -- `POST /api/v1/test_dict/` - 测试字典处理 - -## Action: `project` -- 详情技能:`project/SKILL.md` -- `POST /api/v1/closeproject/` - 关闭项目 -- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 -- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 -- `POST /api/v1/copyproject/` - 复制项目 -- `POST /api/v1/createproject/` - 创建新项目 -- `POST /api/v1/deleteproject/` - 删除项目 -- `GET /api/v1/downloadinp/` - 下载 INP 文件 -- `GET /api/v1/downloadinp/` - 下载 INP 文件 -- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 -- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 -- `GET /api/v1/exportinp/` - 导出项目为 ChangeSet -- `GET /api/v1/haveproject/` - 检查项目是否存在 -- `POST /api/v1/importinp/` - 导入 INP 文件内容 -- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 -- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 -- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 -- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 -- `GET /api/v1/isprojectopen/` - 检查项目是否已打开 -- `GET /api/v1/listprojects/` - 获取项目列表 -- `POST /api/v1/lockproject/` - 锁定项目 -- `POST /api/v1/lockproject/` - 锁定项目 -- `POST /api/v1/openproject/` - 打开项目 -- `GET /api/v1/project_info/` - 获取项目信息 -- `POST /api/v1/readinp/` - 读取 INP 文件到项目 -- `POST /api/v1/readinp/` - 读取 INP 文件到项目 -- `POST /api/v1/unlockproject/` - 解锁项目 -- `POST /api/v1/unlockproject/` - 解锁项目 -- `POST /api/v1/uploadinp/` - 上传 INP 文件 -- `POST /api/v1/uploadinp/` - 上传 INP 文件 - -## Action: `project_data` -- 详情技能:`project_data/SKILL.md` -- `GET /api/v1/burst-locate-result` - 获取爆管定位结果 -- `GET /api/v1/burst-locate-result/{burst_incident}` - 按事件查询爆管定位结果 -- `GET /api/v1/scada-info` - 获取SCADA信息 -- `GET /api/v1/scheme-list` - 获取方案列表 - -## Action: `schemes` -- 详情技能:`schemes/SKILL.md` -- `GET /api/v1/getallschemes/` - 获取所有方案 -- `GET /api/v1/getscheme/` - 获取单个方案 -- `GET /api/v1/getschemeschema/` - 获取方案模式 - -## Action: `snapshots` -- 详情技能:`snapshots/SKILL.md` -- `POST /api/v1/batch/` - 执行批量命令 -- `POST /api/v1/compressedbatch/` - 执行压缩批量命令 -- `GET /api/v1/getcurrentoperationid/` - 获取当前操作ID -- `GET /api/v1/getrestoreoperation/` - 获取恢复操作ID -- `GET /api/v1/getsnapshots/` - 获取快照列表 -- `GET /api/v1/havesnapshot/` - 检查快照是否存在 -- `GET /api/v1/havesnapshotforcurrentoperation/` - 检查当前操作快照是否存在 -- `GET /api/v1/havesnapshotforoperation/` - 检查操作快照是否存在 -- `POST /api/v1/pickoperation/` - 选择操作 -- `POST /api/v1/picksnapshot/` - 选择快照 -- `POST /api/v1/redo/` - 重做操作 -- `POST /api/v1/setrestoreoperation/` - 设置恢复操作ID -- `GET /api/v1/syncwithserver/` - 与服务器同步 -- `POST /api/v1/takenapshotforcurrentoperation` - 为当前操作创建快照(兼容模式) -- `POST /api/v1/takesnapshot/` - 创建快照 -- `POST /api/v1/takesnapshotforcurrentoperation` - 为当前操作创建快照 -- `POST /api/v1/takesnapshotforoperation/` - 为操作创建快照 -- `POST /api/v1/undo/` - 撤销操作 - -# See Also - -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/extension/SKILL.md b/.github/skills/business/project-workspace/extension/SKILL.md deleted file mode 100644 index acd8e42..0000000 --- a/.github/skills/business/project-workspace/extension/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: api-operations-business-project-workspace-extension -description: business/project-workspace 场景下 extension 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **extension** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/getallextensiondata/` - 获取所有扩展数据 -- `GET /api/v1/getallextensiondatakeys/` - 获取所有扩展数据键 -- `GET /api/v1/getextensiondata/` - 获取指定扩展数据 -- `POST /api/v1/setextensiondata/` - 设置扩展数据 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/misc/SKILL.md b/.github/skills/business/project-workspace/misc/SKILL.md deleted file mode 100644 index fb06950..0000000 --- a/.github/skills/business/project-workspace/misc/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: api-operations-business-project-workspace-misc -description: business/project-workspace 场景下 misc 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **misc** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `GET /api/v1/getallburstlocateresults/` - 获取所有爆管定位结果 -- `GET /api/v1/getallsensorplacements/` - 获取所有传感器位置 -- `GET /api/v1/getjson/` - 获取JSON示例 -- `GET /api/v1/getrealtimedata/` - 获取实时数据 -- `GET /api/v1/getsimulationresult/` - 获取模拟结果 -- `POST /api/v1/test_dict/` - 测试字典处理 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/project/SKILL.md b/.github/skills/business/project-workspace/project/SKILL.md deleted file mode 100644 index 1bc4638..0000000 --- a/.github/skills/business/project-workspace/project/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: api-operations-business-project-workspace-project -description: business/project-workspace 场景下 project 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **project** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/closeproject/` - 关闭项目 -- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 -- `GET /api/v1/convertv3tov2/` - 转换 INP V3 为 V2 -- `POST /api/v1/copyproject/` - 复制项目 -- `POST /api/v1/createproject/` - 创建新项目 -- `POST /api/v1/deleteproject/` - 删除项目 -- `GET /api/v1/downloadinp/` - 下载 INP 文件 -- `GET /api/v1/downloadinp/` - 下载 INP 文件 -- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 -- `GET /api/v1/dumpinp/` - 导出项目到 INP 文件 -- `GET /api/v1/exportinp/` - 导出项目为 ChangeSet -- `GET /api/v1/haveproject/` - 检查项目是否存在 -- `POST /api/v1/importinp/` - 导入 INP 文件内容 -- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 -- `GET /api/v1/isprojectlocked/` - 检查项目是否被锁定 -- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 -- `GET /api/v1/isprojectlockedbyme/` - 检查项目是否被当前用户锁定 -- `GET /api/v1/isprojectopen/` - 检查项目是否已打开 -- `GET /api/v1/listprojects/` - 获取项目列表 -- `POST /api/v1/lockproject/` - 锁定项目 -- `POST /api/v1/lockproject/` - 锁定项目 -- `POST /api/v1/openproject/` - 打开项目 -- `GET /api/v1/project_info/` - 获取项目信息 -- `POST /api/v1/readinp/` - 读取 INP 文件到项目 -- `POST /api/v1/readinp/` - 读取 INP 文件到项目 -- `POST /api/v1/unlockproject/` - 解锁项目 -- `POST /api/v1/unlockproject/` - 解锁项目 -- `POST /api/v1/uploadinp/` - 上传 INP 文件 -- `POST /api/v1/uploadinp/` - 上传 INP 文件 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/project_data/SKILL.md b/.github/skills/business/project-workspace/project_data/SKILL.md deleted file mode 100644 index da50365..0000000 --- a/.github/skills/business/project-workspace/project_data/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: api-operations-business-project-workspace-project_data -description: business/project-workspace 场景下 project_data 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **project_data** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/burst-locate-result` - 获取爆管定位结果 -- `GET /api/v1/burst-locate-result/{burst_incident}` - 按事件查询爆管定位结果 -- `GET /api/v1/scada-info` - 获取SCADA信息 -- `GET /api/v1/scheme-list` - 获取方案列表 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/schemes/SKILL.md b/.github/skills/business/project-workspace/schemes/SKILL.md deleted file mode 100644 index 18ab871..0000000 --- a/.github/skills/business/project-workspace/schemes/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-business-project-workspace-schemes -description: business/project-workspace 场景下 schemes 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **schemes** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/getallschemes/` - 获取所有方案 -- `GET /api/v1/getscheme/` - 获取单个方案 -- `GET /api/v1/getschemeschema/` - 获取方案模式 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/business/project-workspace/snapshots/SKILL.md b/.github/skills/business/project-workspace/snapshots/SKILL.md deleted file mode 100644 index f390f0d..0000000 --- a/.github/skills/business/project-workspace/snapshots/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: api-operations-business-project-workspace-snapshots -description: business/project-workspace 场景下 snapshots 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **snapshots** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/batch/` - 执行批量命令 -- `POST /api/v1/compressedbatch/` - 执行压缩批量命令 -- `GET /api/v1/getcurrentoperationid/` - 获取当前操作ID -- `GET /api/v1/getrestoreoperation/` - 获取恢复操作ID -- `GET /api/v1/getsnapshots/` - 获取快照列表 -- `GET /api/v1/havesnapshot/` - 检查快照是否存在 -- `GET /api/v1/havesnapshotforcurrentoperation/` - 检查当前操作快照是否存在 -- `GET /api/v1/havesnapshotforoperation/` - 检查操作快照是否存在 -- `POST /api/v1/pickoperation/` - 选择操作 -- `POST /api/v1/picksnapshot/` - 选择快照 -- `POST /api/v1/redo/` - 重做操作 -- `POST /api/v1/setrestoreoperation/` - 设置恢复操作ID -- `GET /api/v1/syncwithserver/` - 与服务器同步 -- `POST /api/v1/takenapshotforcurrentoperation` - 为当前操作创建快照(兼容模式) -- `POST /api/v1/takesnapshot/` - 创建快照 -- `POST /api/v1/takesnapshotforcurrentoperation` - 为当前操作创建快照 -- `POST /api/v1/takesnapshotforoperation/` - 为操作创建快照 -- `POST /api/v1/undo/` - 撤销操作 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联网络资产: `../network-assets` -- 关联时序数据: `../../data/timeseries-access` diff --git a/.github/skills/examples.md b/.github/skills/examples.md deleted file mode 100644 index 45467cb..0000000 --- a/.github/skills/examples.md +++ /dev/null @@ -1,21 +0,0 @@ -# 示例 - -## 示例 1:按目录查找操作 - -用户目标:"我要改一个泵的属性,去哪个 skill?" - -建议路径: - -1. 打开 `SKILL.md` 查看目录导航。 -2. 进入 `business/network-assets/SKILL.md`。 -3. 在 `Action: pumps` 下选择对应接口(如 `setpumpproperties`)。 - -## 示例 2:按场景联调 - -用户目标:"排查 SCADA 历史数据接口异常。" - -建议路径: - -1. 进入 `analytics/scada-operations/SKILL.md`。 -2. 按 `Action` 定位 `scada` 或 `data_query`。 -3. 结合 `runbook.md` 做状态码与参数排查。 diff --git a/.github/skills/platform/governance-observability/SKILL.md b/.github/skills/platform/governance-observability/SKILL.md deleted file mode 100644 index d64cb3a..0000000 --- a/.github/skills/platform/governance-observability/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: api-operations-platform-governance-observability -description: 审计、健康检查和缓存运维接口集合。 -version: 2.1.0 ---- - -# 何时使用 - -当需求落在 **platform/governance-observability** 的接口范围时使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# Action Skills - -- `audit`: `audit/SKILL.md` -- `cache`: `cache/SKILL.md` -- `meta`: `meta/SKILL.md` - -# 操作目录(Domain -> Scenario -> Action) - -## Action: `audit` -- 详情技能:`audit/SKILL.md` -- `GET /api/v1/audit/logs` - 查询审计日志 -- `GET /api/v1/audit/logs/count` - 获取审计日志总数 -- `GET /api/v1/audit/logs/my` - 查询我的审计日志 - -## Action: `cache` -- 详情技能:`cache/SKILL.md` -- `POST /api/v1/clearallredis/` - 清除所有缓存 -- `POST /api/v1/clearrediskey/` - 清除单个缓存键 -- `POST /api/v1/clearrediskeys/` - 清除匹配的缓存键 -- `GET /api/v1/queryredis/` - 查询缓存键列表 - -## Action: `meta` -- 详情技能:`meta/SKILL.md` -- `GET /api/v1/meta/db/health` - 检查数据库健康状态 -- `GET /api/v1/meta/project` - 获取项目元数据 -- `GET /api/v1/meta/projects` - 列出用户项目 - -# See Also - -- 关联身份权限: `../../business/identity-access` -- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/platform/governance-observability/audit/SKILL.md b/.github/skills/platform/governance-observability/audit/SKILL.md deleted file mode 100644 index a35b217..0000000 --- a/.github/skills/platform/governance-observability/audit/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-platform-governance-observability-audit -description: platform/governance-observability 场景下 audit 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **audit** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/audit/logs` - 查询审计日志 -- `GET /api/v1/audit/logs/count` - 获取审计日志总数 -- `GET /api/v1/audit/logs/my` - 查询我的审计日志 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联身份权限: `../../business/identity-access` -- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/platform/governance-observability/cache/SKILL.md b/.github/skills/platform/governance-observability/cache/SKILL.md deleted file mode 100644 index 6c7d5f9..0000000 --- a/.github/skills/platform/governance-observability/cache/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: api-operations-platform-governance-observability-cache -description: platform/governance-observability 场景下 cache 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **cache** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET`, `POST` - -# 操作列表 - -- `POST /api/v1/clearallredis/` - 清除所有缓存 -- `POST /api/v1/clearrediskey/` - 清除单个缓存键 -- `POST /api/v1/clearrediskeys/` - 清除匹配的缓存键 -- `GET /api/v1/queryredis/` - 查询缓存键列表 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联身份权限: `../../business/identity-access` -- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/platform/governance-observability/meta/SKILL.md b/.github/skills/platform/governance-observability/meta/SKILL.md deleted file mode 100644 index da31bc2..0000000 --- a/.github/skills/platform/governance-observability/meta/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: api-operations-platform-governance-observability-meta -description: platform/governance-observability 场景下 meta 操作接口。 -version: 1.0.0 ---- - -# 何时使用 - -当你只需要处理 **meta** 相关接口时,使用本技能。 - -# 输入要求 - -- 可选:`BASE_URL`(默认 `http://127.0.0.1:8000`) -- 可选:`AUTH_TOKEN`(按环境鉴权策略) -- 覆盖方法:`GET` - -# 操作列表 - -- `GET /api/v1/meta/db/health` - 检查数据库健康状态 -- `GET /api/v1/meta/project` - 获取项目元数据 -- `GET /api/v1/meta/projects` - 列出用户项目 - -# See Also - -- 关联场景: `../` -- 关联总览: `../../../SKILL.md` -- 关联身份权限: `../../business/identity-access` -- 关联SCADA操作: `../../analytics/scada-operations` diff --git a/.github/skills/runbook.md b/.github/skills/runbook.md deleted file mode 100644 index 1775923..0000000 --- a/.github/skills/runbook.md +++ /dev/null @@ -1,20 +0,0 @@ -# API Skills 使用 Runbook - -## 标准流程 - -1. 先在 `SKILL.md` 选择领域与场景。 -2. 进入对应 `*//SKILL.md`,按 `Action` 找到接口。 -3. 组装请求:`$BASE_URL` + `path`,并按需带 `AUTH_TOKEN`。 -4. 记录请求参数、状态码、响应体。 - -## 异常处理 - -- `401/403`:检查 token 与角色权限。 -- `404`:检查路径前缀与路由配置是否一致。 -- `422`:检查 query/body 参数与字段类型。 -- `5xx`:记录响应体并关联后端日志排查。 - -## 重试建议 - -- 网络超时、`5xx` 可有限重试 1~2 次。 -- `4xx` 先修正参数与权限,不建议直接重试。 diff --git a/.github/skills/scripts/call-api.sh b/.github/skills/scripts/call-api.sh deleted file mode 100755 index cf0a957..0000000 --- a/.github/skills/scripts/call-api.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BASE_URL="${BASE_URL:-http://127.0.0.1:8000}" -NETWORK="${NETWORK:-tjwater}" -URL="${BASE_URL%/}/api/v1/burst-detection/schemes/?network=${NETWORK}" - -headers=(-H "Accept: application/json") -if [[ -n "${AUTH_TOKEN:-}" ]]; then - headers+=(-H "Authorization: Bearer ${AUTH_TOKEN}") -fi - -echo "[api-operations] GET ${URL}" >&2 -curl --silent --show-error --fail-with-body "${headers[@]}" "$URL" -echo diff --git a/app/api/v1/endpoints/copilot.py b/app/api/v1/endpoints/copilot.py deleted file mode 100644 index f8c5c75..0000000 --- a/app/api/v1/endpoints/copilot.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import json -from typing import AsyncGenerator, Optional - -import httpx -from fastapi import APIRouter, Depends, Request, status -from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field - -from app.auth.keycloak_dependencies import get_current_keycloak_username -from app.core.config import settings - -router = APIRouter() - - -class CopilotChatStreamRequest(BaseModel): - message: str = Field(..., min_length=1, max_length=10000) - conversation_id: Optional[str] = Field(default=None, max_length=128) - - -def _sse_event(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - -@router.post( - "/chat/stream", - summary="Copilot 聊天流式响应", - description="向 Python Copilot sidecar 转发请求并通过 SSE 返回增量内容", -) -async def copilot_chat_stream( - payload: CopilotChatStreamRequest, - request: Request, - username: str = Depends(get_current_keycloak_username), -): - timeout = httpx.Timeout( - connect=10.0, - read=float(settings.COPILOT_STREAM_TIMEOUT_SECONDS), - write=10.0, - pool=10.0, - ) - sidecar_url = settings.COPILOT_SIDECAR_URL.rstrip("/") - upstream_url = f"{sidecar_url}/chat/stream" - - async def event_generator() -> AsyncGenerator[str, None]: - headers: dict[str, str] = {} - auth_header = request.headers.get("authorization") - project_id = request.headers.get("x-project-id") - if auth_header: - headers["authorization"] = auth_header - if project_id: - headers["x-project-id"] = project_id - - body = { - "message": payload.message, - "conversationId": payload.conversation_id, - "userId": username, - } - - try: - async with httpx.AsyncClient(timeout=timeout) as client: - async with client.stream( - "POST", - upstream_url, - json=body, - headers=headers, - ) as response: - if response.status_code >= 400: - detail_text = await response.aread() - detail = detail_text.decode("utf-8", errors="replace") - yield _sse_event( - "error", - { - "message": "Copilot sidecar request failed", - "status": response.status_code, - "detail": detail, - }, - ) - return - - async for line in response.aiter_lines(): - if await request.is_disconnected(): - return - yield f"{line}\n" - except httpx.ReadTimeout: - yield _sse_event( - "error", - { - "message": "Copilot stream timeout", - "status": status.HTTP_504_GATEWAY_TIMEOUT, - }, - ) - except httpx.ConnectError as exc: - yield _sse_event( - "error", - { - "message": "Copilot sidecar unavailable", - "status": status.HTTP_503_SERVICE_UNAVAILABLE, - "detail": str(exc), - }, - ) - except Exception as exc: - yield _sse_event( - "error", - { - "message": "Unexpected stream proxy error", - "status": status.HTTP_500_INTERNAL_SERVER_ERROR, - "detail": str(exc), - }, - ) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index ebbefcb..f0c286e 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,13 +1,12 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( auth, - copilot, project, simulation, scada, extension, snapshots, - data_query, + # data_query, users, schemes, misc, @@ -113,6 +112,3 @@ api_router.include_router(project_data.router, tags=["Project Data"]) # Extension api_router.include_router(extension.router, tags=["Extension"]) - -# Copilot Chat -api_router.include_router(copilot.router, prefix="/copilot", tags=["Copilot"]) diff --git a/app/core/config.py b/app/core/config.py index 81b7496..7404bd4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -62,10 +62,6 @@ class Settings(BaseSettings): KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_AUDIENCE: str = "" - # Copilot Sidecar - COPILOT_SIDECAR_URL: str = "http://127.0.0.1:8787" - COPILOT_STREAM_TIMEOUT_SECONDS: int = 120 - @property def SQLALCHEMY_DATABASE_URI(self) -> str: db_password = quote_plus(self.DB_PASSWORD) diff --git a/app/infra/audit/middleware.py b/app/infra/audit/middleware.py index 544b29b..d8f1e3e 100644 --- a/app/infra/audit/middleware.py +++ b/app/infra/audit/middleware.py @@ -60,12 +60,8 @@ class AuditMiddleware(BaseHTTPMiddleware): "/meta/projects", "/api/v1/openproject/", "/openproject/", - "/api/v1/copilot/chat/", - "/api/v1/copilot/chat/stream", } EXCLUDED_PATH_PREFIXES = ( - "/api/v1/copilot/chat/", - "/copilot/chat/", ) async def dispatch(self, request: Request, call_next: Callable) -> Response: diff --git a/copilot-sidecar/server.py b/copilot-sidecar/server.py deleted file mode 100644 index a00fdf8..0000000 --- a/copilot-sidecar/server.py +++ /dev/null @@ -1,193 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -import time -import uuid -from dataclasses import dataclass -from typing import Any, Optional - -from fastapi import FastAPI, Request -from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field, ConfigDict -from copilot import CopilotClient, PermissionHandler - - -def _sse(event: str, data: dict[str, Any]) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - -@dataclass -class SessionHolder: - session: Any - last_used_at: float - - -app = FastAPI(title="TJWater Copilot Sidecar") -client: Optional[CopilotClient] = None -sessions: dict[str, SessionHolder] = {} -session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800")) -model = os.getenv("COPILOT_MODEL", "gpt-5.4") -logger = logging.getLogger("copilot_sidecar") - - -class ChatStreamRequest(BaseModel): - message: str = Field(..., min_length=1, max_length=10000) - conversation_id: Optional[str] = Field( - default=None, alias="conversationId", max_length=128 - ) - user_id: Optional[str] = Field(default=None, alias="userId", max_length=128) - model_config = ConfigDict(populate_by_name=True) - - -@app.on_event("startup") -async def startup_event() -> None: - global client - client = CopilotClient() - await client.start() - - -@app.on_event("shutdown") -async def shutdown_event() -> None: - if client is not None: - for holder in sessions.values(): - try: - await holder.session.disconnect() - except Exception as exc: - logger.warning("Failed to disconnect session during shutdown: %s", exc) - sessions.clear() - await client.stop() - - -async def _cleanup_sessions() -> None: - now = time.time() - expired = [ - sid - for sid, holder in sessions.items() - if now - holder.last_used_at > session_ttl_seconds - ] - for sid in expired: - holder = sessions.pop(sid, None) - if holder is None: - continue - try: - await holder.session.disconnect() - except Exception as exc: - logger.warning("Failed to disconnect expired session %s: %s", sid, exc) - - -async def _get_or_create_session(conversation_id: str): - await _cleanup_sessions() - if conversation_id in sessions: - sessions[conversation_id].last_used_at = time.time() - return sessions[conversation_id].session - - if client is None: - raise RuntimeError("Copilot client is not initialized") - - session = await client.create_session( - model=model, - streaming=True, - on_permission_request=PermissionHandler.approve_all, - ) - sessions[conversation_id] = SessionHolder(session=session, last_used_at=time.time()) - return session - - -@app.get("/health") -async def health() -> dict[str, Any]: - return {"ok": True, "model": model, "sessions": len(sessions)} - - -@app.post("/chat/stream") -async def chat_stream(payload: ChatStreamRequest, request: Request): - conv_id = ( - payload.conversation_id.strip() - if isinstance(payload.conversation_id, str) and payload.conversation_id.strip() - else f"conv-{uuid.uuid4().hex[:10]}" - ) - message = payload.message.strip() - - async def event_generator(): - queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue() - done = asyncio.Event() - saw_message_delta = False - - def on_event(event): - nonlocal saw_message_delta - event_type = getattr(event.type, "value", str(event.type)) - data = getattr(event, "data", None) - if event_type == "assistant.message_delta": - content = getattr(data, "delta_content", "") or "" - if content: - saw_message_delta = True - queue.put_nowait( - ("token", {"conversationId": conv_id, "content": content}) - ) - elif event_type == "assistant.message" and not saw_message_delta: - content = getattr(data, "content", "") or "" - if content: - queue.put_nowait( - ("token", {"conversationId": conv_id, "content": content}) - ) - elif event_type == "session.idle": - queue.put_nowait(("done", {"conversationId": conv_id})) - done.set() - elif event_type == "error": - queue.put_nowait( - ( - "error", - { - "conversationId": conv_id, - "message": "copilot session error", - "detail": str(data), - }, - ) - ) - done.set() - - try: - session = await _get_or_create_session(conv_id) - unsubscribe = session.on(on_event) - try: - await session.send(message) - while not done.is_set() or not queue.empty(): - if await request.is_disconnected(): - logger.info( - "Client disconnected during stream: conversation=%s", - conv_id, - ) - return - try: - event_name, event_data = await asyncio.wait_for( - queue.get(), timeout=0.2 - ) - yield _sse(event_name, event_data) - except asyncio.TimeoutError: - continue - finally: - unsubscribe() - if conv_id in sessions: - sessions[conv_id].last_used_at = time.time() - except Exception as exc: - logger.exception("Copilot generation failed for %s: %s", conv_id, exc) - yield _sse( - "error", - { - "conversationId": conv_id, - "message": "copilot generation failed", - "detail": str(exc), - }, - ) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) diff --git a/requirements.txt b/requirements.txt index e69742c..0844ca8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -168,4 +168,3 @@ zmq==0.0.0 pymoo==0.6.1.6 scikit-learn==1.6.1 scipy==1.15.2 -github-copilot-sdk==0.2.0 \ No newline at end of file diff --git a/scripts/run_server.py b/scripts/run_server.py index 5b50447..5ea313b 100644 --- a/scripts/run_server.py +++ b/scripts/run_server.py @@ -1,83 +1,22 @@ import asyncio -import atexit import os -import signal -import subprocess import sys -from urllib.parse import urlparse import uvicorn # 将项目根目录添加到 python 路径 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -_SIDECAR_PROCESS: subprocess.Popen | None = None - - -def _parse_sidecar_target() -> tuple[str, int]: - sidecar_url = os.getenv("COPILOT_SIDECAR_URL", "http://127.0.0.1:8787").strip() - parsed = urlparse(sidecar_url) - host = parsed.hostname or "127.0.0.1" - port = parsed.port or 8787 - return host, port - - -def _stop_sidecar() -> None: - global _SIDECAR_PROCESS - proc = _SIDECAR_PROCESS - if proc is None: - return - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait(timeout=3) - _SIDECAR_PROCESS = None - - -def _start_sidecar_if_needed() -> None: - global _SIDECAR_PROCESS - sidecar_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "copilot-sidecar") - ) - - host, port = _parse_sidecar_target() - cmd = [ - sys.executable, - "-m", - "uvicorn", - "server:app", - "--host", - host, - "--port", - str(port), - "--log-level", - os.getenv("COPILOT_SIDECAR_LOG_LEVEL", "warning"), - ] - _SIDECAR_PROCESS = subprocess.Popen(cmd, cwd=sidecar_dir) - print(f"[run_server] sidecar started at {host}:{port}.") - - if __name__ == "__main__": # Windows 设置事件循环策略 if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - atexit.register(_stop_sidecar) - signal.signal(signal.SIGTERM, lambda *_: _stop_sidecar()) - signal.signal(signal.SIGINT, lambda *_: _stop_sidecar()) - - _start_sidecar_if_needed() - try: - # 用 uvicorn.run 支持 workers 参数 - uvicorn.run( - "app.main:app", - host="0.0.0.0", - port=8000, - # workers=2, # 这里可以设置多进程 - loop="asyncio", - ) - finally: - _stop_sidecar() + # 用 uvicorn.run 支持 workers 参数 + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + # workers=2, # 这里可以设置多进程 + loop="asyncio", + ) diff --git a/tests/api/test_copilot_chat_endpoint.py b/tests/api/test_copilot_chat_endpoint.py deleted file mode 100644 index 86535aa..0000000 --- a/tests/api/test_copilot_chat_endpoint.py +++ /dev/null @@ -1,117 +0,0 @@ -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from app.api.v1.endpoints import copilot as copilot_endpoint - - -class _FakeStreamResponse: - def __init__(self, status_code: int, lines: list[str] | None = None, body: bytes = b""): - self.status_code = status_code - self._lines = lines or [] - self._body = body - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return None - - async def aread(self) -> bytes: - return self._body - - async def aiter_lines(self): - for line in self._lines: - yield line - - -class _FakeAsyncClient: - response: _FakeStreamResponse - captured: dict - - def __init__(self, *args, **kwargs): - self._kwargs = kwargs - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return None - - def stream(self, method: str, url: str, json: dict, headers: dict): - _FakeAsyncClient.captured = { - "method": method, - "url": url, - "json": json, - "headers": headers, - "client_kwargs": self._kwargs, - } - return _FakeAsyncClient.response - - -def _build_client(monkeypatch) -> TestClient: - app = FastAPI() - app.include_router(copilot_endpoint.router, prefix="/api/v1/copilot") - app.dependency_overrides[copilot_endpoint.get_current_keycloak_username] = ( - lambda: "tester" - ) - monkeypatch.setattr(copilot_endpoint.httpx, "AsyncClient", _FakeAsyncClient) - return TestClient(app) - - -def test_chat_stream_forwards_auth_and_payload(monkeypatch): - _FakeAsyncClient.response = _FakeStreamResponse( - status_code=200, - lines=[ - 'event: token', - 'data: {"conversationId":"c1","content":"hello"}', - "", - 'event: done', - 'data: {"conversationId":"c1"}', - "", - ], - ) - client = _build_client(monkeypatch) - - response = client.post( - "/api/v1/copilot/chat/stream", - json={"message": "hi", "conversation_id": "conv-1"}, - headers={ - "Authorization": "Bearer keycloak-token", - "X-Project-Id": "project-a", - }, - ) - - assert response.status_code == 200 - assert "text/event-stream" in response.headers["content-type"] - assert "event: token" in response.text - assert "event: done" in response.text - - captured = _FakeAsyncClient.captured - assert captured["method"] == "POST" - assert captured["url"].endswith("/chat/stream") - assert captured["headers"]["authorization"] == "Bearer keycloak-token" - assert captured["headers"]["x-project-id"] == "project-a" - assert captured["json"] == { - "message": "hi", - "conversationId": "conv-1", - "userId": "tester", - } - - -def test_chat_stream_emits_error_event_when_upstream_fails(monkeypatch): - _FakeAsyncClient.response = _FakeStreamResponse( - status_code=401, - body=b"upstream unauthorized", - ) - client = _build_client(monkeypatch) - - response = client.post( - "/api/v1/copilot/chat/stream", - json={"message": "hi"}, - headers={"Authorization": "Bearer keycloak-token"}, - ) - - assert response.status_code == 200 - assert "event: error" in response.text - assert "Copilot sidecar request failed" in response.text - assert '"status": 401' in response.text From 6b09c6b20d78901519fc45c68d06d195f3871eba Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 3 Apr 2026 14:53:55 +0800 Subject: [PATCH 08/93] =?UTF-8?q?=E5=88=A0=E9=99=A4=20Dockerfile=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cc8290b..1c6e311 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ RUN uv pip install --system --no-cache-dir -r requirements.txt # 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 COPY app ./app COPY db_inp ./db_inp -COPY temp ./temp COPY .env . # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 From 644babf77e64f1bd1035378f45c5b2c97a3add0c Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 8 Apr 2026 10:49:01 +0800 Subject: [PATCH 09/93] =?UTF-8?q?=E5=B0=86=E7=8E=AF=E5=A2=83=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=BA=E7=94=9F=E4=BA=A7=E6=A8=A1=E5=BC=8F=EF=BC=9B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=BD=91=E7=BB=9C=E5=90=8D=E7=A7=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- app/core/config.py | 4 +++- app/services/project_info.py | 5 ++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index f11f581..dd9267b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # TJWater Server 环境变量配置模板 # 复制此文件为 .env 并填写实际值 -ENVIRONMENT="local" +ENVIRONMENT="production" NETWORK_NAME="tjwater" # ============================================ # 安全配置 (必填) diff --git a/app/core/config.py b/app/core/config.py index 7404bd4..3975143 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,9 +6,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): PROJECT_NAME: str = "TJWater Server" - ENVIRONMENT: str = "local" + ENVIRONMENT: str = "production" API_V1_STR: str = "/api/v1" + NETWORK_NAME: str = "default_network" + # JWT 配置 SECRET_KEY: str = ( "your-secret-key-here-change-in-production-use-openssl-rand-hex-32" diff --git a/app/services/project_info.py b/app/services/project_info.py index 0a38481..19ebf46 100644 --- a/app/services/project_info.py +++ b/app/services/project_info.py @@ -1,4 +1,3 @@ -import os +from app.core.config import settings -# 从环境变量 NETWORK_NAME 读取 -name = os.getenv("NETWORK_NAME") +name = settings.NETWORK_NAME From 51b481d1743b25b8a5fa557a4e21593faa41eb07 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 8 Apr 2026 11:47:46 +0800 Subject: [PATCH 10/93] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/infra/epanet/epanet.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/app/infra/epanet/epanet.py b/app/infra/epanet/epanet.py index 2bf327b..a2e490d 100644 --- a/app/infra/epanet/epanet.py +++ b/app/infra/epanet/epanet.py @@ -310,11 +310,17 @@ def _safe_remove(path: str) -> None: def _make_isolated_run_paths(base_name: str, cwd: str) -> tuple[str, str, str]: + # 确保保存临时文件的目录存在 + db_inp_dir = os.path.join(cwd, "db_inp") + temp_dir = os.path.join(cwd, "temp") + os.makedirs(db_inp_dir, exist_ok=True) + os.makedirs(temp_dir, exist_ok=True) + # 进程号 + UUID 生成唯一后缀,避免并发进程互相覆盖临时文件。 token = f"{os.getpid()}_{uuid.uuid4().hex}" - inp = os.path.join(cwd, "db_inp", f"{base_name}.db.{token}.inp") - rpt = os.path.join(cwd, "temp", f"{base_name}.db.{token}.rpt") - opt = os.path.join(cwd, "temp", f"{base_name}.db.{token}.opt") + inp = os.path.join(db_inp_dir, f"{base_name}.db.{token}.inp") + rpt = os.path.join(temp_dir, f"{base_name}.db.{token}.rpt") + opt = os.path.join(temp_dir, f"{base_name}.db.{token}.opt") return inp, rpt, opt @@ -345,11 +351,17 @@ def run_project_return_dict(name: str, readable_output: bool = True) -> dict[str lib_dir = os.path.dirname(exe) env["LD_LIBRARY_PATH"] = f"{lib_dir}:{env.get('LD_LIBRARY_PATH', '')}" - process = subprocess.run([exe, inp, rpt, opt], env=env) + process = subprocess.run([exe, inp, rpt, opt], env=env, capture_output=True, text=True) result = process.returncode if result != 0: + logging.error(f"EPANET failed with return code {result}") + logging.error(f"EPANET stdout: {process.stdout}") + logging.error(f"EPANET stderr: {process.stderr}") data["simulation_result"] = "failed" + data["error_code"] = result + data["stdout"] = process.stdout + data["stderr"] = process.stderr else: data["simulation_result"] = "successful" if readable_output: @@ -360,7 +372,12 @@ def run_project_return_dict(name: str, readable_output: bool = True) -> dict[str data["input_file"] = inp data["report_file"] = rpt data["output_file"] = opt - data["report"] = dump_report(rpt) + + if os.path.exists(rpt): + data["report"] = dump_report(rpt) + else: + logging.error(f"EPANET report file not found: {rpt}") + data["report"] = f"Error: EPANET report file not found. Simulation return code: {result}. Check server logs for stdout/stderr." # 返回内容后删除仿真临时文件,避免临时文件堆积。 _safe_remove(inp) From bf2aaa5ff771393af01c1214644b5df37395ccad Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 14 Apr 2026 14:46:51 +0800 Subject: [PATCH 11/93] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E4=B8=BA=20UTC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + app/core/security.py | 16 ++- app/infra/db/timescaledb/internal_queries.py | 28 ++--- .../db/timescaledb/repositories/realtime.py | 80 ++----------- .../db/timescaledb/repositories/scheme.py | 80 ++----------- app/services/burst_detection.py | 9 +- app/services/burst_location.py | 9 +- app/services/leakage_identifier.py | 9 +- app/services/scheme_management.py | 10 +- app/services/time_api.py | 109 ++++++++++-------- resources/sql/001_create_users_table.sql | 4 +- resources/sql/002_create_audit_logs_table.sql | 2 +- .../sql/003_normalize_timestamp_columns.sql | 63 ++++++++++ resources/sql/create/40.scheme_list.sql | 4 +- tests/unit/test_burst_location_service.py | 46 +++++--- tests/unit/test_time_api.py | 45 ++++++++ 16 files changed, 263 insertions(+), 252 deletions(-) create mode 100644 resources/sql/003_normalize_timestamp_columns.sql create mode 100644 tests/unit/test_time_api.py diff --git a/.gitignore b/.gitignore index 10754c5..46780ee 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ *.dump .vscode/ app/algorithms/health/model/my_survival_forest_model_quxi.joblib +inp/ diff --git a/app/core/security.py b/app/core/security.py index 802e837..a99e69f 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, Union, Any from jose import jwt @@ -8,6 +8,10 @@ from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + def create_access_token( subject: Union[str, Any], expires_delta: Optional[timedelta] = None ) -> str: @@ -22,9 +26,9 @@ def create_access_token( JWT token 字符串 """ if expires_delta: - expire = datetime.now() + expires_delta + expire = _utc_now() + expires_delta else: - expire = datetime.now() + timedelta( + expire = _utc_now() + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) @@ -32,7 +36,7 @@ def create_access_token( "exp": expire, "sub": str(subject), "type": "access", - "iat": datetime.now(), + "iat": _utc_now(), } encoded_jwt = jwt.encode( to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM @@ -50,13 +54,13 @@ def create_refresh_token(subject: Union[str, Any]) -> str: Returns: JWT refresh token 字符串 """ - expire = datetime.now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + expire = _utc_now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) to_encode = { "exp": expire, "sub": str(subject), "type": "refresh", - "iat": datetime.now(), + "iat": _utc_now(), } encoded_jwt = jwt.encode( to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM diff --git a/app/infra/db/timescaledb/internal_queries.py b/app/infra/db/timescaledb/internal_queries.py index 3de4db7..fda46f3 100644 --- a/app/infra/db/timescaledb/internal_queries.py +++ b/app/infra/db/timescaledb/internal_queries.py @@ -10,6 +10,7 @@ from app.core.config import get_timescaledb_pgconn_string from app.infra.db.timescaledb.repositories.scheme import SchemeRepository from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository +from app.services.time_api import parse_utc_time class InternalStorage: @@ -89,10 +90,9 @@ class InternalQueries: ) -> dict: """查询指定时间点的 SCADA 数据""" - # 解析时间,假设是北京时间 - beijing_time = datetime.fromisoformat(query_time) - start_time = beijing_time - timedelta(seconds=1) - end_time = beijing_time + timedelta(seconds=1) + target_time = parse_utc_time(query_time, field_name="query_time") + start_time = target_time - timedelta(seconds=1) + end_time = target_time + timedelta(seconds=1) for attempt in range(max_retries): try: @@ -132,14 +132,8 @@ class InternalQueries: max_retries: int = 3, ) -> dict[str, list[dict]]: """查询指定时间窗的 SCADA 数据,返回 {device_id: [{time, value}, ...]}。""" - start_dt = ( - datetime.fromisoformat(start_time) - if isinstance(start_time, str) - else start_time - ) - end_dt = ( - datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time - ) + start_dt = parse_utc_time(start_time, field_name="start_time") + end_dt = parse_utc_time(end_time, field_name="end_time") for attempt in range(max_retries): try: @@ -238,14 +232,8 @@ class InternalQueries: if not element_ids: return {} - start_dt = ( - datetime.fromisoformat(start_time) - if isinstance(start_time, str) - else start_time - ) - end_dt = ( - datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time - ) + start_dt = parse_utc_time(start_time, field_name="start_time") + end_dt = parse_utc_time(end_time, field_name="end_time") table_name, valid_fields = InternalQueries._resolve_simulation_table(element_type) if field not in valid_fields: raise ValueError(f"Invalid field for {element_type}: {field}") diff --git a/app/infra/db/timescaledb/repositories/realtime.py b/app/infra/db/timescaledb/repositories/realtime.py index 06a32de..7c0facb 100644 --- a/app/infra/db/timescaledb/repositories/realtime.py +++ b/app/infra/db/timescaledb/repositories/realtime.py @@ -1,10 +1,8 @@ from typing import List, Any, Dict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from collections import defaultdict from psycopg import AsyncConnection, Connection, sql - -# 定义UTC+8时区 -UTC_8 = timezone(timedelta(hours=8)) +from app.services.time_api import parse_utc_time class RealtimeRepository: @@ -397,24 +395,9 @@ class RealtimeRepository: link_result_list: List of link simulation results result_start_time: Start time for the results (ISO format string) """ - # Convert result_start_time string to datetime if needed - if isinstance(result_start_time, str): - # 如果是ISO格式字符串,解析并转换为UTC+8 - if result_start_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat( - result_start_time.replace("Z", "+00:00") - ) - simulation_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - simulation_time = datetime.fromisoformat(result_start_time) - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) - else: - simulation_time = result_start_time - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) + simulation_time = parse_utc_time( + result_start_time, field_name="result_start_time" + ) # Prepare node data for batch insert node_data = [] @@ -475,24 +458,9 @@ class RealtimeRepository: link_result_list: List of link simulation results result_start_time: Start time for the results (ISO format string) """ - # Convert result_start_time string to datetime if needed - if isinstance(result_start_time, str): - # 如果是ISO格式字符串,解析并转换为UTC+8 - if result_start_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat( - result_start_time.replace("Z", "+00:00") - ) - simulation_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - simulation_time = datetime.fromisoformat(result_start_time) - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) - else: - simulation_time = result_start_time - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) + simulation_time = parse_utc_time( + result_start_time, field_name="result_start_time" + ) # Prepare node data for batch insert node_data = [] @@ -556,21 +524,7 @@ class RealtimeRepository: Returns: List of records matching the criteria """ - # Convert query_time string to datetime - if isinstance(query_time, str): - if query_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat(query_time.replace("Z", "+00:00")) - target_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - target_time = datetime.fromisoformat(query_time) - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) - else: - target_time = query_time - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) + target_time = parse_utc_time(query_time, field_name="query_time") # Create time range: query_time ± 1 second start_time = target_time - timedelta(seconds=1) @@ -614,21 +568,7 @@ class RealtimeRepository: Returns: List of records matching the criteria """ - # Convert query_time string to datetime - if isinstance(query_time, str): - if query_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat(query_time.replace("Z", "+00:00")) - target_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - target_time = datetime.fromisoformat(query_time) - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) - else: - target_time = query_time - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) + target_time = parse_utc_time(query_time, field_name="query_time") # Create time range: query_time ± 1 second start_time = target_time - timedelta(seconds=1) diff --git a/app/infra/db/timescaledb/repositories/scheme.py b/app/infra/db/timescaledb/repositories/scheme.py index bfa09ca..f0960b3 100644 --- a/app/infra/db/timescaledb/repositories/scheme.py +++ b/app/infra/db/timescaledb/repositories/scheme.py @@ -1,11 +1,9 @@ from typing import List, Any, Dict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from collections import defaultdict from psycopg import AsyncConnection, Connection, sql import app.services.globals as globals - -# 定义UTC+8时区 -UTC_8 = timezone(timedelta(hours=8)) +from app.services.time_api import parse_utc_time class SchemeRepository: @@ -466,24 +464,9 @@ class SchemeRepository: link_result_list: List of link simulation results result_start_time: Start time for the results (ISO format string) """ - # Convert result_start_time string to datetime if needed - if isinstance(result_start_time, str): - # 如果是ISO格式字符串,解析并转换为UTC+8 - if result_start_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat( - result_start_time.replace("Z", "+00:00") - ) - simulation_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - simulation_time = datetime.fromisoformat(result_start_time) - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) - else: - simulation_time = result_start_time - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) + simulation_time = parse_utc_time( + result_start_time, field_name="result_start_time" + ) timestep_parts = globals.hydraulic_timestep.split(":") timestep = timedelta( @@ -564,24 +547,9 @@ class SchemeRepository: link_result_list: List of link simulation results result_start_time: Start time for the results (ISO format string) """ - # Convert result_start_time string to datetime if needed - if isinstance(result_start_time, str): - # 如果是ISO格式字符串,解析并转换为UTC+8 - if result_start_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat( - result_start_time.replace("Z", "+00:00") - ) - simulation_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - simulation_time = datetime.fromisoformat(result_start_time) - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) - else: - simulation_time = result_start_time - if simulation_time.tzinfo is None: - simulation_time = simulation_time.replace(tzinfo=UTC_8) + simulation_time = parse_utc_time( + result_start_time, field_name="result_start_time" + ) timestep_parts = globals.hydraulic_timestep.split(":") timestep = timedelta( @@ -664,21 +632,7 @@ class SchemeRepository: Returns: List of records matching the criteria """ - # Convert query_time string to datetime - if isinstance(query_time, str): - if query_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat(query_time.replace("Z", "+00:00")) - target_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - target_time = datetime.fromisoformat(query_time) - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) - else: - target_time = query_time - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) + target_time = parse_utc_time(query_time, field_name="query_time") # Create time range: query_time ± 1 second start_time = target_time - timedelta(seconds=1) @@ -727,21 +681,7 @@ class SchemeRepository: Returns: List of records matching the criteria """ - # Convert query_time string to datetime - if isinstance(query_time, str): - if query_time.endswith("Z"): - # UTC时间,转换为UTC+8 - utc_time = datetime.fromisoformat(query_time.replace("Z", "+00:00")) - target_time = utc_time.astimezone(UTC_8) - else: - # 假设已经是UTC+8时间 - target_time = datetime.fromisoformat(query_time) - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) - else: - target_time = query_time - if target_time.tzinfo is None: - target_time = target_time.replace(tzinfo=UTC_8) + target_time = parse_utc_time(query_time, field_name="query_time") # Create time range: query_time ± 1 second start_time = target_time - timedelta(seconds=1) diff --git a/app/services/burst_detection.py b/app/services/burst_detection.py index 59baf32..9665934 100644 --- a/app/services/burst_detection.py +++ b/app/services/burst_detection.py @@ -14,6 +14,7 @@ from app.services.scheme_management import ( store_scheme_info, ) from app.services.tjnetwork import get_all_scada_info +from app.services.time_api import extract_date, parse_utc_time, utc_now def run_burst_detection( @@ -241,7 +242,7 @@ def list_burst_detection_schemes( network: str, query_date: datetime | str | None = None, ) -> list[dict[str, Any]]: - parsed_date = _to_datetime(query_date).date() if query_date is not None else None + parsed_date = extract_date(query_date, field_name="query_date") if query_date is not None else None return query_burst_detection_schemes( name=network, network=network, @@ -269,7 +270,7 @@ def _store_burst_detection_scheme( if scheme_name_exists(network, scheme_name): raise ValueError(f"方案名称已存在: {scheme_name}") - now_iso = datetime.now().isoformat() + now_iso = utc_now().isoformat() scheme_detail = { "network": network, "sensor_nodes": payload.get("sensor_nodes", []), @@ -426,6 +427,4 @@ def _build_observed_pressure_from_scada( def _to_datetime(value: datetime | str) -> datetime: - if isinstance(value, datetime): - return value - return datetime.fromisoformat(value) + return parse_utc_time(value) diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 3892ca2..5a6b52b 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -15,6 +15,7 @@ from app.services.scheme_management import ( store_scheme_info, ) from app.services.tjnetwork import dump_inp, get_all_scada_info +from app.services.time_api import extract_date, parse_utc_time, utc_now SeriesInput = pd.Series | dict[str, Any] | list[dict[str, Any]] FLOW_SCADA_TYPES = {"pipe_flow", "flow", "demand"} @@ -301,7 +302,7 @@ def run_burst_location_by_network( def list_burst_location_schemes( network: str, query_date: datetime | str | None = None ) -> list[dict[str, Any]]: - parsed_date = _to_datetime(query_date).date() if query_date is not None else None + parsed_date = extract_date(query_date, field_name="query_date") if query_date is not None else None return query_burst_location_schemes( name=network, network=network, query_date=parsed_date ) @@ -327,7 +328,7 @@ def _store_burst_scheme( if scheme_name_exists(network, scheme_name): raise ValueError(f"方案名称已存在: {scheme_name}") - now_iso = datetime.now().isoformat() + now_iso = utc_now().isoformat() scheme_detail = { "network": network, "pressure_scada_ids": payload.get("pressure_scada_ids", []), @@ -641,9 +642,7 @@ def _dedupe_ids(ids: list[str] | None) -> list[str]: def _to_datetime(value: datetime | str) -> datetime: - if isinstance(value, datetime): - return value - return datetime.fromisoformat(value) + return parse_utc_time(value) def _prepare_burst_inp(network: str) -> str: diff --git a/app/services/leakage_identifier.py b/app/services/leakage_identifier.py index e90cb24..a85d653 100644 --- a/app/services/leakage_identifier.py +++ b/app/services/leakage_identifier.py @@ -23,6 +23,7 @@ from app.services.tjnetwork import ( get_network_link_nodes, get_network_node_coords, ) +from app.services.time_api import extract_date, parse_utc_time, utc_now DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4)) @@ -119,7 +120,7 @@ def run_leakage_identification( scheme_start_time = ( _to_datetime(scada_start).isoformat() if scada_start is not None - else datetime.now().isoformat() + else utc_now().isoformat() ) scheme_detail = { "network": network, @@ -177,7 +178,7 @@ def run_leakage_identification( def list_leakage_identify_schemes( network: str, query_date: datetime | str | None = None ) -> list[dict[str, Any]]: - parsed_date = _to_datetime(query_date).date() if query_date is not None else None + parsed_date = extract_date(query_date, field_name="query_date") if query_date is not None else None return query_leakage_identify_schemes( name=network, network=network, query_date=parsed_date ) @@ -509,9 +510,7 @@ def _build_observed_pressure_from_scada( def _to_datetime(value: datetime | str) -> datetime: - if isinstance(value, datetime): - return value - return datetime.fromisoformat(value) + return parse_utc_time(value) def _prepare_leakage_inp(network: str) -> str: diff --git a/app/services/scheme_management.py b/app/services/scheme_management.py index a86a9bd..0bb1f11 100644 --- a/app/services/scheme_management.py +++ b/app/services/scheme_management.py @@ -1,6 +1,6 @@ import ast import json -from datetime import date +from datetime import date, datetime import geopandas as gpd import pandas as pd @@ -8,6 +8,7 @@ import psycopg from sqlalchemy import create_engine from app.core.config import get_pgconn_string +from app.services.time_api import parse_utc_time # 2025/03/23 @@ -89,7 +90,7 @@ def store_scheme_info( scheme_name: str, scheme_type: str, username: str, - scheme_start_time: str, + scheme_start_time: datetime | str, scheme_detail: dict, ): """ @@ -112,13 +113,16 @@ def store_scheme_info( """ # 将字典转换为 JSON 字符串 scheme_detail_json = json.dumps(scheme_detail) + normalized_scheme_start_time = parse_utc_time( + scheme_start_time, field_name="scheme_start_time" + ) cur.execute( sql, ( scheme_name, scheme_type, username, - scheme_start_time, + normalized_scheme_start_time, scheme_detail_json, ), ) diff --git a/app/services/time_api.py b/app/services/time_api.py index 85cc2f4..00904e2 100644 --- a/app/services/time_api.py +++ b/app/services/time_api.py @@ -1,5 +1,6 @@ -from datetime import datetime, timezone, timedelta -from dateutil import parser, tz +from datetime import date, datetime, time, timedelta, timezone + +from dateutil import parser, tz ''' 2025-02-09T15:45:00+00:00 采用的是 ISO 8601 国际标准日期时间格式,具体特点如下: @@ -13,57 +14,67 @@ from dateutil import parser, tz 2025-02-09T15:45:00+08:00 ''' -BG_TZ = tz.gettz('Asia/Shanghai') -UTC_TZ = tz.gettz('UTC') +BG_TZ = tz.gettz("Asia/Shanghai") +UTC_TZ = timezone.utc -def parse_utc_time(query_time: str) -> datetime: - ''' - 接受 任意格式的字符串,如果解析出来不带时区,则用 replace 添加 +00:00 时区 - 如果解析出来已经有时区,则用 astimezone 转换成UTC时间 - ''' +TIMEZONE_REQUIRED_MESSAGE = ( + "Datetime values must include an explicit timezone offset, for example " + "'2025-02-09T15:45:00Z' or '2025-02-09T23:45:00+08:00'." +) - # 解析时间字符串 - dt: datetime = parser.parse(query_time) + +def parse_aware_time(query_time: datetime | str, field_name: str = "datetime") -> datetime: + """ + 解析时间并确保结果带有时区信息。 + """ + dt = parser.parse(query_time) if isinstance(query_time, str) else query_time if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC_TZ) - else: - dt = dt.astimezone(UTC_TZ) - + raise ValueError(f"{field_name} is missing timezone information. {TIMEZONE_REQUIRED_MESSAGE}") return dt + + +def extract_date(value: date | datetime | str, field_name: str = "date") -> date: + """ + 提取日期部分,但保留调用方原始时区语义,不强制转换到 UTC。 + """ + if isinstance(value, date) and not isinstance(value, datetime): + return value + return parse_aware_time(value, field_name=field_name).date() + + +def utc_now() -> datetime: + """ + 返回带 UTC 时区的当前时间。 + """ + return datetime.now(UTC_TZ) + + +def parse_utc_time(query_time: datetime | str, field_name: str = "datetime") -> datetime: + ''' + 接受带时区的时间字符串/对象,并统一转换成 UTC 时间。 + ''' + return parse_aware_time(query_time, field_name=field_name).astimezone(UTC_TZ) -def parse_beijing_time(query_time: str) -> datetime: + +def parse_beijing_time(query_time: datetime | str, field_name: str = "datetime") -> datetime: ''' - 接受 任意格式的字符串,如果解析出来不带时区,则用 replace 添加 +08:00 时区 - 如果解析出来已经有时区,则用 astimezone 转换成北京时间 - - 也就是任意合法的时间字符串,最后都解析成 北京 时间 - + 接受带时区的时间字符串/对象,并统一转换成北京时间。 ''' - - # 解析时间字符串 - dt: datetime = parser.parse(query_time) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=BG_TZ) - else: - dt = dt.astimezone(tz=BG_TZ) - - return dt + return parse_aware_time(query_time, field_name=field_name).astimezone(tz=BG_TZ) -def to_utc_time(dt: datetime) -> datetime: +def to_utc_time(dt: datetime | str, field_name: str = "datetime") -> datetime: ''' - 将一个北京时间的时间点,转换成utc + 将一个带时区的时间点,转换成 UTC。 ''' - utc_time = dt.astimezone(UTC_TZ) - return utc_time + return parse_aware_time(dt, field_name=field_name).astimezone(UTC_TZ) -def to_beijing_time(dt: datetime) -> datetime: +def to_beijing_time(dt: datetime | str, field_name: str = "datetime") -> datetime: ''' - 将一个 utc 的时间点,转换成北京时间 + 将一个带时区的时间点,转换成北京时间。 ''' - beijing_time = dt.astimezone(tz=BG_TZ) - return beijing_time + return parse_aware_time(dt, field_name=field_name).astimezone(tz=BG_TZ) def to_time_range(dt: datetime, delta: float) -> tuple[datetime, datetime]: @@ -83,7 +94,8 @@ def parse_beijing_date_range(query_date: str) -> tuple[datetime, datetime]: 将一个日期字符串,转换成 start/end 时间段,传进来的日期被认为是北京时间 日期字符串格式:YYYY-MM-DD ''' - start_time = parse_beijing_time(query_date) + target_date = date.fromisoformat(query_date) + start_time = datetime.combine(target_date, time.min, BG_TZ) end_time = start_time + timedelta(days=1) return (start_time, end_time) @@ -108,7 +120,7 @@ def get_date_from_time(time: str) -> str: ''' 将一个时间点,转换成日期 ''' - dt = parse_beijing_time(time) + dt = parse_beijing_time(time, field_name="time") return str(dt.date()) @@ -116,28 +128,27 @@ def is_today(query_date: str) -> bool: ''' 判断一个日期是否是今天 ''' - dt = parse_beijing_time(query_date) - return dt.date() == datetime.now().date() + dt = parse_beijing_time(query_date, field_name="query_date") + return dt.date() == datetime.now(BG_TZ).date() def is_yesterday(query_date: str) -> bool: ''' 判断一个日期是否是昨天 ''' - dt = parse_beijing_time(query_date) - return dt.date() == (datetime.now().date() - timedelta(days=1)) + dt = parse_beijing_time(query_date, field_name="query_date") + return dt.date() == (datetime.now(BG_TZ).date() - timedelta(days=1)) def is_tomorrow(query_date: str) -> bool: ''' 判断一个日期是否是明天 ''' - dt = parse_beijing_time(query_date) - return dt.date() == (datetime.now().date() + timedelta(days=1)) + dt = parse_beijing_time(query_date, field_name="query_date") + return dt.date() == (datetime.now(BG_TZ).date() + timedelta(days=1)) def is_today_or_future(query_date: str) -> bool: ''' 判断一个日期是否是今天或未来 ''' - dt = parse_beijing_time(query_date) - return dt.date() >= datetime.now().date() - + dt = parse_beijing_time(query_date, field_name="query_date") + return dt.date() >= datetime.now(BG_TZ).date() diff --git a/resources/sql/001_create_users_table.sql b/resources/sql/001_create_users_table.sql index d0eb301..5caed32 100644 --- a/resources/sql/001_create_users_table.sql +++ b/resources/sql/001_create_users_table.sql @@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS users ( role VARCHAR(20) DEFAULT 'USER' NOT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL, is_superuser BOOLEAN DEFAULT FALSE NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT users_role_check CHECK (role IN ('ADMIN', 'OPERATOR', 'USER', 'VIEWER')) ); diff --git a/resources/sql/002_create_audit_logs_table.sql b/resources/sql/002_create_audit_logs_table.sql index 5fdc1c1..6f0d9fe 100644 --- a/resources/sql/002_create_audit_logs_table.sql +++ b/resources/sql/002_create_audit_logs_table.sql @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS audit_logs ( request_data JSONB, response_status INTEGER, error_message TEXT, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL ); -- 创建索引以提高查询性能 diff --git a/resources/sql/003_normalize_timestamp_columns.sql b/resources/sql/003_normalize_timestamp_columns.sql new file mode 100644 index 0000000..bd99af4 --- /dev/null +++ b/resources/sql/003_normalize_timestamp_columns.sql @@ -0,0 +1,63 @@ +-- ============================================ +-- TJWater Server 时区统一迁移脚本 +-- 将历史无时区时间列升级为 TIMESTAMP WITH TIME ZONE +-- 约定:历史无时区值按 UTC 解释 +-- ============================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'created_at' + AND data_type = 'timestamp without time zone' + ) THEN + EXECUTE 'ALTER TABLE public.users + ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE + USING created_at AT TIME ZONE ''UTC'''; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'updated_at' + AND data_type = 'timestamp without time zone' + ) THEN + EXECUTE 'ALTER TABLE public.users + ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE + USING updated_at AT TIME ZONE ''UTC'''; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'audit_logs' + AND column_name = 'timestamp' + AND data_type = 'timestamp without time zone' + ) THEN + EXECUTE 'ALTER TABLE public.audit_logs + ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE + USING "timestamp" AT TIME ZONE ''UTC'''; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'scheme_list' + AND column_name = 'scheme_start_time' + AND data_type IN ('character varying', 'text') + ) THEN + EXECUTE 'ALTER TABLE public.scheme_list + ALTER COLUMN scheme_start_time TYPE TIMESTAMP WITH TIME ZONE + USING CASE + WHEN scheme_start_time ~ ''(Z|[+-][0-9]{2}:[0-9]{2})$'' THEN scheme_start_time::timestamptz + ELSE scheme_start_time::timestamp AT TIME ZONE ''UTC'' + END'; + END IF; +END $$; diff --git a/resources/sql/create/40.scheme_list.sql b/resources/sql/create/40.scheme_list.sql index 4dbb7f9..0b8d007 100644 --- a/resources/sql/create/40.scheme_list.sql +++ b/resources/sql/create/40.scheme_list.sql @@ -9,6 +9,6 @@ create table scheme_list ( scheme_type varchar(32) not null, username varchar(32) not null REFERENCES "users"(username) ON UPDATE CASCADE ON DELETE RESTRICT, create_time TIMESTAMP WITH TIME ZONE not null DEFAULT date_trunc('minute', CURRENT_TIMESTAMP), - scheme_start_time varchar(50) not null, + scheme_start_time TIMESTAMP WITH TIME ZONE not null, scheme_detail JSON -) \ No newline at end of file +) diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index abe38a1..e5132fb 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -1,7 +1,7 @@ import importlib.util import sys import types -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -30,6 +30,24 @@ def _load_burst_location_module(): ]: ensure_package(package_name) + time_api_module = types.ModuleType("app.services.time_api") + time_api_module.parse_utc_time = ( + lambda value, field_name="datetime": ( + value.astimezone(timezone.utc) + if isinstance(value, datetime) and value.tzinfo is not None + else datetime.fromisoformat(value).astimezone(timezone.utc) + ) + ) + time_api_module.extract_date = ( + lambda value, field_name="date": ( + value.date() + if isinstance(value, datetime) + else datetime.fromisoformat(value).date() + ) + ) + time_api_module.utc_now = lambda: datetime.now(timezone.utc) + sys.modules["app.services.time_api"] = time_api_module + algorithms_module = types.ModuleType("app.algorithms.burst_location") algorithms_module.run_burst_location = lambda **kwargs: {} sys.modules["app.algorithms.burst_location"] = algorithms_module @@ -125,16 +143,16 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey def fake_scheme_query(**kwargs): scheme_calls.append(kwargs) + start_hour = datetime.fromisoformat(kwargs["start_time"]).astimezone( + timezone(timedelta(hours=8)) + ).hour if kwargs["element_type"] == "node" and kwargs["field"] == "pressure": - start_hour = datetime.fromisoformat(kwargs["start_time"]).hour values = [12.0, 14.0, 16.0, 18.0] if start_hour == 8 else [8.0, 10.0, 12.0, 14.0] return {"J1": _build_series(kwargs["start_time"], values)} if kwargs["element_type"] == "link" and kwargs["field"] == "flow": - start_hour = datetime.fromisoformat(kwargs["start_time"]).hour values = [5.0, 7.0, 9.0, 11.0] if start_hour == 8 else [2.0, 4.0, 6.0, 8.0] return {"P1": _build_series(kwargs["start_time"], values)} if kwargs["element_type"] == "node" and kwargs["field"] == "actual_demand": - start_hour = datetime.fromisoformat(kwargs["start_time"]).hour values = [3.0, 5.0, 7.0, 9.0] if start_hour == 8 else [1.0, 3.0, 5.0, 7.0] return {"J2": _build_series(kwargs["start_time"], values)} raise AssertionError(f"Unexpected scheme query: {kwargs}") @@ -167,8 +185,8 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey simulation_scheme_name="BurstSchemeA", simulation_scheme_type="burst_analysis", burst_leakage=10.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0), + scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), use_scada_flow=True, ) @@ -192,14 +210,14 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey assert any(call["element_type"] == "link" and call["field"] == "flow" for call in scheme_calls) assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in scheme_calls) assert len(realtime_calls) == 3 - assert all(datetime.fromisoformat(call["start_time"]).hour == 8 for call in realtime_calls) - assert all(datetime.fromisoformat(call["end_time"]).hour == 9 for call in realtime_calls) + assert all(datetime.fromisoformat(call["start_time"]).hour == 0 for call in realtime_calls) + assert all(datetime.fromisoformat(call["end_time"]).hour == 1 for call in realtime_calls) assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in realtime_calls) assert any(call["element_type"] == "link" and call["field"] == "flow" for call in realtime_calls) assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in realtime_calls) assert result["scada_window"] == { - "burst_start": "2025-01-01T08:00:00", - "burst_end": "2025-01-01T09:00:00", + "burst_start": "2025-01-01T00:00:00+00:00", + "burst_end": "2025-01-01T01:00:00+00:00", } @@ -225,8 +243,8 @@ def test_run_burst_location_requires_simulation_scheme_name(monkeypatch, tmp_pat username="testuser", data_source="simulation", burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0), + scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), ) @@ -290,8 +308,8 @@ def test_run_burst_location_monitoring_uses_scada_for_burst_and_realtime_for_nor username="testuser", data_source="monitoring", burst_leakage=1.0, - scada_burst_start=datetime(2025, 1, 1, 8, 0, 0), - scada_burst_end=datetime(2025, 1, 1, 9, 0, 0), + scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), ) assert result["observed_source"] == "scada_burst_realtime_normal_timerange" diff --git a/tests/unit/test_time_api.py b/tests/unit/test_time_api.py new file mode 100644 index 0000000..ef1bbfb --- /dev/null +++ b/tests/unit/test_time_api.py @@ -0,0 +1,45 @@ +import importlib.util +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import pytest + + +def _load_time_api_module(): + module_path = ( + Path(__file__).resolve().parents[2] / "app" / "services" / "time_api.py" + ) + spec = importlib.util.spec_from_file_location("tests_time_api_under_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def test_parse_utc_time_rejects_naive_datetimes(): + module = _load_time_api_module() + + with pytest.raises(ValueError, match="timezone information"): + module.parse_utc_time("2025-01-01T08:00:00") + + +def test_parse_utc_time_normalizes_offset_datetime_to_utc(): + module = _load_time_api_module() + result = module.parse_utc_time("2025-01-01T08:00:00+08:00") + + assert result == datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) + + +def test_extract_date_keeps_original_offset_calendar_day(): + module = _load_time_api_module() + result = module.extract_date("2025-01-01T00:30:00+08:00") + + assert result == date(2025, 1, 1) + + +def test_utc_now_returns_timezone_aware_utc_datetime(): + module = _load_time_api_module() + result = module.utc_now() + + assert result.tzinfo == timezone.utc + assert result.utcoffset() == timedelta(0) From 3b712ea467c97f59eb76c4e7c6ae948c2c31c08b Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 17 Apr 2026 17:21:50 +0800 Subject: [PATCH 12/93] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BC=A0=E6=84=9F?= =?UTF-8?q?=E5=99=A8=E5=B8=83=E7=BD=AE=E7=AE=97=E6=B3=95=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=95=B0=E6=8D=AE=E5=BA=93=E6=9B=B4=E6=96=B0=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/algorithms/cleaning/pressure.py | 654 +++++++++++++----- app/algorithms/sensor/kmeans.py | 88 +-- app/algorithms/sensor/sensitivity.py | 136 ++-- app/api/v1/endpoints/simulation.py | 4 +- .../db/timescaledb/repositories/scada.py | 9 +- tests/unit/test_pressure_cleaning.py | 108 +++ tests/unit/test_scada_repository.py | 87 +++ 7 files changed, 795 insertions(+), 291 deletions(-) create mode 100644 tests/unit/test_pressure_cleaning.py create mode 100644 tests/unit/test_scada_repository.py diff --git a/app/algorithms/cleaning/pressure.py b/app/algorithms/cleaning/pressure.py index 6fc545e..2287ba3 100644 --- a/app/algorithms/cleaning/pressure.py +++ b/app/algorithms/cleaning/pressure.py @@ -1,18 +1,435 @@ import pandas as pd import numpy as np import matplotlib.pyplot as plt -from sklearn.cluster import KMeans -from sklearn.impute import SimpleImputer import os -from app.algorithms._utils import fill_time_gaps +ID_LIKE_COLUMNS = { + "id", + "device_id", + "node_id", + "sensor_id", + "monitor_id", + "junction_id", +} + + +def _normalize_time_frame(data: pd.DataFrame) -> pd.DataFrame: + """返回按时间排序的副本,并尽量将 time 列解析为时间类型。""" + data = data.copy() + if "time" in data.columns: + data["time"] = pd.to_datetime(data["time"], errors="coerce") + data = data.sort_values(["time"]).reset_index(drop=True) + return data + + +def _select_pressure_columns(data: pd.DataFrame) -> tuple[list[str], list[str]]: + """区分需要清洗的数值列与需要原样保留的列。""" + value_cols: list[str] = [] + keep_cols: list[str] = [] + for col in data.columns: + if col == "time": + continue + col_key = col.lower() + if col_key in ID_LIKE_COLUMNS or col_key.endswith("_id"): + keep_cols.append(col) + continue + numeric = pd.to_numeric(data[col], errors="coerce") + if numeric.notna().sum() == 0 or numeric.nunique(dropna=True) <= 1: + keep_cols.append(col) + else: + value_cols.append(col) + return value_cols, keep_cols + + +def _robust_scale(values: pd.Series) -> float: + """基于 MAD 计算稳健尺度。""" + series = pd.to_numeric(values, errors="coerce").dropna() + if series.empty: + return 1.0 + median = series.median() + mad = (series - median).abs().median() + if pd.notna(mad) and mad > 0: + return float(1.4826 * mad) + iqr = series.quantile(0.75) - series.quantile(0.25) + if pd.notna(iqr) and iqr > 0: + return float(iqr / 1.349) + std = series.std() + if pd.notna(std) and std > 0: + return float(std) + return 1.0 + + +def _shrink_toward_baseline(observed: float, baseline: float, scale: float) -> float: + """把观测值向基线值收缩,scale 越小,修复越强。""" + if pd.isna(observed): + return baseline + if pd.isna(baseline): + return observed + diff = observed - baseline + weight = scale / (abs(diff) + scale) + return float(baseline + diff * weight) + + +def _infer_time_frequency(time_values: pd.Series | pd.Index) -> pd.Timedelta: + """从时间序列中推断采样频率,失败时默认 15 分钟。""" + parsed = pd.to_datetime(pd.Series(time_values), errors="coerce").dropna().sort_values() + if len(parsed) < 2: + return pd.Timedelta(minutes=15) + + diffs = parsed.diff().dropna() + diffs = diffs[diffs > pd.Timedelta(0)] + if diffs.empty: + return pd.Timedelta(minutes=15) + + mode = diffs.mode() + return mode.iloc[0] if not mode.empty else diffs.median() + + +def _build_local_pressure_baseline(series: pd.Series) -> pd.Series: + """基于局部插值与中值滤波构造平滑基线。""" + baseline = _safe_time_interpolate(series) + baseline = baseline.rolling(window=5, center=True, min_periods=1).median() + baseline = _safe_time_interpolate(baseline) + return baseline.ffill().bfill() + + +def _build_seasonal_pressure_baseline(series: pd.Series) -> pd.Series: + """按一天内的同一时刻构造季节性基线,适合日周期压力数据。""" + if not isinstance(series.index, pd.DatetimeIndex): + return pd.Series(np.nan, index=series.index, dtype=float) + + slot_labels = pd.Series(series.index.strftime("%H:%M:%S"), index=series.index) + return series.groupby(slot_labels).transform("median") + + +def _detect_pressure_spikes(series: pd.Series, local_baseline: pd.Series) -> pd.Series: + """识别单点异常上升/下降尖峰,避免过度修正正常波动。""" + residual = series - local_baseline + neighbor_center = (series.shift(1) + series.shift(-1)) / 2 + curvature = series - neighbor_center + + residual_scale = max(_robust_scale(residual), 1e-6) + curvature_scale = max(_robust_scale(curvature), 1e-6) + direction_flip = ((series - series.shift(1)) * (series.shift(-1) - series) < 0).fillna(False) + + return ( + residual.abs() > 3.5 * residual_scale + ) & ( + curvature.abs() > 3.0 * curvature_scale + ) & direction_flip + + +def _fill_pressure_gaps( + original: pd.Series, + repaired: pd.Series, + local_baseline: pd.Series, + seasonal_baseline: pd.Series, +) -> pd.Series: + """短缺口用局部插值,长缺口优先使用同一时刻的季节性轨迹。""" + missing_mask = original.isna() + if not missing_mask.any(): + return repaired + + gap_groups = (missing_mask != missing_mask.shift(fill_value=False)).cumsum() + gap_lengths = missing_mask.groupby(gap_groups).transform("sum").where(missing_mask, 0) + + filled = repaired.copy() + short_gap_mask = missing_mask & (gap_lengths < 4) + long_gap_mask = missing_mask & ~short_gap_mask + + filled[short_gap_mask] = local_baseline[short_gap_mask] + long_gap_fill = seasonal_baseline.where(seasonal_baseline.notna(), local_baseline) + filled[long_gap_mask] = long_gap_fill[long_gap_mask] + return filled + + +def _clean_pressure_series(series: pd.Series) -> pd.Series: + """清洗单个压力时间序列。""" + series = pd.to_numeric(series, errors="coerce").astype(float) + local_baseline = _build_local_pressure_baseline(series) + spike_mask = _detect_pressure_spikes(series, local_baseline) + + repaired = series.copy() + repaired[spike_mask] = local_baseline[spike_mask] + + seasonal_baseline = _build_seasonal_pressure_baseline(repaired) + repaired = _fill_pressure_gaps(series, repaired, local_baseline, seasonal_baseline) + + if repaired.isna().any(): + repaired = repaired.where(repaired.notna(), local_baseline) + return repaired.ffill().bfill() + + +def _format_time_column(data: pd.DataFrame) -> pd.DataFrame: + """统一输出时间格式,方便下游直接按 ISO 字符串解析。""" + if "time" not in data.columns: + return data + + formatted = data.copy() + time_values = pd.to_datetime(formatted["time"], errors="coerce") + if time_values.isna().all(): + return formatted + + if time_values.dt.tz is not None: + time_strings = time_values.dt.strftime("%Y-%m-%dT%H:%M:%S%z") + time_strings = time_strings.str.replace( + r"([+-]\d{2})(\d{2})$", + r"\1:\2", + regex=True, + ) + else: + time_strings = time_values.dt.strftime("%Y-%m-%dT%H:%M:%S") + + formatted["time"] = time_strings.where(time_values.notna(), formatted["time"]) + return formatted + + +def _expand_snapshot_time_grid(data: pd.DataFrame, freq: pd.Timedelta) -> pd.DataFrame: + """仅补齐时间轴,不提前填充值,避免长缺口丢失原始形状特征。""" + expanded = data.copy() + expanded["time"] = pd.to_datetime(expanded["time"], errors="coerce") + expanded = expanded.dropna(subset=["time"]).sort_values("time") + if expanded.empty: + return data + + indexed = expanded.set_index("time") + full_index = pd.date_range(indexed.index.min(), indexed.index.max(), freq=freq) + indexed = indexed.reindex(full_index) + indexed.index.name = "time" + return indexed.reset_index() + + +def _safe_datetime_index(values: pd.Series | pd.Index | list[object]) -> pd.DatetimeIndex | None: + """尽量把时间值标准化为 DatetimeIndex;失败则返回 None。""" + parsed = pd.to_datetime(values, errors="coerce") + try: + datetime_index = pd.DatetimeIndex(parsed) + except (TypeError, ValueError): + return None + + if datetime_index.isna().all(): + return None + return datetime_index + + +def _safe_time_interpolate(series: pd.Series) -> pd.Series: + """仅在索引确实是 DatetimeIndex 时使用 time interpolation。""" + if isinstance(series.index, pd.DatetimeIndex): + return series.interpolate(method="time", limit_direction="both") + return series.interpolate(limit_direction="both") + + +def _detect_long_form_identifier(data: pd.DataFrame, value_cols: list[str], keep_cols: list[str]) -> str | None: + """识别 time/id/value 长表结构。""" + if "time" not in data.columns or len(value_cols) != 1: + return None + + identifier_candidates = [ + col + for col in keep_cols + if col.lower() in ID_LIKE_COLUMNS or col.lower().endswith("_id") + ] + if len(identifier_candidates) != 1: + return None + if not data["time"].duplicated().any(): + return None + return identifier_candidates[0] + + +def _clean_long_form_pressure( + data: pd.DataFrame, + value_col: str, + identifier_col: str, + keep_cols: list[str], + fill_gaps: bool, +) -> pd.DataFrame: + """按测点拆分 long-form 压力数据,再逐列清洗后恢复原结构。""" + data = _normalize_time_frame(data) + wide_df = ( + data[[identifier_col, "time", value_col]] + .pivot(index="time", columns=identifier_col, values=value_col) + .reset_index() + ) + + sensor_cols = [col for col in wide_df.columns if col != "time"] + cleaned_wide = _clean_snapshot_pressure(wide_df, sensor_cols, keep_cols=[], fill_gaps=fill_gaps) + + cleaned_long = cleaned_wide.melt( + id_vars="time", + var_name=identifier_col, + value_name=value_col, + ) + + passthrough_cols = [col for col in keep_cols if col != identifier_col] + if passthrough_cols: + metadata = data[[identifier_col] + passthrough_cols].drop_duplicates(subset=[identifier_col]) + cleaned_long = cleaned_long.merge(metadata, on=identifier_col, how="left") + + try: + cleaned_long[identifier_col] = cleaned_long[identifier_col].astype(data[identifier_col].dtype) + except (TypeError, ValueError): + pass + + cleaned_long = cleaned_long.sort_values(["time", identifier_col]).reset_index(drop=True) + ordered_cols = ["time", identifier_col] + passthrough_cols + [value_col] + cleaned_long = cleaned_long[[col for col in ordered_cols if col in cleaned_long.columns]] + return cleaned_long + + +def _build_time_slot_frame( + data: pd.DataFrame, value_col: str, expected_slots: int +) -> pd.DataFrame: + """把重复时间点整理成 time x slot 的矩阵。""" + grouped = data.groupby("time", sort=True) + times = list(grouped.groups.keys()) + slot_frame = pd.DataFrame(index=pd.Index(times, name="time"), columns=range(expected_slots), dtype=float) + + for time_value, group in grouped: + values = pd.to_numeric(group[value_col], errors="coerce").tolist() + for slot_idx, value in enumerate(values[:expected_slots]): + slot_frame.loc[time_value, slot_idx] = value + return slot_frame + + +def _slot_baseline(slot_frame: pd.DataFrame) -> pd.DataFrame: + """对每个槽位做时间插值和平滑,得到基线轨迹。""" + baseline = pd.DataFrame(index=slot_frame.index, columns=slot_frame.columns, dtype=float) + for col in slot_frame.columns: + series = slot_frame[col].astype(float) + series = _safe_time_interpolate(series) + series = series.rolling(window=5, center=True, min_periods=1).median() + series = _safe_time_interpolate(series).ffill().bfill() + baseline[col] = series + return baseline + + +def _choose_insertion_position( + observed: list[float], baseline_row: pd.Series, expected_slots: int +) -> int: + """为少一个观测值的时间组选择最合理的插入位置。""" + missing_count = expected_slots - len(observed) + if missing_count <= 0: + return 0 + + best_pos = 0 + best_cost = float("inf") + for insert_pos in range(expected_slots): + cost = 0.0 + obs_idx = 0 + for slot_idx in range(expected_slots): + if slot_idx == insert_pos: + continue + obs_value = observed[obs_idx] + base_value = float(baseline_row.iloc[slot_idx]) + if pd.notna(obs_value) and pd.notna(base_value): + cost += abs(obs_value - base_value) + obs_idx += 1 + if cost < best_cost: + best_cost = cost + best_pos = insert_pos + return best_pos + + +def _clean_repeated_timestamp_pressure( + data: pd.DataFrame, value_col: str, keep_cols: list[str] +) -> pd.DataFrame: + """针对同一时间点重复采样的压力数据进行修复。""" + data = _normalize_time_frame(data) + grouped_sizes = data.groupby("time").size() + if grouped_sizes.empty: + return data + + expected_slots = int(grouped_sizes.mode().iloc[0]) if not grouped_sizes.mode().empty else int(grouped_sizes.max()) + expected_slots = max(expected_slots, int(grouped_sizes.max())) + slot_frame = _build_time_slot_frame(data, value_col, expected_slots) + baseline_frame = _slot_baseline(slot_frame) + + residuals = slot_frame - baseline_frame + slot_scales = { + col: max(_robust_scale(residuals[col]), 1e-6) for col in residuals.columns + } + + cleaned_rows: list[dict[str, object]] = [] + grouped = data.groupby("time", sort=True) + for time_value, group in grouped: + observed_values = pd.to_numeric(group[value_col], errors="coerce").tolist() + baseline_row = baseline_frame.loc[time_value] + insert_pos = _choose_insertion_position(observed_values, baseline_row, expected_slots) + + cleaned_values: list[float] = [] + obs_idx = 0 + for slot_idx in range(expected_slots): + if slot_idx == insert_pos and len(observed_values) < expected_slots: + cleaned_values.append(float(baseline_row.iloc[slot_idx])) + continue + + if obs_idx >= len(observed_values): + cleaned_values.append(float(baseline_row.iloc[slot_idx])) + continue + + observed = observed_values[obs_idx] + baseline = float(baseline_row.iloc[slot_idx]) + cleaned_values.append( + _shrink_toward_baseline(observed, baseline, slot_scales.get(slot_idx, 1.0)) + ) + obs_idx += 1 + + # 其余字段原样保留;常量列(如 id)直接复制第一条记录即可 + template_row = group.iloc[0].to_dict() + for slot_idx, cleaned_value in enumerate(cleaned_values): + row = dict(template_row) + row["time"] = time_value + row[value_col] = cleaned_value + cleaned_rows.append(row) + + cleaned_df = pd.DataFrame(cleaned_rows) + cleaned_df = cleaned_df.sort_values(["time"]).reset_index(drop=True) + ordered_cols = ["time"] + keep_cols + [value_col] + ordered_cols = [col for col in ordered_cols if col in cleaned_df.columns] + remaining_cols = [col for col in cleaned_df.columns if col not in ordered_cols] + cleaned_df = cleaned_df[ordered_cols + remaining_cols] + return _format_time_column(cleaned_df) + + +def _clean_snapshot_pressure( + data: pd.DataFrame, value_cols: list[str], keep_cols: list[str], fill_gaps: bool +) -> pd.DataFrame: + """针对单条时间序列或多列快照数据进行稳健修复。""" + data = _normalize_time_frame(data) + if fill_gaps and "time" in data.columns: + freq = _infer_time_frequency(data["time"]) + data = _expand_snapshot_time_grid(data, freq) + data["time"] = pd.to_datetime(data["time"], errors="coerce") + data = data.sort_values(["time"]).reset_index(drop=True) + + cleaned_df = data.copy() + time_index = ( + _safe_datetime_index(cleaned_df["time"]) + if "time" in cleaned_df.columns + else None + ) + if time_index is None: + time_index = pd.RangeIndex(start=0, stop=len(cleaned_df)) + for col in value_cols: + series = pd.Series( + pd.to_numeric(cleaned_df[col], errors="coerce").to_numpy(), + index=time_index, + dtype=float, + ) + cleaned_df[col] = _clean_pressure_series(series).to_numpy() + + ordered_cols = ["time"] + keep_cols + value_cols + ordered_cols = [col for col in ordered_cols if col in cleaned_df.columns] + remaining_cols = [col for col in cleaned_df.columns if col not in ordered_cols] + cleaned_df = cleaned_df[ordered_cols + remaining_cols] + return _format_time_column(cleaned_df) def clean_pressure_data_km( input_csv_path: str, show_plot: bool = False, fill_gaps: bool = True ) -> str: """ - 读取输入 CSV,基于 KMeans 检测异常并用滚动平均修复。输出为 _cleaned.xlsx(同目录)。 + 读取输入 CSV,基于时间结构进行稳健修复。输出为 _cleaned.xlsx(同目录)。 原始数据在 sheet 'raw_pressure_data',处理后数据在 sheet 'cleaned_pressusre_data'。 返回输出文件的绝对路径。 @@ -24,80 +441,38 @@ def clean_pressure_data_km( # 读取 CSV input_csv_path = os.path.abspath(input_csv_path) data = pd.read_csv(input_csv_path, header=0, index_col=None, encoding="utf-8") + data = _normalize_time_frame(data) + value_cols, keep_cols = _select_pressure_columns(data) + has_repeated_time = "time" in data.columns and data["time"].duplicated().any() + identifier_col = _detect_long_form_identifier(data, value_cols, keep_cols) - # 补齐时间缺口(如果数据包含 time 列) - if fill_gaps and "time" in data.columns: - data = fill_time_gaps( - data, time_col="time", freq="1min", short_gap_threshold=10 + if identifier_col is not None: + data_repaired = _clean_long_form_pressure( + data, + value_cols[0], + identifier_col, + keep_cols, + fill_gaps, ) + elif has_repeated_time and len(value_cols) == 1: + data_repaired = _clean_repeated_timestamp_pressure(data, value_cols[0], keep_cols) + else: + data_repaired = _clean_snapshot_pressure(data, value_cols, keep_cols, fill_gaps) - # 分离时间列和数值列 - time_col_data = None - if "time" in data.columns: - time_col_data = data["time"] - data = data.drop(columns=["time"]) - # 标准化 - data_norm = (data - data.mean()) / data.std() - - # 聚类与异常检测 - k = 3 - kmeans = KMeans(n_clusters=k, init="k-means++", n_init=50, random_state=42) - clusters = kmeans.fit_predict(data_norm) - centers = kmeans.cluster_centers_ - - distances = np.linalg.norm(data_norm.values - centers[clusters], axis=1) - threshold = distances.mean() + 3 * distances.std() - - anomaly_pos = np.where(distances > threshold)[0] - anomaly_indices = data.index[anomaly_pos] - - anomaly_details = {} - for pos in anomaly_pos: - row_norm = data_norm.iloc[pos] - cluster_idx = clusters[pos] - center = centers[cluster_idx] - diff = abs(row_norm - center) - main_sensor = diff.idxmax() - anomaly_details[data.index[pos]] = main_sensor - - # 修复:滚动平均(窗口可调) - data_rolled = data.rolling(window=13, center=True, min_periods=1).mean() - data_repaired = data.copy() - for pos in anomaly_pos: - label = data.index[pos] - sensor = anomaly_details[label] - data_repaired.loc[label, sensor] = data_rolled.loc[label, sensor] - - # 可选可视化(使用位置作为 x 轴) + # 可选可视化(只展示首个数值列) plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False - - if show_plot and len(data.columns) > 0: - n = len(data) - time = np.arange(n) - plt.figure(figsize=(12, 8)) - for col in data.columns: - plt.plot(time, data[col].values, marker="o", markersize=3, label=col) - for pos in anomaly_pos: - sensor = anomaly_details[data.index[pos]] - plt.plot(pos, data.iloc[pos][sensor], "ro", markersize=8) - plt.xlabel("时间点(序号)") + if show_plot and value_cols: + plot_col = value_cols[0] + if "time" in data_repaired.columns: + x = pd.to_datetime(data_repaired["time"], errors="coerce") + else: + x = np.arange(len(data_repaired)) + plt.figure(figsize=(12, 6)) + plt.plot(x, pd.to_numeric(data_repaired[plot_col], errors="coerce"), label="cleaned") + plt.xlabel("时间" if "time" in data_repaired.columns else "序号") plt.ylabel("压力监测值") - plt.title("各传感器折线图(红色标记主要异常点)") - plt.legend() - plt.show() - - plt.figure(figsize=(12, 8)) - for col in data_repaired.columns: - plt.plot( - time, data_repaired[col].values, marker="o", markersize=3, label=col - ) - for pos in anomaly_pos: - sensor = anomaly_details[data.index[pos]] - plt.plot(pos, data_repaired.iloc[pos][sensor], "go", markersize=8) - plt.xlabel("时间点(序号)") - plt.ylabel("修复后压力监测值") - plt.title("修复后各传感器折线图(绿色标记修复值)") + plt.title(f"{plot_col} 清洗结果") plt.legend() plt.show() @@ -110,9 +485,6 @@ def clean_pressure_data_km( # 如果原始数据包含时间列,将其添加回结果 data_for_save = data.copy() data_repaired_for_save = data_repaired.copy() - if time_col_data is not None: - data_for_save.insert(0, "time", time_col_data) - data_repaired_for_save.insert(0, "time", time_col_data) if os.path.exists(output_path): os.remove(output_path) # 覆盖同名文件 @@ -126,10 +498,10 @@ def clean_pressure_data_km( return os.path.abspath(output_path) -def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> dict: +def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> pd.DataFrame: """ - 接收一个 DataFrame 数据结构,使用KMeans聚类检测异常并用滚动平均修复。 - 返回清洗后的字典数据结构。 + 接收一个 DataFrame 数据结构,使用时间感知的稳健修复方法清洗压力数据。 + 返回清洗后的 DataFrame。 Args: data: 输入 DataFrame(可包含 time 列) @@ -137,113 +509,37 @@ def clean_pressure_data_df_km(data: pd.DataFrame, show_plot: bool = False) -> di """ # 使用传入的 DataFrame data = data.copy() + data = _normalize_time_frame(data) + value_cols, keep_cols = _select_pressure_columns(data) + has_repeated_time = "time" in data.columns and data["time"].duplicated().any() + identifier_col = _detect_long_form_identifier(data, value_cols, keep_cols) - # 补齐时间缺口(如果启用且数据包含 time 列) - data_filled = fill_time_gaps( - data, time_col="time", freq="1min", short_gap_threshold=10 - ) + if identifier_col is not None: + data_repaired = _clean_long_form_pressure( + data, + value_cols[0], + identifier_col, + keep_cols, + fill_gaps=True, + ) + elif has_repeated_time and len(value_cols) == 1: + data_repaired = _clean_repeated_timestamp_pressure(data, value_cols[0], keep_cols) + else: + data_repaired = _clean_snapshot_pressure(data, value_cols, keep_cols, fill_gaps=True) - # 保存 time 列用于最后合并 - time_col_series = None - if "time" in data_filled.columns: - time_col_series = data_filled["time"] - - # 移除 time 列用于后续清洗 - data_filled = data_filled.drop(columns=["time"]) - - # 标准化(使用填充后的数据) - data_norm = (data_filled - data_filled.mean()) / data_filled.std() - - # 添加:处理标准化后的 NaN(例如,标准差为0的列),防止异常数据,时间段内所有数据都相同导致计算结果为 NaN - imputer = SimpleImputer( - strategy="constant", fill_value=0, keep_empty_features=True - ) # 用 0 填充 NaN,包括全 NaN,并保留空特征 - data_norm = pd.DataFrame( - imputer.fit_transform(data_norm), - columns=data_norm.columns, - index=data_norm.index, - ) - - # 聚类与异常检测 - k = 3 - kmeans = KMeans(n_clusters=k, init="k-means++", n_init=50, random_state=42) - clusters = kmeans.fit_predict(data_norm) - centers = kmeans.cluster_centers_ - - distances = np.linalg.norm(data_norm.values - centers[clusters], axis=1) - threshold = distances.mean() + 3 * distances.std() - - anomaly_pos = np.where(distances > threshold)[0] - anomaly_indices = data_filled.index[anomaly_pos] - - anomaly_details = {} - for pos in anomaly_pos: - row_norm = data_norm.iloc[pos] - cluster_idx = clusters[pos] - center = centers[cluster_idx] - diff = abs(row_norm - center) - main_sensor = diff.idxmax() - anomaly_details[data_filled.index[pos]] = main_sensor - - # 修复:滚动平均(窗口可调) - data_rolled = data_filled.rolling(window=13, center=True, min_periods=1).mean() - data_repaired = data_filled.copy() - for pos in anomaly_pos: - label = data_filled.index[pos] - sensor = anomaly_details[label] - data_repaired.loc[label, sensor] = data_rolled.loc[label, sensor] - - # 可选可视化(使用位置作为 x 轴) - plt.rcParams["font.sans-serif"] = ["SimHei"] - plt.rcParams["axes.unicode_minus"] = False - - if show_plot and len(data.columns) > 0: - n = len(data) - time = np.arange(n) - n_filled = len(data_filled) - time_filled = np.arange(n_filled) - plt.figure(figsize=(12, 8)) - for col in data.columns: - plt.plot( - time, data[col].values, marker="o", markersize=3, label=col, alpha=0.5 - ) - for col in data_filled.columns: - plt.plot( - time_filled, - data_filled[col].values, - marker="x", - markersize=3, - label=f"{col}_filled", - linestyle="--", - ) - for pos in anomaly_pos: - sensor = anomaly_details[data_filled.index[pos]] - plt.plot(pos, data_filled.iloc[pos][sensor], "ro", markersize=8) - plt.xlabel("时间点(序号)") + if show_plot and value_cols: + plt.rcParams["font.sans-serif"] = ["SimHei"] + plt.rcParams["axes.unicode_minus"] = False + plot_col = value_cols[0] + x = pd.to_datetime(data_repaired["time"], errors="coerce") if "time" in data_repaired.columns else np.arange(len(data_repaired)) + plt.figure(figsize=(12, 6)) + plt.plot(x, pd.to_numeric(data_repaired[plot_col], errors="coerce"), label="cleaned") + plt.xlabel("时间" if "time" in data_repaired.columns else "序号") plt.ylabel("压力监测值") - plt.title("各传感器折线图(红色标记主要异常点,虚线为0值填充后)") + plt.title(f"{plot_col} 清洗结果") plt.legend() plt.show() - plt.figure(figsize=(12, 8)) - for col in data_repaired.columns: - plt.plot( - time_filled, data_repaired[col].values, marker="o", markersize=3, label=col - ) - for pos in anomaly_pos: - sensor = anomaly_details[data_filled.index[pos]] - plt.plot(pos, data_repaired.iloc[pos][sensor], "go", markersize=8) - plt.xlabel("时间点(序号)") - plt.ylabel("修复后压力监测值") - plt.title("修复后各传感器折线图(绿色标记修复值)") - plt.legend() - plt.show() - - # 将 time 列添加回结果 - if time_col_series is not None: - data_repaired.insert(0, "time", time_col_series) - - # 返回清洗后的字典 return data_repaired diff --git a/app/algorithms/sensor/kmeans.py b/app/algorithms/sensor/kmeans.py index e30c70e..84c37d3 100644 --- a/app/algorithms/sensor/kmeans.py +++ b/app/algorithms/sensor/kmeans.py @@ -6,104 +6,66 @@ import sklearn.cluster import os - class QD_KMeans(object): def __init__(self, wn, num_monitors): # self.inp = inp - self.cluster_num = num_monitors # 聚类中心个数,也即测压点个数 - self.wn=wn + self.cluster_num = num_monitors # 聚类中心个数,也即测压点个数 + self.wn = wn self.monitor_nodes = [] self.coords = [] self.junction_nodes = {} # Added missing initialization - def get_junctions_coordinates(self): - - for junction_name in self.wn.junction_name_list: + + for junction_name in self.wn.junction_name_list: junction = self.wn.get_node(junction_name) self.junction_nodes[junction_name] = junction.coordinates - self.coords.append(junction.coordinates ) + self.coords.append(junction.coordinates) - # print(f"Total junctions: {self.junction_coordinates}") + # print(f"Total junctions: {self.junction_coordinates}") def select_monitoring_points(self): if not self.coords: # Add check if coordinates are collected self.get_junctions_coordinates() coords = np.array(self.coords) - coords_normalized = (coords - coords.min(axis=0)) / (coords.max(axis=0) - coords.min(axis=0)) - kmeans = sklearn.cluster.KMeans(n_clusters= self.cluster_num, random_state=42) - kmeans.fit(coords_normalized) + coords_normalized = (coords - coords.min(axis=0)) / ( + coords.max(axis=0) - coords.min(axis=0) + ) + kmeans = sklearn.cluster.KMeans(n_clusters=self.cluster_num, random_state=42) + kmeans.fit(coords_normalized) for center in kmeans.cluster_centers_: distances = np.sum((coords_normalized - center) ** 2, axis=1) nearest_node = self.wn.junction_name_list[np.argmin(distances)] - self.monitor_nodes.append(nearest_node) + self.monitor_nodes.append(nearest_node) return self.monitor_nodes - def visualize_network(self): """Visualize network with monitoring points""" - ax=wntr.graphics.plot_network(self.wn, - node_attribute=self.monitor_nodes, - node_size=30, - title='Optimal sensor') - plt.show() + ax = wntr.graphics.plot_network( + self.wn, + node_attribute=self.monitor_nodes, + node_size=30, + title="Optimal sensor", + ) + plt.show() - - def kmeans_sensor_placement(name: str, sensor_num: int, min_diameter: int) -> list: - inp_name = f'./db_inp/{name}.db.inp' - wn= wntr.network.WaterNetworkModel(inp_name) - wn_cluster=QD_KMeans(wn, sensor_num) + inp_name = f"./db_inp/{name}.db.inp" + wn = wntr.network.WaterNetworkModel(inp_name) + wn_cluster = QD_KMeans(wn, sensor_num) # Select monitoring pointse - sensor_ids= wn_cluster.select_monitoring_points() + sensor_ids = wn_cluster.select_monitoring_points() # wn_cluster.visualize_network() return sensor_ids - if __name__ == "__main__": - #sensorindex = get_ID(name='suzhouhe_2024_cloud_0817', sensor_num=30, min_diameter=500) - sensorindex = kmeans_sensor_placement(name='szh', sensor_num=50, min_diameter=300) + # sensorindex = get_ID(name='suzhouhe_2024_cloud_0817', sensor_num=30, min_diameter=500) + sensorindex = kmeans_sensor_placement(name="szh", sensor_num=50, min_diameter=300) print(sensorindex) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/algorithms/sensor/sensitivity.py b/app/algorithms/sensor/sensitivity.py index 1f19925..ef09fad 100644 --- a/app/algorithms/sensor/sensitivity.py +++ b/app/algorithms/sensor/sensitivity.py @@ -20,6 +20,7 @@ import geopandas as gpd from sklearn.metrics import pairwise_distances import app.services.project_info as project_info + # 2025/03/12 # Step1: 获取节点坐标 def getCoor(wn: wntr.network.WaterNetworkModel) -> pandas.DataFrame: @@ -31,7 +32,7 @@ def getCoor(wn: wntr.network.WaterNetworkModel) -> pandas.DataFrame: # site: pandas.Series # index:节点名称(wn.node_name_list) # values:每个节点的坐标,格式为 tuple(如 (x, y) 或 (x, y, z)) - site = wn.query_node_attribute('coordinates') + site = wn.query_node_attribute("coordinates") # Coor: pandas.Series # index:与site相同(节点名称)。 # values:坐标转换为numpy.ndarray(如array([10.5, 20.3])) @@ -43,9 +44,9 @@ def getCoor(wn: wntr.network.WaterNetworkModel) -> pandas.DataFrame: x.append(Coor.values[i][0]) # 将 x 坐标存入 x 列表。 y.append(Coor.values[i][1]) # 将 y 坐标存入 y 列表 # xy: dict[str, list], x、y 坐标的字典 - xy = {'x': x, 'y': y} + xy = {"x": x, "y": y} # Coor_node: pandas.DataFrame, 存储节点 x, y 坐标的 DataFrame - Coor_node = pd.DataFrame(xy, index=wn.node_name_list, columns=['x', 'y']) + Coor_node = pd.DataFrame(xy, index=wn.node_name_list, columns=["x", "y"]) return Coor_node @@ -87,23 +88,23 @@ def skater_partition(G, n_clusters): 字典形式的聚类结果,键为区域编号,值为该区域内的节点列表。 """ # 1. 获取所有节点坐标,假设每个节点都有 'pos' 属性 - pos = nx.get_node_attributes(G, 'pos') + pos = nx.get_node_attributes(G, "pos") nodes = list(G.nodes()) # 构造坐标数组:每行为 [x, y] coords = np.array([pos[node] for node in nodes]) # 2. 构造 GeoDataFrame:创建 DataFrame 并生成 geometry 列 - df = pd.DataFrame(coords, columns=['x', 'y'], index=nodes) + df = pd.DataFrame(coords, columns=["x", "y"], index=nodes) # 利用 shapely 的 Point 构造空间位置 - df['geometry'] = df.apply(lambda row: Point(row['x'], row['y']), axis=1) - gdf = gpd.GeoDataFrame(df, geometry='geometry') + df["geometry"] = df.apply(lambda row: Point(row["x"], row["y"]), axis=1) + gdf = gpd.GeoDataFrame(df, geometry="geometry") # 3. 构造空间权重矩阵,使用 4 近邻方法(k=4,可根据实际情况调整) w = ps.weights.KNN.from_array(coords, k=4) - w.transform = 'R' + w.transform = "R" # 4. 调用 SKATER:新版本 API 要求传入 gdf, w 以及 attrs_name(这里使用 'x' 和 'y' 作为属性) - skater = Skater(gdf, w, attrs_name=['x', 'y'], n_clusters=n_clusters) + skater = Skater(gdf, w, attrs_name=["x", "y"], n_clusters=n_clusters) skater.solve() # 5. 获取聚类标签,构造成字典格式 @@ -133,24 +134,24 @@ def spectral_partition(G, n_clusters): 键为聚类标签,值为该聚类对应的节点列表。 """ # 1. 获取节点空间坐标,注意保证每个节点都有 'pos' 属性 - pos_dict = nx.get_node_attributes(G, 'pos') + pos_dict = nx.get_node_attributes(G, "pos") nodes = list(G.nodes()) coords = np.array([pos_dict[node] for node in nodes]) # 2. 计算节点之间的欧氏距离矩阵 - D = pairwise_distances(coords, metric='euclidean') + D = pairwise_distances(coords, metric="euclidean") # 3. 计算 sigma 值:这里取所有距离的均值,当然也可以根据实际情况调整 sigma = np.mean(D) # 4. 构造相似度矩阵:使用高斯核函数 # A(i, j) = exp( -d(i,j)^2 / (2*sigma^2) ) - A = np.exp(- (D ** 2) / (2 * sigma ** 2)) + A = np.exp(-(D**2) / (2 * sigma**2)) # 5. 使用谱聚类进行图分区 - clustering = SpectralClustering(n_clusters=n_clusters, - affinity='precomputed', - random_state=0) + clustering = SpectralClustering( + n_clusters=n_clusters, affinity="precomputed", random_state=0 + ) labels = clustering.fit_predict(A) # 6. 构造字典形式的分区结果 @@ -160,6 +161,7 @@ def spectral_partition(G, n_clusters): return groups + # 2025/03/12 # Step3: wn_func类,水力计算 # wn_func 主要用于计算: @@ -181,7 +183,7 @@ class wn_func(object): self.results = wntr.sim.EpanetSimulator(wn).run_sim() # 存储运行结果 self.wn = wn # self.q:pandas.DataFrame,管道流量,索引为时间步长,列为管道名称 - self.q = self.results.link['flowrate'] + self.q = self.results.link["flowrate"] # ReservoirIndex / Tankindex: list[str],水库 / 水箱节点名称列表 ReservoirIndex = wn.reservoir_name_list Tankindex = wn.tank_name_list @@ -191,7 +193,7 @@ class wn_func(object): # self.nodes: list[str],所有节点的名称 self.nodes = wn.node_name_list # self.coordinates:pandas.Series,节点坐标,索引为节点名,值为 (x, y) 坐标的 tuple - self.coordinates = wn.query_node_attribute('coordinates') + self.coordinates = wn.query_node_attribute("coordinates") # allpumps / allvalves: list[str],所有泵/阀门名称列表 allpumps = wn.pump_name_list allvalves = wn.valve_name_list @@ -222,17 +224,27 @@ class wn_func(object): # 泵的起终点、tank、reservoir # self.delnodes: list[str],需要删除的节点(包括水库、泵、阀门连接的节点) self.delnodes = list( - set(ReservoirIndex).union(Tankindex, pumpstnode, pumpednode, valvestnode, valveednode, Reservoirednode)) + set(ReservoirIndex).union( + Tankindex, + pumpstnode, + pumpednode, + valvestnode, + valveednode, + Reservoirednode, + ) + ) # 泵、起终点为tank、reservoir的管道 # self.delpipes: list[str],需要删除的管道(包括水库、泵、阀门连接的管道) - self.delpipes = list(set(wn.pump_name_list).union(wn.valve_name_list).union(Reservoirpipe)) + self.delpipes = list( + set(wn.pump_name_list).union(wn.valve_name_list).union(Reservoirpipe) + ) self.pipes = [pipe for pipe in wn.pipe_name_list if pipe not in self.delpipes] # self.L: list[float],所有管道的长度(以米为单位) - self.L = wn.query_link_attribute('length')[self.pipes].tolist() + self.L = wn.query_link_attribute("length")[self.pipes].tolist() self.n = len(self.nodes) self.m = len(self.pipes) # self.unit_headloss: list[float],单位水头损失(headloss 数据的第一行,单位:米/km) - self.unit_headloss = self.results.link['headloss'].iloc[0, :].tolist() + self.unit_headloss = self.results.link["headloss"].iloc[0, :].tolist() ## self.delnodes1 = list(set(ReservoirIndex).union(Tankindex)) @@ -245,7 +257,9 @@ class wn_func(object): end_node = wn.links[pipe].end_node.name self.less_than_min_diameter_junction_list.extend([start_node, end_node]) # 去重 - self.less_than_min_diameter_junction_list = list(set(self.less_than_min_diameter_junction_list)) + self.less_than_min_diameter_junction_list = list( + set(self.less_than_min_diameter_junction_list) + ) # Step3.2: 计算水力距离 def CtoS(self): @@ -266,7 +280,7 @@ class wn_func(object): q = self.q L = self.L # H1:pandas.DataFrame,水头数据,索引为时间步长,列为节点名 - H1 = self.results.node['head'].T + H1 = self.results.node["head"].T # hh:list[float],计算管道两端水头之差 hh = [] # 水头损失 @@ -280,8 +294,18 @@ class wn_func(object): # headloss:pandas.DataFrame,管道水头损失矩阵 headloss = pd.DataFrame(hh, index=pipes).T # s1:管道阻力系数,s2:将管道阻力系数与管道的起始节点和终止节点对应 - hf = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float) - weightL = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float) + hf = pd.DataFrame( + np.array([0] * (n**2)).reshape(n, n), + index=nodes, + columns=nodes, + dtype=float, + ) + weightL = pd.DataFrame( + np.array([0] * (n**2)).reshape(n, n), + index=nodes, + columns=nodes, + dtype=float, + ) # s2为对应管道起始节点与终止节点的粗糙度系数矩阵,index代表起始节点,columns代表终止节点 G = nx.DiGraph() for i in range(0, m): @@ -298,11 +322,16 @@ class wn_func(object): weightL.loc[b, a] = headloss.loc[0, pipe] * L[i] G.add_weighted_edges_from([(b, a, weightL.loc[b, a])]) - hydraulicL = pd.DataFrame(np.array([0] * (n ** 2)).reshape(n, n), index=nodes, columns=nodes, dtype=float) + hydraulicL = pd.DataFrame( + np.array([0] * (n**2)).reshape(n, n), + index=nodes, + columns=nodes, + dtype=float, + ) for a in nodes: if a in G.nodes: - d = nx.shortest_path_length(G, source=a, weight='weight') + d = nx.shortest_path_length(G, source=a, weight="weight") for b in list(d.keys()): hydraulicL.loc[a, b] = d[b] @@ -331,11 +360,17 @@ class wn_func(object): for t in self.wn.tanks(): self.nonjunc_index.append(t[0]) # Conn:numpy.matrix,节点-管道连接矩阵,起点 -1,终点 1 - Conn = np.mat(np.zeros([n, m - p - v])) # 节点和管道的关系矩阵,行为节点,列为管道,起点为-1,终点为1 + Conn = np.mat( + np.zeros([n, m - p - v]) + ) # 节点和管道的关系矩阵,行为节点,列为管道,起点为-1,终点为1 # NConn:numpy.matrix,节点-节点连接矩阵,有管道相连的地方设为 1 NConn = np.mat(np.zeros([n, n])) # 节点之间的关系,之间有管道为1,反之为0 # pipes:list[str],去除泵和阀门的管道列表 - pipes = [pipe for pipe in self.wn.pipes() if pipe not in self.wn.pumps() and pipe not in self.wn.valves()] + pipes = [ + pipe + for pipe in self.wn.pipes() + if pipe not in self.wn.pumps() and pipe not in self.wn.valves() + ] for pipe_name, pipe in pipes: start = self.wn.node_name_list.index(pipe.start_node_name) end = self.wn.node_name_list.index(pipe.end_node_name) @@ -345,12 +380,21 @@ class wn_func(object): NConn[start, end] = 1 NConn[end, start] = 1 self.A = Conn - link_name_list = [link for link in self.wn.link_name_list if - link not in self.wn.pump_name_list and link not in self.wn.valve_name_list] - self.A2 = pd.DataFrame(self.A, index=self.wn.node_name_list, columns=link_name_list) + link_name_list = [ + link + for link in self.wn.link_name_list + if link not in self.wn.pump_name_list + and link not in self.wn.valve_name_list + ] + self.A2 = pd.DataFrame( + self.A, index=self.wn.node_name_list, columns=link_name_list + ) self.A2 = self.A2.drop(self.delnodes) for pipe in self.delpipes: - if pipe not in self.wn.pump_name_list and pipe not in self.wn.valve_name_list: + if ( + pipe not in self.wn.pump_name_list + and pipe not in self.wn.valve_name_list + ): self.A2 = self.A2.drop(columns=pipe) self.junc_list = self.A2.index self.A2 = np.mat(self.A2) # 节点管道关系 @@ -372,10 +416,10 @@ class wn_func(object): except EpanetException: pass finally: - h = result.link['headloss'][self.pipes].values[0] - q = result.link['flowrate'][self.pipes].values[0] - l = self.wn.query_link_attribute('length')[self.pipes] - C = self.wn.query_link_attribute('roughness')[self.pipes] + h = result.link["headloss"][self.pipes].values[0] + q = result.link["flowrate"][self.pipes].values[0] + l = self.wn.query_link_attribute("length")[self.pipes] + C = self.wn.query_link_attribute("roughness")[self.pipes] # headloss:numpy.ndarray,水头损失数组 headloss = np.array(h) # 调整流量方向 @@ -393,7 +437,7 @@ class wn_func(object): try: det = np.linalg.det(X) except RuntimeError as e: - sign, logdet = slogdet(X) # 防止溢出 + sign, logdet = slogdet(X) # 防止溢出 det = sign * np.exp(logdet) if det != 0: J_H_Cw = X.I * A * S @@ -430,7 +474,10 @@ class Sensorplacement(wn_func): """ Sensorplacement 类继承了 wn_func 类,并且用于计算和优化传感器布置的位置。 """ - def __init__(self, wn: wntr.network.WaterNetworkModel, sensornum: int, min_diameter: int): + + def __init__( + self, wn: wntr.network.WaterNetworkModel, sensornum: int, min_diameter: int + ): """ :param wn: 由wntr生成的模型 @@ -442,7 +489,9 @@ class Sensorplacement(wn_func): # 1.某个节点到所有节点的加权距离之和 # 2.某个节点到该组内所有节点的加权距离之和 - def sensor(self, SS: pandas.DataFrame, G: networkx.Graph, group: dict[int, list[str]]): + def sensor( + self, SS: pandas.DataFrame, G: networkx.Graph, group: dict[int, list[str]] + ): """ sensor 方法是用来根据灵敏度矩阵 SS 和加权图 G 来确定传感器布置位置的 :param SS: 灵敏度矩阵,每个节点的行和列代表不同节点,矩阵元素表示节点间的灵敏度。SS.iloc[i, :] 表示第 i 行对应节点 i 到所有其他节点的灵敏度 @@ -527,7 +576,7 @@ def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]: :return: 测压点节点ID """ # inp_file_real:str,输入文件名,表示原始水力模型文件的路径,该文件格式为 EPANET 输入文件(.inp),包含管网的结构信息、节点、管道、泵等数据 - inp_file_real = f'./db_inp/{name}.db.inp' + inp_file_real = f"./db_inp/{name}.db.inp" # sensornum:int,需要布置的传感器数量 # sensornum = sensor_num # wn_real:wntr.network.WaterNetworkModel,加载 EPANET 水力模型 @@ -538,7 +587,7 @@ def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]: results_real = sim_real.run_sim() # real_C:list[float],包含所有管道粗糙度的列表 - real_C = wn_real.query_link_attribute('roughness').tolist() + real_C = wn_real.query_link_attribute("roughness").tolist() # wn_fun1:wn_func(继承自 object),创建 wn_func 类的实例,传入 wn_real 水力模型对象。wn_func 用于计算管网相关的水力属性,比如水力距离、灵敏度等 wn_fun1 = wn_func(wn_real, min_diameter=min_diameter) # nodes:list[str],管网的节点名称列表 @@ -598,7 +647,6 @@ def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]: sensorindex, sensorindex_2 = wn_fun.sensor(SS, G, group) # 初始的sensorindex # print(str(sensor_num), "个测压点,测压点位置:", sensorindex) - # 重新打开数据库 # if is_project_open(name=name): # close_project(name=name) @@ -637,7 +685,7 @@ def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]: return sensorindex -if __name__ == '__main__': +if __name__ == "__main__": sensorindex = get_ID(name=project_info.name, sensor_num=20, min_diameter=300) print(sensorindex) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index ac3a679..25d4e83 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -6,7 +6,6 @@ import shutil import threading from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Body from fastapi.responses import PlainTextResponse -import app.infra.db.influxdb.api as influxdb_api import app.services.simulation as simulation import app.services.globals as globals from app.services.tjnetwork import ( @@ -28,8 +27,7 @@ from app.algorithms.sensor import ( pressure_sensor_placement_sensitivity, pressure_sensor_placement_kmeans, ) -import app.algorithms.cleaning.flow as flow_data_clean -import app.algorithms.cleaning.pressure as pressure_data_clean + from app.services.network_import import network_update from app.services.simulation_ops import ( project_management, diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py index bc8717f..d5a6348 100644 --- a/app/infra/db/timescaledb/repositories/scada.py +++ b/app/infra/db/timescaledb/repositories/scada.py @@ -89,12 +89,17 @@ class ScadaRepository: if field not in valid_fields: raise ValueError(f"Invalid field: {field}") - query = sql.SQL( + update_query = sql.SQL( "UPDATE scada.scada_data SET {} = %s WHERE time = %s AND device_id = %s" ).format(sql.Identifier(field)) + insert_query = sql.SQL( + "INSERT INTO scada.scada_data (time, device_id, {}) VALUES (%s, %s, %s)" + ).format(sql.Identifier(field)) async with conn.cursor() as cur: - await cur.execute(query, (value, time, device_id)) + await cur.execute(update_query, (value, time, device_id)) + if cur.rowcount == 0: + await cur.execute(insert_query, (time, device_id, value)) @staticmethod async def delete_scada_by_id_time_range( diff --git a/tests/unit/test_pressure_cleaning.py b/tests/unit/test_pressure_cleaning.py new file mode 100644 index 0000000..9ccd9d8 --- /dev/null +++ b/tests/unit/test_pressure_cleaning.py @@ -0,0 +1,108 @@ +import importlib.util +import sys +import types +from pathlib import Path + +import numpy as np +import pandas as pd + + +def _load_pressure_cleaning_module(): + project_root = Path(__file__).resolve().parents[2] + utils_path = project_root / "app" / "algorithms" / "_utils.py" + pressure_path = project_root / "app" / "algorithms" / "cleaning" / "pressure.py" + + app_module = sys.modules.setdefault("app", types.ModuleType("app")) + algorithms_module = sys.modules.setdefault( + "app.algorithms", + types.ModuleType("app.algorithms"), + ) + setattr(app_module, "algorithms", algorithms_module) + + utils_spec = importlib.util.spec_from_file_location("app.algorithms._utils", utils_path) + assert utils_spec and utils_spec.loader + utils_module = importlib.util.module_from_spec(utils_spec) + sys.modules["app.algorithms._utils"] = utils_module + utils_spec.loader.exec_module(utils_module) + + pressure_spec = importlib.util.spec_from_file_location( + "tests_pressure_under_test", + pressure_path, + ) + assert pressure_spec and pressure_spec.loader + pressure_module = importlib.util.module_from_spec(pressure_spec) + pressure_spec.loader.exec_module(pressure_module) + return pressure_module + + +def test_clean_pressure_data_df_km_repairs_long_form_pressure_series(): + module = _load_pressure_cleaning_module() + repo_root = Path(__file__).resolve().parents[3] + + raw_df = pd.read_csv(repo_root / "data" / "node_simulation.csv") + noisy_df = pd.read_csv(repo_root / "data" / "node_simulation_noisy.csv") + cleaned_df = module.clean_pressure_data_df_km(noisy_df) + + for df in (raw_df, noisy_df, cleaned_df): + df["time"] = pd.to_datetime(df["time"]) + + assert len(cleaned_df) == len(raw_df) + assert set(cleaned_df.columns) == {"time", "id", "pressure"} + assert cleaned_df["pressure"].isna().sum() == 0 + + noisy_joined = raw_df.merge(noisy_df, on=["time", "id"], how="inner", suffixes=("_raw", "_noisy")) + cleaned_joined = raw_df.merge( + cleaned_df, + on=["time", "id"], + how="inner", + suffixes=("_raw", "_clean"), + ) + + noisy_rmse = float( + np.sqrt(np.mean((noisy_joined["pressure_raw"] - noisy_joined["pressure_noisy"]) ** 2)) + ) + cleaned_rmse = float( + np.sqrt(np.mean((cleaned_joined["pressure_raw"] - cleaned_joined["pressure_clean"]) ** 2)) + ) + noisy_mae = float( + np.mean(np.abs(noisy_joined["pressure_raw"] - noisy_joined["pressure_noisy"])) + ) + cleaned_mae = float( + np.mean(np.abs(cleaned_joined["pressure_raw"] - cleaned_joined["pressure_clean"])) + ) + + assert cleaned_rmse < 0.35 + assert cleaned_rmse < noisy_rmse * 0.5 + assert cleaned_mae < noisy_mae + + repaired_gap = cleaned_df[ + (cleaned_df["id"] == 170490) + & (cleaned_df["time"] == pd.Timestamp("2026-01-01T05:00:00+08:00")) + ]["pressure"].iloc[0] + assert abs(repaired_gap - 30.62433433532715) < 1.0 + + spike_row = cleaned_df[ + (cleaned_df["id"] == 42563) + & (cleaned_df["time"] == pd.Timestamp("2026-01-01T03:45:00+08:00")) + ]["pressure"].iloc[0] + assert abs(spike_row - 28.018701553344727) < 2.0 + + +def test_clean_pressure_data_df_km_accepts_single_sensor_wide_frame_with_utc_strings(): + module = _load_pressure_cleaning_module() + repo_root = Path(__file__).resolve().parents[3] + + noisy_df = pd.read_csv(repo_root / "data" / "node_simulation_noisy.csv") + single_sensor = ( + noisy_df[noisy_df["id"] == 170490][["time", "pressure"]] + .rename(columns={"pressure": "170490"}) + .copy() + ) + single_sensor["time"] = ( + pd.to_datetime(single_sensor["time"], utc=True).dt.strftime("%Y-%m-%dT%H:%M:%SZ") + ) + + cleaned_df = module.clean_pressure_data_df_km(single_sensor) + + assert len(cleaned_df) == 192 + assert cleaned_df["170490"].isna().sum() == 0 diff --git a/tests/unit/test_scada_repository.py b/tests/unit/test_scada_repository.py new file mode 100644 index 0000000..98fbcbb --- /dev/null +++ b/tests/unit/test_scada_repository.py @@ -0,0 +1,87 @@ +from datetime import datetime, timezone +import importlib.util +from pathlib import Path + +import pytest + + +def _load_scada_repository(): + module_path = ( + Path(__file__).resolve().parents[2] + / "app" + / "infra" + / "db" + / "timescaledb" + / "repositories" + / "scada.py" + ) + spec = importlib.util.spec_from_file_location("tests_scada_repo_under_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module.ScadaRepository + + +class _FakeCursor: + def __init__(self, initial_rowcount: int): + self.initial_rowcount = initial_rowcount + self.rowcount = 0 + self.calls: list[tuple[str, tuple]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query, params): + self.calls.append((str(query), params)) + if len(self.calls) == 1: + self.rowcount = self.initial_rowcount + else: + self.rowcount = 1 + + +class _FakeConnection: + def __init__(self, initial_rowcount: int): + self.cursor_instance = _FakeCursor(initial_rowcount) + + def cursor(self): + return self.cursor_instance + + +@pytest.mark.asyncio +async def test_update_scada_field_inserts_when_update_hits_no_rows(): + ScadaRepository = _load_scada_repository() + conn = _FakeConnection(initial_rowcount=0) + point_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + + await ScadaRepository.update_scada_field( + conn, + point_time, + "170490", + "cleaned_value", + 26.5, + ) + + assert len(conn.cursor_instance.calls) == 2 + assert "UPDATE scada.scada_data SET" in conn.cursor_instance.calls[0][0] + assert "INSERT INTO scada.scada_data" in conn.cursor_instance.calls[1][0] + + +@pytest.mark.asyncio +async def test_update_scada_field_skips_insert_when_update_succeeds(): + ScadaRepository = _load_scada_repository() + conn = _FakeConnection(initial_rowcount=1) + point_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + + await ScadaRepository.update_scada_field( + conn, + point_time, + "170490", + "cleaned_value", + 26.5, + ) + + assert len(conn.cursor_instance.calls) == 1 + assert "UPDATE scada.scada_data SET" in conn.cursor_instance.calls[0][0] From a1dcbd4230272643da43e3093ad00061ef1cbac6 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Apr 2026 13:06:09 +0800 Subject: [PATCH 13/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20dockerfile=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E9=AB=98=E6=89=93=E5=8C=85=E6=95=88=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c6e311..0dc2071 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM continuumio/miniconda3:latest +FROM condaforge/miniforge3:latest WORKDIR /app # 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题) -RUN conda install -y -c conda-forge python=3.12 pymetis && \ - conda clean -afy +RUN mamba install -y python=3.12 pymetis && \ + mamba clean -afy COPY requirements.txt . RUN pip install uv From 751950e5b5e7960c2eca2105889bb32d5f074348 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 20 May 2026 11:45:01 +0800 Subject: [PATCH 14/93] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/network/regions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/v1/endpoints/network/regions.py b/app/api/v1/endpoints/network/regions.py index 1097ca5..61c84f0 100644 --- a/app/api/v1/endpoints/network/regions.py +++ b/app/api/v1/endpoints/network/regions.py @@ -474,11 +474,11 @@ async def fastapi_generate_service_area( @router.get( "/calculatevirtualdistrict/", summary="计算虚拟分区", - description="根据指定的中心节点计算虚拟分区方案" + description="根据指定的压力监测节点作为中心节点计算虚拟分区方案" ) async def fastapi_calculate_virtual_district( network: str = Query(..., description="管网名称(或数据库名称)"), - centers: list[str] = Query(..., description="中心节点ID列表") + centers: list[str] = Query(..., description="压力监测节点ID列表") ) -> dict[str, list[Any]]: """计算虚拟分区。""" return calculate_virtual_district(network, centers) From 2317f4d527d3ec4720ec2ad18e020aaf764c3e33 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 21 May 2026 15:32:12 +0800 Subject: [PATCH 15/93] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20API=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E6=8E=A5=E5=8F=A3=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/network/regions.py | 76 +------- tests/api/test_audit_endpoints.py | 91 +++++++++ tests/api/test_auth_endpoints.py | 139 ++++++++++++++ tests/api/test_project_endpoints.py | 152 +++++++++++++++ tests/api/test_regions_endpoints.py | 154 ++++++++++++++++ tests/api/test_simulation_endpoints.py | 175 ++++++++++++++++++ tests/api/test_user_management_endpoints.py | 95 ++++++++++ tests/auth/test_security.py | 36 ++++ tests/conftest.py | 195 +++++++++++++++++++- tests/unit/test_audit_repository.py | 79 ++++++++ tests/unit/test_auth_dependencies.py | 97 ++++++++++ tests/unit/test_permissions.py | 56 ++++++ tests/unit/test_scada_repository.py | 37 ++-- tests/unit/test_user_repository.py | 124 +++++++++++++ 失效API排查.md | 76 ++++++++ 15 files changed, 1486 insertions(+), 96 deletions(-) create mode 100644 tests/api/test_audit_endpoints.py create mode 100644 tests/api/test_auth_endpoints.py create mode 100644 tests/api/test_project_endpoints.py create mode 100644 tests/api/test_regions_endpoints.py create mode 100644 tests/api/test_simulation_endpoints.py create mode 100644 tests/api/test_user_management_endpoints.py create mode 100644 tests/auth/test_security.py create mode 100644 tests/unit/test_audit_repository.py create mode 100644 tests/unit/test_auth_dependencies.py create mode 100644 tests/unit/test_permissions.py create mode 100644 tests/unit/test_user_repository.py create mode 100644 失效API排查.md diff --git a/app/api/v1/endpoints/network/regions.py b/app/api/v1/endpoints/network/regions.py index 61c84f0..4d1b564 100644 --- a/app/api/v1/endpoints/network/regions.py +++ b/app/api/v1/endpoints/network/regions.py @@ -7,11 +7,9 @@ from app.services.tjnetwork import ( add_region, add_service_area, add_virtual_district, - # calculate_district_metering_area, calculate_district_metering_area_for_network, calculate_district_metering_area_for_nodes, calculate_district_metering_area_for_region, - # calculate_region, calculate_service_area, calculate_virtual_district, delete_district_metering_area, @@ -19,13 +17,11 @@ from app.services.tjnetwork import ( delete_service_area, delete_virtual_district, generate_district_metering_area, - # generate_region, generate_service_area, generate_sub_district_metering_area, generate_virtual_district, get_all_district_metering_area_ids, get_all_district_metering_areas, - # get_all_regions, get_all_service_areas, get_all_virtual_districts, get_district_metering_area, @@ -48,18 +44,6 @@ router = APIRouter() # region 32 ############################################################ -@router.get( - "/calculateregion/", - summary="计算区域", - description="计算指定水网在指定时间步长的区域分区" -) -async def fastapi_calculate_region( - network: str = Query(..., description="管网名称(或数据库名称)"), - time_index: int = Query(..., description="时间步长索引", ge=0) -) -> dict[str, Any]: - """计算区域分区。""" - return calculate_region(network, time_index) - @router.get( "/getregionschema/", summary="获取区域属性架构", @@ -125,62 +109,11 @@ async def fastapi_delete_region( props = await req.json() return delete_region(network, ChangeSet(props)) -@router.get( - "/getallregions/", - summary="获取所有区域", - description="获取指定水网中的所有区域信息" -) -async def fastapi_get_all_regions( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> list[dict[str, Any]]: - """获取所有区域的信息列表。""" - return get_all_regions(network) - -@router.post( - "/generateregion/", - response_model=None, - summary="生成区域分区", - description="根据参数自动生成水网的区域分区" -) -async def fastapi_generate_region( - network: str = Query(..., description="管网名称(或数据库名称)"), - inflate_delta: float = Query(..., description="膨胀参数") -) -> ChangeSet: - """生成区域分区。""" - return generate_region(network, inflate_delta) - ############################################################ # district_metering_area 33 ############################################################ -@router.get( - "/calculatedistrictmeteringarea/", - summary="计算DMA分区", - description="计算指定节点集的区域计量(DMA)分区方案" -) -async def fastapi_calculate_district_metering_area( - network: str = Query(..., description="管网名称(或数据库名称)"), - req: Request = None -) -> list[list[str]]: - """ - 计算DMA分区。 - - 请求体格式: - { - "nodes": 节点ID列表(list[str]), - "part_count": 分区数量(int), - "part_type": 分区类型(int) - } - """ - props = await req.json() - nodes = props["nodes"] - part_count = props["part_count"] - part_type = props["part_type"] - return calculate_district_metering_area( - network, nodes, part_count, part_type - ) - @router.get( "/calculatedistrictmeteringareaforregion/", summary="计算区域内DMA分区", @@ -368,14 +301,13 @@ async def fastapi_generate_sub_district_metering_area( @router.get( "/calculateservicearea/", summary="计算服务区", - description="计算指定水网在指定时间步长的服务区分区" + description="计算指定水网的服务区分区,返回全部时间步结果" ) async def fastapi_calculate_service_area( network: str = Query(..., description="管网名称(或数据库名称)"), - time_index: int = Query(..., description="时间步长索引", ge=0) -) -> dict[str, Any]: - """计算服务区分区。""" - return calculate_service_area(network, time_index) +) -> list[dict[str, list[str]]]: + """计算服务区分区,返回全部时间步结果。""" + return calculate_service_area(network) @router.get( "/getserviceareaschema/", diff --git a/tests/api/test_audit_endpoints.py b/tests/api/test_audit_endpoints.py new file mode 100644 index 0000000..d043800 --- /dev/null +++ b/tests/api/test_audit_endpoints.py @@ -0,0 +1,91 @@ +from unittest.mock import AsyncMock + +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import audit as audit_endpoint +from app.auth.metadata_dependencies import ( + get_current_metadata_admin, + get_current_metadata_user, +) +from tests.conftest import build_test_app, make_audit_log + + +def _build_client(repo, *, metadata_admin=None, metadata_user=None) -> TestClient: + app = build_test_app(audit_endpoint.router, "/audit") + app.dependency_overrides[audit_endpoint.get_audit_repository] = lambda: repo + if metadata_admin is not None: + app.dependency_overrides[get_current_metadata_admin] = lambda: metadata_admin + if metadata_user is not None: + app.dependency_overrides[get_current_metadata_user] = lambda: metadata_user + return TestClient(app) + + +def test_get_audit_logs_passes_filters(): + repo = type( + "Repo", + (), + { + "get_logs": AsyncMock(return_value=[make_audit_log(action="LOGIN")]), + "get_log_count": AsyncMock(), + }, + )() + client = _build_client(repo, metadata_admin=object()) + + response = client.get( + "/audit/logs", + params={ + "action": "LOGIN", + "resource_type": "user", + "skip": 2, + "limit": 5, + }, + ) + + assert response.status_code == 200 + assert response.json()[0]["action"] == "LOGIN" + repo.get_logs.assert_awaited_once() + kwargs = repo.get_logs.await_args.kwargs + assert kwargs["action"] == "LOGIN" + assert kwargs["resource_type"] == "user" + assert kwargs["skip"] == 2 + assert kwargs["limit"] == 5 + + +def test_get_audit_logs_count_returns_count_payload(): + repo = type( + "Repo", + (), + { + "get_logs": AsyncMock(), + "get_log_count": AsyncMock(return_value=7), + }, + )() + client = _build_client(repo, metadata_admin=object()) + + response = client.get("/audit/logs/count", params={"action": "DELETE_USER"}) + + assert response.status_code == 200 + assert response.json() == {"count": 7} + repo.get_log_count.assert_awaited_once() + assert repo.get_log_count.await_args.kwargs["action"] == "DELETE_USER" + + +def test_get_my_audit_logs_forces_current_user_id(): + current_user = type("User", (), {"id": make_audit_log().user_id})() + repo = type( + "Repo", + (), + { + "get_logs": AsyncMock(return_value=[make_audit_log(user_id=current_user.id)]), + "get_log_count": AsyncMock(), + }, + )() + client = _build_client(repo, metadata_user=current_user) + + response = client.get("/audit/logs/my", params={"limit": 3}) + + assert response.status_code == 200 + repo.get_logs.assert_awaited_once() + kwargs = repo.get_logs.await_args.kwargs + assert kwargs["user_id"] == current_user.id + assert kwargs["limit"] == 3 diff --git a/tests/api/test_auth_endpoints.py b/tests/api/test_auth_endpoints.py new file mode 100644 index 0000000..144e442 --- /dev/null +++ b/tests/api/test_auth_endpoints.py @@ -0,0 +1,139 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import auth as auth_endpoint +from app.auth.dependencies import get_current_active_user, get_user_repository +from app.core.security import create_access_token, create_refresh_token, get_password_hash +from tests.conftest import build_test_app, make_user + + +def _build_client(repo, current_user=None) -> TestClient: + app = build_test_app(auth_endpoint.router, "/api/v1/auth") + app.dependency_overrides[get_user_repository] = lambda: repo + if current_user is not None: + app.dependency_overrides[get_current_active_user] = lambda: current_user + return TestClient(app) + + +def test_register_success(): + repo = SimpleNamespace( + user_exists=AsyncMock(side_effect=[False, False]), + create_user=AsyncMock(return_value=make_user()), + ) + client = _build_client(repo) + + response = client.post( + "/api/v1/auth/register", + json={ + "username": "tester", + "email": "tester@example.com", + "password": "secret123", + }, + ) + + assert response.status_code == 201 + assert response.json()["username"] == "tester" + + +def test_register_rejects_duplicate_username(): + repo = SimpleNamespace( + user_exists=AsyncMock(side_effect=[True]), + create_user=AsyncMock(), + ) + client = _build_client(repo) + + response = client.post( + "/api/v1/auth/register", + json={ + "username": "tester", + "email": "tester@example.com", + "password": "secret123", + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Username already registered" + repo.create_user.assert_not_awaited() + + +def test_login_supports_email_lookup(): + hashed_password = get_password_hash("secret123") + repo = SimpleNamespace( + get_user_by_username=AsyncMock(return_value=None), + get_user_by_email=AsyncMock( + return_value=make_user( + email="tester@example.com", + hashed_password=hashed_password, + ) + ), + ) + client = _build_client(repo) + + response = client.post( + "/api/v1/auth/login", + data={"username": "tester@example.com", "password": "secret123"}, + ) + + assert response.status_code == 200 + assert response.json()["token_type"] == "bearer" + repo.get_user_by_email.assert_awaited_once_with("tester@example.com") + + +def test_login_simple_uses_query_params(): + hashed_password = get_password_hash("secret123") + repo = SimpleNamespace( + get_user_by_username=AsyncMock( + return_value=make_user(hashed_password=hashed_password) + ), + get_user_by_email=AsyncMock(), + ) + client = _build_client(repo) + + response = client.post( + "/api/v1/auth/login/simple", + params={"username": "tester", "password": "secret123"}, + ) + + assert response.status_code == 200 + assert response.json()["token_type"] == "bearer" + + +def test_me_returns_current_user_info(): + client = _build_client(SimpleNamespace(), current_user=make_user(username="alice")) + + response = client.get("/api/v1/auth/me") + + assert response.status_code == 200 + assert response.json()["username"] == "alice" + + +def test_refresh_rejects_access_token(): + repo = SimpleNamespace(get_user_by_username=AsyncMock()) + client = _build_client(repo) + + response = client.post( + "/api/v1/auth/refresh", + params={"refresh_token": create_access_token("tester")}, + ) + + assert response.status_code == 401 + + +def test_refresh_success_returns_new_access_token(): + repo = SimpleNamespace( + get_user_by_username=AsyncMock(return_value=make_user()), + ) + client = _build_client(repo) + refresh_token = create_refresh_token("tester") + + response = client.post( + "/api/v1/auth/refresh", + params={"refresh_token": refresh_token}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["refresh_token"] == refresh_token + assert payload["token_type"] == "bearer" diff --git a/tests/api/test_project_endpoints.py b/tests/api/test_project_endpoints.py new file mode 100644 index 0000000..374f4ad --- /dev/null +++ b/tests/api/test_project_endpoints.py @@ -0,0 +1,152 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from tests.conftest import build_test_app, install_stub, load_module_from_path + + +class DummyChangeSet: + def __init__(self, operations=None): + if operations is None: + self.operations = [] + elif isinstance(operations, dict): + self.operations = [operations] + else: + self.operations = operations + + +def _load_project_module(monkeypatch): + install_stub(monkeypatch, "app.services", package=True) + install_stub( + monkeypatch, + "app.services.project_info", + {}, + ) + install_stub( + monkeypatch, + "app.services.tjnetwork", + { + "ChangeSet": DummyChangeSet, + "list_project": lambda: ["demo"], + "have_project": lambda network: network == "demo", + "create_project": lambda network: None, + "delete_project": lambda network: None, + "is_project_open": lambda network: False, + "open_project": lambda network: None, + "close_project": lambda network: None, + "copy_project": lambda source, target: None, + "import_inp": lambda network, cs: {"ok": True}, + "export_inp": lambda network, version: DummyChangeSet({"kind": "export"}), + "read_inp": lambda network, inp: True, + "dump_inp": lambda network, inp: True, + "get_all_vertices": lambda network: [], + "get_all_scada_elements": lambda network: [], + "get_all_district_metering_areas": lambda network: [], + "get_all_service_areas": lambda network: [], + "get_all_virtual_districts": lambda network: [], + "get_extension_data": lambda network, key: None, + "convert_inp_v3_to_v2": lambda inp: DummyChangeSet({"inp": inp}), + }, + ) + install_stub( + monkeypatch, + "app.auth.project_dependencies", + {"get_metadata_repository": lambda: None}, + ) + install_stub( + monkeypatch, + "app.infra.db.postgresql.database", + {"get_database_instance": lambda network: None}, + ) + install_stub( + monkeypatch, + "app.infra.db.timescaledb.database", + {"get_database_instance": lambda network: None}, + ) + return load_module_from_path( + "tests_project_endpoints_module", + "app/api/v1/endpoints/project.py", + ) + + +def test_project_info_returns_404_when_missing(monkeypatch): + module = _load_project_module(monkeypatch) + repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=None)) + app = build_test_app(module.router, "/api/v1") + app.dependency_overrides[module.get_metadata_repository] = lambda: repo + client = TestClient(app) + + response = client.get("/api/v1/project_info/", params={"network": "missing"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "Project missing not found" + + +def test_project_info_returns_geoserver_payload(monkeypatch): + module = _load_project_module(monkeypatch) + detail = SimpleNamespace( + project_id=uuid4(), + name="Demo Project", + code="demo", + description="desc", + gs_workspace="ws", + map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}, + status="active", + geoserver=SimpleNamespace( + gs_base_url="http://gs", + gs_admin_user="admin", + gs_datastore_name="store", + default_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}, + srid=4326, + ), + ) + repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=detail)) + app = build_test_app(module.router, "/api/v1") + app.dependency_overrides[module.get_metadata_repository] = lambda: repo + client = TestClient(app) + + response = client.get("/api/v1/project_info/", params={"network": "demo"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == "demo" + assert payload["geoserver"]["gs_base_url"] == "http://gs" + + +def test_open_project_returns_network_even_when_db_connection_fails(monkeypatch): + module = _load_project_module(monkeypatch) + called = [] + + monkeypatch.setattr(module, "open_project", lambda network: called.append(network)) + + async def failing_get_pg_db(network): + raise RuntimeError("db down") + + monkeypatch.setattr(module, "get_pg_db", failing_get_pg_db) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post("/api/v1/openproject/", params={"network": "demo"}) + + assert response.status_code == 200 + assert response.json() == "demo" + assert called == ["demo"] + + +def test_project_lock_lifecycle(monkeypatch): + module = _load_project_module(monkeypatch) + module.lockedPrjs.clear() + client = TestClient(build_test_app(module.router, "/api/v1")) + + first_lock = client.post("/api/v1/lockproject/", params={"network": "demo"}) + second_lock = client.post("/api/v1/lockproject/", params={"network": "demo"}) + locked_by_me = client.get("/api/v1/isprojectlockedbyme/", params={"network": "demo"}) + unlock = client.post("/api/v1/unlockproject/", params={"network": "demo"}) + locked = client.get("/api/v1/isprojectlocked/", params={"network": "demo"}) + + assert first_lock.json() == 0 + assert second_lock.json() == 1 + assert locked_by_me.json() is True + assert unlock.json() is True + assert locked.json() is False diff --git a/tests/api/test_regions_endpoints.py b/tests/api/test_regions_endpoints.py new file mode 100644 index 0000000..b51a9cc --- /dev/null +++ b/tests/api/test_regions_endpoints.py @@ -0,0 +1,154 @@ +from typing import Any + +from fastapi.testclient import TestClient + +from tests.conftest import build_test_app, install_stub, load_module_from_path + + +class DummyChangeSet: + def __init__(self, operations=None): + if operations is None: + self.operations = [] + elif isinstance(operations, dict): + self.operations = [operations] + else: + self.operations = operations + + +def _noop(*args, **kwargs): + return None + + +def _load_regions_module(monkeypatch): + install_stub(monkeypatch, "app.services", package=True) + install_stub( + monkeypatch, + "app.services.tjnetwork", + { + "Any": Any, + "ChangeSet": DummyChangeSet, + "add_district_metering_area": _noop, + "add_region": _noop, + "add_service_area": _noop, + "add_virtual_district": _noop, + "calculate_district_metering_area_for_network": lambda *args, **kwargs: [], + "calculate_district_metering_area_for_nodes": lambda *args, **kwargs: [], + "calculate_district_metering_area_for_region": lambda *args, **kwargs: [], + "calculate_service_area": lambda network: [], + "calculate_virtual_district": lambda *args, **kwargs: {}, + "delete_district_metering_area": _noop, + "delete_region": _noop, + "delete_service_area": _noop, + "delete_virtual_district": _noop, + "generate_district_metering_area": _noop, + "generate_service_area": _noop, + "generate_sub_district_metering_area": _noop, + "generate_virtual_district": _noop, + "get_all_district_metering_area_ids": lambda network: [], + "get_all_district_metering_areas": lambda network: [], + "get_all_service_areas": lambda network: [], + "get_all_virtual_districts": lambda network: [], + "get_district_metering_area": lambda network, area_id: {}, + "get_district_metering_area_schema": lambda network: {}, + "get_region": lambda network, region_id: {}, + "get_region_schema": lambda network: {}, + "get_service_area": lambda network, area_id: {}, + "get_service_area_schema": lambda network: {}, + "get_virtual_district": lambda network, area_id: {}, + "get_virtual_district_schema": lambda network: {}, + "set_district_metering_area": _noop, + "set_region": _noop, + "set_service_area": _noop, + "set_virtual_district": _noop, + }, + ) + return load_module_from_path( + "tests_regions_endpoints_module", + "app/api/v1/endpoints/network/regions.py", + ) + + +def test_removed_routes_are_absent_and_return_404(monkeypatch): + module = _load_regions_module(monkeypatch) + client = TestClient(build_test_app(module.router, "/api/v1")) + + openapi = client.get("/openapi.json").json() + + assert "/api/v1/calculateregion/" not in openapi["paths"] + assert "/api/v1/getallregions/" not in openapi["paths"] + assert "/api/v1/generateregion/" not in openapi["paths"] + assert "/api/v1/calculatedistrictmeteringarea/" not in openapi["paths"] + assert client.get("/api/v1/calculateregion/", params={"network": "demo", "time_index": 0}).status_code == 404 + assert client.get("/api/v1/calculatedistrictmeteringarea/", params={"network": "demo"}).status_code == 404 + + +def test_calculate_service_area_contract_uses_only_network(monkeypatch): + module = _load_regions_module(monkeypatch) + calls = [] + monkeypatch.setattr( + module, + "calculate_service_area", + lambda network: calls.append(network) or [{"source-1": ["n1", "n2"]}], + ) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/calculateservicearea/", + params={"network": "demo", "time_index": 5}, + ) + schema = client.get("/openapi.json").json() + + assert response.status_code == 200 + assert response.json() == [{"source-1": ["n1", "n2"]}] + assert calls == ["demo"] + parameter_names = [ + item["name"] + for item in schema["paths"]["/api/v1/calculateservicearea/"]["get"]["parameters"] + ] + assert parameter_names == ["network"] + + +def test_add_district_metering_area_converts_boundary_to_tuples(monkeypatch): + module = _load_regions_module(monkeypatch) + captured = {} + + def fake_add(network, change_set): + captured["network"] = network + captured["boundary"] = change_set.operations[0]["boundary"] + return {"ok": True} + + monkeypatch.setattr(module, "add_district_metering_area", fake_add) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/adddistrictmeteringarea/", + params={"network": "demo"}, + json={"id": "dma-1", "boundary": [[1, 2], [3, 4], [1, 2]]}, + ) + + assert response.status_code == 200 + assert captured == { + "network": "demo", + "boundary": [(1, 2), (3, 4), (1, 2)], + } + + +def test_generate_virtual_district_reads_centers_from_body(monkeypatch): + module = _load_regions_module(monkeypatch) + captured = {} + + def fake_generate(network, centers, inflate_delta): + captured["args"] = (network, centers, inflate_delta) + return {"generated": True} + + monkeypatch.setattr(module, "generate_virtual_district", fake_generate) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/generatevirtualdistrict/", + params={"network": "demo", "inflate_delta": 0.75}, + json={"centers": ["J1", "J2"]}, + ) + + assert response.status_code == 200 + assert captured["args"] == ("demo", ["J1", "J2"], 0.75) diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py new file mode 100644 index 0000000..e9cdb1c --- /dev/null +++ b/tests/api/test_simulation_endpoints.py @@ -0,0 +1,175 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from tests.conftest import build_test_app, install_stub, load_module_from_path + + +def _load_simulation_module(monkeypatch): + install_stub(monkeypatch, "app.services", package=True) + install_stub( + monkeypatch, + "app.services.simulation", + {"run_simulation": lambda **kwargs: None}, + ) + install_stub(monkeypatch, "app.services.globals", {}) + install_stub( + monkeypatch, + "app.services.tjnetwork", + { + "run_project": lambda network: "report", + "run_project_return_dict": lambda network: {"output": {}, "report": "ok"}, + "run_inp": lambda network: "inp-report", + "dump_output": lambda output: f"dump::{output}", + }, + ) + install_stub(monkeypatch, "app.algorithms", package=True) + install_stub(monkeypatch, "app.algorithms.simulation", package=True) + install_stub( + monkeypatch, + "app.algorithms.simulation.scenarios", + { + "burst_analysis": lambda *args, **kwargs: "burst", + "valve_close_analysis": lambda *args, **kwargs: "valve", + "flushing_analysis": lambda *args, **kwargs: "flush", + "contaminant_simulation": lambda *args, **kwargs: "contaminant", + "age_analysis": lambda *args, **kwargs: "age", + "pressure_regulation": lambda *args, **kwargs: "pressure", + }, + ) + install_stub( + monkeypatch, + "app.algorithms.sensor", + { + "pressure_sensor_placement_sensitivity": lambda *args, **kwargs: [], + "pressure_sensor_placement_kmeans": lambda *args, **kwargs: [], + }, + ) + install_stub( + monkeypatch, + "app.services.network_import", + {"network_update": lambda *args, **kwargs: "updated"}, + ) + install_stub( + monkeypatch, + "app.services.simulation_ops", + { + "project_management": lambda *args, **kwargs: "managed", + "scheduling_simulation": lambda *args, **kwargs: "scheduled", + "daily_scheduling_simulation": lambda *args, **kwargs: "daily", + }, + ) + install_stub( + monkeypatch, + "app.services.valve_isolation", + {"analyze_valve_isolation": lambda *args, **kwargs: {}}, + ) + return load_module_from_path( + "tests_simulation_endpoints_module", + "app/api/v1/endpoints/simulation.py", + ) + + +def test_run_project_endpoint_returns_plain_text(monkeypatch): + module = _load_simulation_module(monkeypatch) + monkeypatch.setattr(module, "run_project", lambda network: f"report::{network}") + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get("/api/v1/runproject/", params={"network": "demo"}) + + assert response.status_code == 200 + assert response.text == "report::demo" + + +def test_scheduling_analysis_maps_request_body(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_schedule(network, start_time, pump_control, tank_id, water_plant_output_id, time_delta): + captured["args"] = ( + network, + start_time, + pump_control, + tank_id, + water_plant_output_id, + time_delta, + ) + return "scheduled" + + monkeypatch.setattr(module, "scheduling_simulation", fake_schedule) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/scheduling_analysis/", + json={ + "network": "demo", + "start_time": "2025-01-01T08:00:00+08:00", + "pump_control": {"P1": [1, 0, 1]}, + "tank_id": "T1", + "water_plant_output_id": "R1", + }, + ) + + assert response.status_code == 200 + assert response.json() == "scheduled" + assert captured["args"] == ( + "demo", + "2025-01-01T08:00:00+08:00", + {"P1": [1, 0, 1]}, + "T1", + "R1", + 300, + ) + + +def test_project_management_maps_named_arguments(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_project_management(**kwargs): + captured.update(kwargs) + return "managed" + + monkeypatch.setattr(module, "project_management", fake_project_management) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/project_management/", + json={ + "network": "demo", + "start_time": "2025-01-01T08:00:00+08:00", + "pump_control": {"P1": [1]}, + "tank_init_level": {"T1": 10.0}, + "region_demand": {"R1": 20.0}, + }, + ) + + assert response.status_code == 200 + assert response.json() == "managed" + assert captured == { + "prj_name": "demo", + "start_datetime": "2025-01-01T08:00:00+08:00", + "pump_control": {"P1": [1]}, + "tank_initial_level_control": {"T1": 10.0}, + "region_demand_control": {"R1": 20.0}, + } + + +def test_network_update_surfaces_service_error(monkeypatch, tmp_path): + module = _load_simulation_module(monkeypatch) + monkeypatch.chdir(tmp_path) + + def boom(_path): + raise RuntimeError("write failed") + + monkeypatch.setattr(module, "network_update", boom) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/network_update/", + files={"file": ("update.txt", b"payload")}, + ) + + assert response.status_code == 500 + assert "数据库操作失败: write failed" in response.json()["detail"] + assert list(Path(tmp_path).glob("network_update_*")) diff --git a/tests/api/test_user_management_endpoints.py b/tests/api/test_user_management_endpoints.py new file mode 100644 index 0000000..8991d5a --- /dev/null +++ b/tests/api/test_user_management_endpoints.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import user_management as user_management_endpoint +from app.auth.dependencies import get_current_active_user, get_user_repository +from app.auth.permissions import get_current_admin +from app.domain.models.role import UserRole +from tests.conftest import build_test_app, make_user + + +def _build_client(repo, *, current_user=None, admin_user=None) -> TestClient: + app = build_test_app(user_management_endpoint.router, "/users") + app.dependency_overrides[get_user_repository] = lambda: repo + if current_user is not None: + app.dependency_overrides[get_current_active_user] = lambda: current_user + if admin_user is not None: + app.dependency_overrides[get_current_admin] = lambda: admin_user + return TestClient(app) + + +def test_list_users_requires_admin_role(): + repo = SimpleNamespace( + get_all_users=AsyncMock( + return_value=[ + make_user(id=1, username="admin", role=UserRole.ADMIN), + make_user(id=2, username="user2"), + ] + ) + ) + client = _build_client( + repo, + current_user=make_user(id=1, role=UserRole.ADMIN), + ) + + response = client.get("/users/", params={"skip": 5, "limit": 2}) + + assert response.status_code == 200 + assert len(response.json()) == 2 + repo.get_all_users.assert_awaited_once_with(skip=5, limit=2) + + +def test_get_user_rejects_non_owner_non_admin(): + repo = SimpleNamespace(get_user_by_id=AsyncMock()) + client = _build_client(repo, current_user=make_user(id=2, role=UserRole.USER)) + + response = client.get("/users/3") + + assert response.status_code == 403 + assert response.json()["detail"] == "You don't have permission to view this user" + repo.get_user_by_id.assert_not_awaited() + + +def test_update_user_blocks_role_change_for_non_admin(): + repo = SimpleNamespace( + get_user_by_id=AsyncMock(return_value=make_user(id=1)), + update_user=AsyncMock(), + ) + client = _build_client(repo, current_user=make_user(id=1, role=UserRole.USER)) + + response = client.put("/users/1", json={"role": "ADMIN"}) + + assert response.status_code == 403 + assert response.json()["detail"] == "Only admins can change user roles" + repo.update_user.assert_not_awaited() + + +def test_delete_user_blocks_self_delete_for_admin(): + admin_user = make_user(id=1, role=UserRole.ADMIN, is_superuser=True) + repo = SimpleNamespace(delete_user=AsyncMock()) + client = _build_client(repo, admin_user=admin_user) + + response = client.delete("/users/1") + + assert response.status_code == 400 + assert response.json()["detail"] == "You cannot delete your own account" + repo.delete_user.assert_not_awaited() + + +def test_activate_user_updates_active_flag(): + repo = SimpleNamespace( + update_user=AsyncMock(return_value=make_user(id=2, is_active=True)), + ) + client = _build_client( + repo, + admin_user=make_user(id=1, role=UserRole.ADMIN, is_superuser=True), + ) + + response = client.post("/users/2/activate") + + assert response.status_code == 200 + assert response.json()["is_active"] is True + user_update = repo.update_user.await_args.args[1] + assert user_update.is_active is True diff --git a/tests/auth/test_security.py b/tests/auth/test_security.py new file mode 100644 index 0000000..8953108 --- /dev/null +++ b/tests/auth/test_security.py @@ -0,0 +1,36 @@ +from jose import jwt + +from app.core.config import settings +from app.core.security import ( + create_access_token, + create_refresh_token, + get_password_hash, + verify_password, +) + + +def test_password_hash_roundtrip(): + hashed = get_password_hash("secret123") + assert hashed != "secret123" + assert verify_password("secret123", hashed) is True + assert verify_password("wrong", hashed) is False + + +def test_create_access_token_sets_access_type(): + token = create_access_token("alice") + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + assert payload["sub"] == "alice" + assert payload["type"] == "access" + assert "exp" in payload + assert "iat" in payload + + +def test_create_refresh_token_sets_refresh_type(): + token = create_refresh_token("alice") + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + assert payload["sub"] == "alice" + assert payload["type"] == "refresh" + assert "exp" in payload + assert "iat" in payload diff --git a/tests/conftest.py b/tests/conftest.py index b1c7e7b..d528272 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,197 @@ -import pytest -import sys +import importlib +import importlib.util import os +import sys +import types +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import FastAPI # 自动添加项目根目录到路径(处理项目结构) -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) def run_this_test(test_file): """自定义函数:运行单个测试文件(类似pytest)""" - # 提取测试文件名(无扩展名) - test_name = os.path.splitext(os.path.basename(test_file))[0] - # 使用pytest运行(自动处理导入) pytest.main([test_file, "-v"]) + + +def build_test_app(router, prefix: str = "") -> FastAPI: + app = FastAPI() + app.include_router(router, prefix=prefix) + return app + + +def load_module_from_path(module_name: str, relative_path: str): + module_path = PROJECT_ROOT / relative_path + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def install_stub(monkeypatch, name: str, attrs: dict | None = None, package: bool = False): + module = types.ModuleType(name) + if package: + module.__path__ = [] + if attrs: + for key, value in attrs.items(): + setattr(module, key, value) + + monkeypatch.setitem(sys.modules, name, module) + + parent_name, _, child_name = name.rpartition(".") + if parent_name: + parent = sys.modules.get(parent_name) + if parent is None: + try: + parent = importlib.import_module(parent_name) + except Exception: + parent = types.ModuleType(parent_name) + parent.__path__ = [] + monkeypatch.setitem(sys.modules, parent_name, parent) + setattr(parent, child_name, module) + + return module + + +class FakeCursor: + def __init__( + self, + *, + fetchone_results=None, + fetchall_results=None, + rowcount: int = 0, + rowcounts=None, + ): + self._fetchone_results = list(fetchone_results or []) + self._fetchall_results = list(fetchall_results or []) + self._rowcounts = list(rowcounts or []) + self.rowcount = rowcount + self.executed: list[tuple[str, dict | tuple | None]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query, params=None): + self.executed.append((str(query), params)) + if self._rowcounts: + self.rowcount = self._rowcounts.pop(0) + + async def fetchone(self): + if self._fetchone_results: + return self._fetchone_results.pop(0) + return None + + async def fetchall(self): + if self._fetchall_results: + return self._fetchall_results.pop(0) + return [] + + +class FakeConnection: + def __init__(self, cursor: FakeCursor): + self.cursor_instance = cursor + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def cursor(self): + return self.cursor_instance + + +class FakeDB: + def __init__(self, cursor: FakeCursor): + self.connection = FakeConnection(cursor) + + def get_connection(self): + return self.connection + + +class FakeExecuteResult: + def __init__(self, *, rows=None, scalar_value=None): + self._rows = list(rows or []) + self._scalar_value = scalar_value + + def scalars(self): + return self + + def all(self): + return self._rows + + def scalar(self): + return self._scalar_value + + +class FakeAsyncSession: + def __init__(self, execute_results=None): + self._execute_results = list(execute_results or []) + self.executed = [] + self.added = [] + self.commit_count = 0 + self.refreshed = [] + + def add(self, obj): + self.added.append(obj) + + async def execute(self, stmt): + self.executed.append(stmt) + if self._execute_results: + return self._execute_results.pop(0) + return FakeExecuteResult() + + async def commit(self): + self.commit_count += 1 + + async def refresh(self, obj): + self.refreshed.append(obj) + + +def make_user(**overrides): + from app.domain.models.role import UserRole + from app.domain.schemas.user import UserInDB + + data = { + "id": 1, + "username": "tester", + "email": "tester@example.com", + "hashed_password": "hashed-password", + "role": UserRole.USER, + "is_active": True, + "is_superuser": False, + "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2025, 1, 1, tzinfo=timezone.utc), + } + data.update(overrides) + return UserInDB(**data) + + +def make_audit_log(**overrides): + data = { + "id": uuid4(), + "user_id": uuid4(), + "project_id": uuid4(), + "action": "LOGIN", + "resource_type": "user", + "resource_id": "1", + "ip_address": "127.0.0.1", + "request_method": "GET", + "request_path": "/audit/logs", + "request_data": {"ok": True}, + "response_status": 200, + "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc), + } + data.update(overrides) + return SimpleNamespace(**data) diff --git a/tests/unit/test_audit_repository.py b/tests/unit/test_audit_repository.py new file mode 100644 index 0000000..01a16bb --- /dev/null +++ b/tests/unit/test_audit_repository.py @@ -0,0 +1,79 @@ +import asyncio +from datetime import datetime, timezone +from uuid import uuid4 + +from app.infra.db.metadb.repositories.audit_repository import AuditRepository +from tests.conftest import FakeAsyncSession, FakeExecuteResult, make_audit_log + + +def test_create_log_adds_commits_and_refreshes(monkeypatch): + class FakeAuditLog: + def __init__(self, **kwargs): + self.id = uuid4() + for key, value in kwargs.items(): + setattr(self, key, value) + + session = FakeAsyncSession() + repo = AuditRepository(session) + monkeypatch.setattr( + "app.infra.db.metadb.repositories.audit_repository.models.AuditLog", + FakeAuditLog, + ) + + result = asyncio.run( + repo.create_log( + action="LOGIN", + request_method="POST", + request_path="/auth/login", + response_status=200, + ) + ) + + assert result.action == "LOGIN" + assert result.request_method == "POST" + assert session.commit_count == 1 + assert len(session.added) == 1 + assert len(session.refreshed) == 1 + + +def test_get_logs_builds_filtered_query_and_returns_models(): + log = make_audit_log(action="UPDATE_USER", resource_type="user") + session = FakeAsyncSession( + execute_results=[FakeExecuteResult(rows=[log])], + ) + repo = AuditRepository(session) + user_id = uuid4() + project_id = uuid4() + start_time = datetime(2025, 1, 1, tzinfo=timezone.utc) + + results = asyncio.run( + repo.get_logs( + user_id=user_id, + project_id=project_id, + action="UPDATE_USER", + resource_type="user", + start_time=start_time, + skip=5, + limit=10, + ) + ) + + assert len(results) == 1 + assert results[0].action == "UPDATE_USER" + stmt = session.executed[0] + assert len(stmt._where_criteria) == 5 + assert stmt._offset == 5 + assert stmt._limit == 10 + + +def test_get_log_count_returns_zero_when_scalar_none(): + session = FakeAsyncSession( + execute_results=[FakeExecuteResult(scalar_value=None)], + ) + repo = AuditRepository(session) + + result = asyncio.run(repo.get_log_count(action="DELETE_USER")) + + assert result == 0 + stmt = session.executed[0] + assert len(stmt._where_criteria) == 1 diff --git a/tests/unit/test_auth_dependencies.py b/tests/unit/test_auth_dependencies.py new file mode 100644 index 0000000..0c556a7 --- /dev/null +++ b/tests/unit/test_auth_dependencies.py @@ -0,0 +1,97 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.auth import dependencies +from app.core.security import create_access_token, create_refresh_token +from tests.conftest import make_user + + +def test_get_db_returns_app_state_db(): + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(db="db-instance"))) + + result = asyncio.run(dependencies.get_db(request)) + + assert result == "db-instance" + + +def test_get_db_raises_when_database_missing(): + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(dependencies.get_db(request)) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "Database not initialized" + + +def test_get_current_user_accepts_valid_access_token(): + repo = SimpleNamespace(get_user_by_username=AsyncMock(return_value=make_user())) + + result = asyncio.run( + dependencies.get_current_user( + token=create_access_token("tester"), + user_repo=repo, + ) + ) + + assert result.username == "tester" + repo.get_user_by_username.assert_awaited_once_with("tester") + + +def test_get_current_user_rejects_refresh_token(): + repo = SimpleNamespace(get_user_by_username=AsyncMock()) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + dependencies.get_current_user( + token=create_refresh_token("tester"), + user_repo=repo, + ) + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid token type. Access token required." + repo.get_user_by_username.assert_not_awaited() + + +def test_get_current_user_rejects_missing_user(): + repo = SimpleNamespace(get_user_by_username=AsyncMock(return_value=None)) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + dependencies.get_current_user( + token=create_access_token("ghost"), + user_repo=repo, + ) + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Could not validate credentials" + + +def test_get_current_active_user_rejects_inactive_user(): + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + dependencies.get_current_active_user( + current_user=make_user(is_active=False), + ) + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Inactive user" + + +def test_get_current_superuser_rejects_non_superuser(): + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + dependencies.get_current_superuser( + current_user=make_user(is_superuser=False), + ) + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Not enough privileges. Superuser access required." diff --git a/tests/unit/test_permissions.py b/tests/unit/test_permissions.py new file mode 100644 index 0000000..3dcd86a --- /dev/null +++ b/tests/unit/test_permissions.py @@ -0,0 +1,56 @@ +import asyncio +import pytest +from fastapi import HTTPException + +from app.auth import permissions +from app.domain.models.role import UserRole +from tests.conftest import make_user + + +def test_require_role_allows_higher_privilege_user(): + checker = permissions.require_role(UserRole.OPERATOR) + + result = asyncio.run(checker(current_user=make_user(role=UserRole.ADMIN))) + + assert result.role == UserRole.ADMIN + + +def test_require_role_rejects_insufficient_role(): + checker = permissions.require_role(UserRole.ADMIN) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(checker(current_user=make_user(role=UserRole.USER))) + + assert exc_info.value.status_code == 403 + assert "Required role: ADMIN" in exc_info.value.detail + + +def test_check_resource_owner_allows_admin(): + assert permissions.check_resource_owner( + 99, + make_user(id=1, role=UserRole.ADMIN), + ) is True + + +def test_check_resource_owner_allows_owner(): + assert permissions.check_resource_owner( + 7, + make_user(id=7, role=UserRole.USER), + ) is True + + +def test_check_resource_owner_rejects_other_user(): + assert permissions.check_resource_owner( + 7, + make_user(id=8, role=UserRole.USER), + ) is False + + +def test_require_owner_or_admin_rejects_other_user(): + checker = permissions.require_owner_or_admin(7) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(checker(current_user=make_user(id=8, role=UserRole.USER))) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "You don't have permission to access this resource" diff --git a/tests/unit/test_scada_repository.py b/tests/unit/test_scada_repository.py index 98fbcbb..c8a01e3 100644 --- a/tests/unit/test_scada_repository.py +++ b/tests/unit/test_scada_repository.py @@ -1,9 +1,8 @@ +import asyncio from datetime import datetime, timezone import importlib.util from pathlib import Path -import pytest - def _load_scada_repository(): module_path = ( @@ -50,18 +49,19 @@ class _FakeConnection: return self.cursor_instance -@pytest.mark.asyncio -async def test_update_scada_field_inserts_when_update_hits_no_rows(): +def test_update_scada_field_inserts_when_update_hits_no_rows(): ScadaRepository = _load_scada_repository() conn = _FakeConnection(initial_rowcount=0) point_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) - await ScadaRepository.update_scada_field( - conn, - point_time, - "170490", - "cleaned_value", - 26.5, + asyncio.run( + ScadaRepository.update_scada_field( + conn, + point_time, + "170490", + "cleaned_value", + 26.5, + ) ) assert len(conn.cursor_instance.calls) == 2 @@ -69,18 +69,19 @@ async def test_update_scada_field_inserts_when_update_hits_no_rows(): assert "INSERT INTO scada.scada_data" in conn.cursor_instance.calls[1][0] -@pytest.mark.asyncio -async def test_update_scada_field_skips_insert_when_update_succeeds(): +def test_update_scada_field_skips_insert_when_update_succeeds(): ScadaRepository = _load_scada_repository() conn = _FakeConnection(initial_rowcount=1) point_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) - await ScadaRepository.update_scada_field( - conn, - point_time, - "170490", - "cleaned_value", - 26.5, + asyncio.run( + ScadaRepository.update_scada_field( + conn, + point_time, + "170490", + "cleaned_value", + 26.5, + ) ) assert len(conn.cursor_instance.calls) == 1 diff --git a/tests/unit/test_user_repository.py b/tests/unit/test_user_repository.py new file mode 100644 index 0000000..7d52ad8 --- /dev/null +++ b/tests/unit/test_user_repository.py @@ -0,0 +1,124 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from app.domain.models.role import UserRole +from app.domain.schemas.user import UserCreate, UserUpdate +from app.infra.db.metadb.repositories.user_repository import UserRepository +from tests.conftest import FakeCursor, FakeDB + + +def _user_row(**overrides): + base = { + "id": 1, + "username": "tester", + "email": "tester@example.com", + "hashed_password": "hashed-password", + "role": "USER", + "is_active": True, + "is_superuser": False, + "created_at": "2025-01-01T00:00:00+00:00", + "updated_at": "2025-01-01T00:00:00+00:00", + } + base.update(overrides) + return base + + +def test_create_user_hashes_password_and_returns_model(monkeypatch): + cursor = FakeCursor(fetchone_results=[_user_row()]) + repo = UserRepository(FakeDB(cursor)) + monkeypatch.setattr( + "app.infra.db.metadb.repositories.user_repository.get_password_hash", + lambda password: f"hashed::{password}", + ) + + result = asyncio.run( + repo.create_user( + UserCreate( + username="tester", + email="tester@example.com", + password="secret123", + ) + ) + ) + + assert result is not None + assert result.username == "tester" + assert cursor.executed[0][1]["hashed_password"] == "hashed::secret123" + + +def test_update_user_without_fields_returns_existing_user(monkeypatch): + repo = UserRepository(FakeDB(FakeCursor())) + existing_user = AsyncMock(return_value="existing") + monkeypatch.setattr(repo, "get_user_by_id", existing_user) + + result = asyncio.run(repo.update_user(1, UserUpdate())) + + assert result == "existing" + existing_user.assert_awaited_once_with(1) + + +def test_update_user_builds_dynamic_query(monkeypatch): + cursor = FakeCursor(fetchone_results=[_user_row(role="ADMIN", email="new@example.com")]) + repo = UserRepository(FakeDB(cursor)) + monkeypatch.setattr( + "app.infra.db.metadb.repositories.user_repository.get_password_hash", + lambda password: f"hashed::{password}", + ) + + result = asyncio.run( + repo.update_user( + 1, + UserUpdate( + email="new@example.com", + password="new-secret", + role=UserRole.ADMIN, + is_active=False, + ), + ), + ) + + assert result is not None + query, params = cursor.executed[0] + assert "email = %(email)s" in query + assert "hashed_password = %(hashed_password)s" in query + assert "role = %(role)s" in query + assert "is_active = %(is_active)s" in query + assert params["hashed_password"] == "hashed::new-secret" + assert params["role"] == "ADMIN" + assert params["is_active"] is False + + +def test_delete_user_returns_false_when_execute_raises(): + cursor = FakeCursor() + cursor.execute = AsyncMock(side_effect=RuntimeError("boom")) + repo = UserRepository(FakeDB(cursor)) + + result = asyncio.run(repo.delete_user(1)) + + assert result is False + + +def test_user_exists_short_circuits_without_filters(): + cursor = FakeCursor() + repo = UserRepository(FakeDB(cursor)) + + result = asyncio.run(repo.user_exists()) + + assert result is False + assert cursor.executed == [] + + +def test_user_exists_checks_username_or_email(): + cursor = FakeCursor(fetchone_results=[{"exists": True}]) + repo = UserRepository(FakeDB(cursor)) + + result = asyncio.run( + repo.user_exists(username="tester", email="tester@example.com") + ) + + assert result is True + query, params = cursor.executed[0] + assert "username = %(username)s OR email = %(email)s" in query + assert params == {"username": "tester", "email": "tester@example.com"} diff --git a/失效API排查.md b/失效API排查.md new file mode 100644 index 0000000..8233525 --- /dev/null +++ b/失效API排查.md @@ -0,0 +1,76 @@ +# `app/api/v1/endpoints/` 失效 API 排查与修正 + +排查范围:`app/api/v1/endpoints/` + +结论:本次共确认 5 个问题接口,处理结果如下: + +- **已删除 4 个未实现坏接口** +- **已修正 1 个签名失配接口** + +> 路由统一前缀来自 `app/main.py:71`,以下完整路径均以 `/api/v1` 开头。 + +## 处理结果 + +| Method | API | 原问题 | 处理结果 | +| --- | --- | --- | --- | +| GET | `/api/v1/calculateregion/` | 调用时 `NameError`,底层无 `calculate_region` 实现 | **已删除** | +| GET | `/api/v1/getallregions/` | 调用时 `NameError`,底层无 `get_all_regions` 实现 | **已删除** | +| POST | `/api/v1/generateregion/` | 调用时 `NameError`,底层无 `generate_region` 实现 | **已删除** | +| GET | `/api/v1/calculatedistrictmeteringarea/` | 调用时 `NameError`,仍指向已废弃旧 DMA 入口 | **已删除** | +| GET | `/api/v1/calculateservicearea/` | endpoint 传 `time_index`,实现只接受 `name` | **已修正**,现返回全部时间步结果 | + +## 删除原因 + +### 1. region 相关 3 个接口 + +以下能力在当前 `wndb` / `tjnetwork` 中均不存在: + +- `calculate_region` +- `get_all_regions` +- `generate_region` + +`app/native/wndb/__init__.py` 当前只提供 region CRUD 和 util 能力,不提供 region 计算或批量查询能力。因此这 3 个接口继续保留只会在运行时失败。 + +### 2. DMA 旧入口 + +旧接口 `calculate_district_metering_area(...)` 已不存在,当前只保留 3 个明确变体: + +- `/api/v1/calculatedistrictmeteringareafornodes/` +- `/api/v1/calculatedistrictmeteringareaforregion/` +- `/api/v1/calculatedistrictmeteringareafornetwork/` + +因此旧入口 `/api/v1/calculatedistrictmeteringarea/` 已删除,避免前端继续误用历史接口。 + +## 修正内容 + +### `GET /api/v1/calculateservicearea/` + +原接口问题: + +- endpoint 定义保留 `time_index` +- 实际实现 `calculate_service_area(name)` 只接收 `network/name` +- 调用时会触发参数数量不匹配 + +本次修正后: + +- 移除 `time_index` 查询参数 +- 返回类型改为 `list[dict[str, list[str]]]` +- 接口语义改为:**返回全部时间步的服务区计算结果** + +## 当前可用替代接口 + +DMA 计算请使用: + +- `/api/v1/calculatedistrictmeteringareafornodes/` +- `/api/v1/calculatedistrictmeteringareaforregion/` +- `/api/v1/calculatedistrictmeteringareafornetwork/` + +服务区计算请使用: + +- `/api/v1/calculateservicearea/` + 现在返回全部时间步结果,不再接收 `time_index` + +## 变更文件 + +- `app/api/v1/endpoints/network/regions.py` +- `失效API排查.md` From 88be97ddebab6987f9234b5879ff975569c6bc8d Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 25 May 2026 17:51:45 +0800 Subject: [PATCH 16/93] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A4=B1=E8=B4=A5=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/native/wndb/database.py | 15 +- app/native/wndb/s29_scada_device.py | 6 +- app/native/wndb/s30_scada_device_data.py | 6 +- app/native/wndb/s32_region_util.py | 40 +--- app/native/wndb/s33_dma_cal.py | 18 +- app/native/wndb/s34_sa_cal.py | 246 ++++++++++++----------- 6 files changed, 171 insertions(+), 160 deletions(-) diff --git a/app/native/wndb/database.py b/app/native/wndb/database.py index c043d03..248b4a3 100644 --- a/app/native/wndb/database.py +++ b/app/native/wndb/database.py @@ -136,18 +136,20 @@ def execute_undo(name: str, discard: bool = False) -> ChangeSet: write(name, row['undo']) + parent = row['parent'] if row['parent'] != None else 0 + # update foreign key - write(name, f"update current_operation set id = {row['parent']} where id = {row['id']}") + write(name, f"update current_operation set id = {parent} where id = {row['id']}") if discard: # update foreign key - write(name, f"update operation set redo_child = null where id = {row['parent']}") + write(name, f"update operation set redo_child = null where id = {parent}") # on delete cascade => child & snapshot write(name, f"delete from operation where id = {row['id']}") else: - write(name, f"update operation set redo_child = {row['id']} where id = {row['parent']}") + write(name, f"update operation set redo_child = {row['id']} where id = {parent}") - e = eval(row['undo_cs']) + e = eval(row['undo_cs']) if row['undo_cs'] not in [None, ''] else [] return ChangeSet.from_list(e) @@ -159,9 +161,10 @@ def execute_redo(name: str) -> ChangeSet: row = read(name, f"select * from operation where id = {row['redo_child']}") write(name, row['redo']) - write(name, f"update current_operation set id = {row['id']} where id = {row['parent']}") + parent = row['parent'] if row['parent'] != None else 0 + write(name, f"update current_operation set id = {row['id']} where id = {parent}") - e = eval(row['redo_cs']) + e = eval(row['redo_cs']) if row['redo_cs'] not in [None, ''] else [] return ChangeSet.from_list(e) diff --git a/app/native/wndb/s29_scada_device.py b/app/native/wndb/s29_scada_device.py index 7af8ee0..ec2f139 100644 --- a/app/native/wndb/s29_scada_device.py +++ b/app/native/wndb/s29_scada_device.py @@ -72,7 +72,7 @@ def _set_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: def set_scada_device(name: str, cs: ChangeSet) -> ChangeSet: if get_scada_device(name, cs.operations[0]['id']) == {}: return ChangeSet() - return execute_command(name, _set_scada_device(name, cs), False) + return execute_command(name, _set_scada_device(name, cs)) def _add_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: @@ -90,7 +90,7 @@ def _add_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: def add_scada_device(name: str, cs: ChangeSet) -> ChangeSet: if get_scada_device(name, cs.operations[0]['id']) != {}: return ChangeSet() - return execute_command(name, _add_scada_device(name, cs), False) + return execute_command(name, _add_scada_device(name, cs)) def _delete_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: @@ -108,7 +108,7 @@ def _delete_scada_device(name: str, cs: ChangeSet) -> DbChangeSet: def delete_scada_device(name: str, cs: ChangeSet) -> ChangeSet: if get_scada_device(name, cs.operations[0]['id']) == {}: return ChangeSet() - return execute_command(name, _delete_scada_device(name, cs), False) + return execute_command(name, _delete_scada_device(name, cs)) def get_all_scada_device_ids(name: str) -> list[str]: diff --git a/app/native/wndb/s30_scada_device_data.py b/app/native/wndb/s30_scada_device_data.py index d1a6043..5f23800 100644 --- a/app/native/wndb/s30_scada_device_data.py +++ b/app/native/wndb/s30_scada_device_data.py @@ -45,7 +45,7 @@ def _set_scada_device_data(name: str, cs: ChangeSet) -> DbChangeSet: def set_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: - return execute_command(name, _set_scada_device_data(name, cs), False) + return execute_command(name, _set_scada_device_data(name, cs)) def _add_scada_device_data(name: str, cs: ChangeSet) -> DbChangeSet: @@ -66,7 +66,7 @@ def add_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: row = try_read(name, f"select * from scada_device_data where device_id = '{cs.operations[0]['device_id']}' and time = '{cs.operations[0]['time']}'") if row != None: return ChangeSet() - return execute_command(name, _add_scada_device_data(name, cs), False) + return execute_command(name, _add_scada_device_data(name, cs)) def _delete_scada_device_data(name: str, cs: ChangeSet) -> DbChangeSet: @@ -87,4 +87,4 @@ def delete_scada_device_data(name: str, cs: ChangeSet) -> ChangeSet: row = try_read(name, f"select * from scada_device_data where device_id = '{cs.operations[0]['device_id']}' and time = '{cs.operations[0]['time']}'") if row == None: return ChangeSet() - return execute_command(name, _delete_scada_device_data(name, cs), False) + return execute_command(name, _delete_scada_device_data(name, cs)) diff --git a/app/native/wndb/s32_region_util.py b/app/native/wndb/s32_region_util.py index 4fdb46d..f9c5e88 100644 --- a/app/native/wndb/s32_region_util.py +++ b/app/native/wndb/s32_region_util.py @@ -1,8 +1,8 @@ -import ctypes import platform import os import math from typing import Any +import pyclipper from .s0_base import get_node_links, get_link_nodes, is_pipe from .s5_pipes import get_pipe from .database import read, try_read, read_all, write @@ -414,40 +414,20 @@ def inflate_boundary(name: str, boundary: list[tuple[float, float]], delta: floa if boundary[0] == boundary[-1]: del(boundary[-1]) - lib = ctypes.CDLL(os.path.join(os.getcwd(), 'api', 'CClipper2.dll')) + precision = 2 + scale = 10 ** precision + path = [(round(x * scale), round(y * scale)) for x, y in boundary] - c_size = ctypes.c_size_t(len(boundary) * 2) - c_path = (ctypes.c_double * c_size.value)() - i = 0 - for xy in boundary: - c_path[i] = xy[0] - i += 1 - c_path[i] = xy[1] - i += 1 - c_delta = ctypes.c_double(delta) - JoinType_Square, JoinType_Round, JoinType_Miter = 0, 1, 2 - c_jt = ctypes.c_int(JoinType_Square) - EndType_Polygon, EndType_Joined, EndType_Butt, EndType_Square, EndType_Round = 0, 1, 2, 3, 4 - c_et = ctypes.c_int(EndType_Polygon) - c_miter_limit = ctypes.c_double(2.0) - c_precision = ctypes.c_int(2) - c_arc_tolerance = ctypes.c_double(0.0) - c_out_path = ctypes.POINTER(ctypes.c_double)() - c_out_size = ctypes.c_size_t(0) - - lib.inflate_paths(c_path, c_size, c_delta, c_jt, c_et, c_miter_limit, c_precision, c_arc_tolerance, ctypes.byref(c_out_path), ctypes.byref(c_out_size)) - if c_out_size.value == 0: - lib.free_paths(ctypes.byref(c_out_path)) + offset = pyclipper.PyclipperOffset(miter_limit=2.0) + offset.AddPath(path, pyclipper.JT_SQUARE, pyclipper.ET_CLOSEDPOLYGON) + solutions = offset.Execute(round(delta * scale)) + if len(solutions) == 0: return [] - - # TODO: simplify_paths :) result: list[tuple[float, float]] = [] - for i in range(0, c_out_size.value, 2): - result.append((c_out_path[i], c_out_path[i + 1])) + for x, y in solutions[0]: + result.append((x / scale, y / scale)) result.append(result[0]) - - lib.free_paths(ctypes.byref(c_out_path)) return result diff --git a/app/native/wndb/s33_dma_cal.py b/app/native/wndb/s33_dma_cal.py index e2d7ae9..5da2ea6 100644 --- a/app/native/wndb/s33_dma_cal.py +++ b/app/native/wndb/s33_dma_cal.py @@ -32,6 +32,13 @@ print(nodes_part_1) def calculate_district_metering_area_for_nodes(name: str, nodes: list[str], part_count: int = 1, part_type: int = PARTITION_TYPE_RB) -> list[list[str]]: + if part_type != PARTITION_TYPE_RB and part_type != PARTITION_TYPE_KWAY: + return [] + if part_count <= 0: + return [] + elif part_count == 1: + return [nodes] + topology = Topology(name, nodes) t_nodes = topology.nodes() t_links = topology.links() @@ -52,7 +59,16 @@ def calculate_district_metering_area_for_nodes(name: str, nodes: list[str], part adjacency_list.append(np.array(a_nodes)) recursive = part_type == PARTITION_TYPE_RB - n_cuts, membership = pymetis.part_graph(nparts=part_count, adjacency=adjacency_list, recursive=recursive, contiguous=True) + options = pymetis.Options() + options.set_defaults() + options._set(pymetis.OptionKey.CONTIG, 1) + options._set(pymetis.OptionKey.SEED, 0) + n_cuts, membership = pymetis.part_graph( + nparts=part_count, + adjacency=adjacency_list, + recursive=recursive, + options=options, + ) result: list[list[str]] = [] for i in range(0, part_count): diff --git a/app/native/wndb/s34_sa_cal.py b/app/native/wndb/s34_sa_cal.py index 025b4c1..158a350 100644 --- a/app/native/wndb/s34_sa_cal.py +++ b/app/native/wndb/s34_sa_cal.py @@ -1,97 +1,96 @@ import os -import ctypes -from .project import have_project -from .inp_out import dump_inp - -def calculate_service_area(name: str) -> list[dict[str, list[str]]]: - if not have_project(name): - raise Exception(f'Not found project [{name}]') - - dir = os.path.abspath(os.getcwd()) - - inp_str = os.path.join(os.path.join(dir, 'db_inp'), name + '.db.inp') - dump_inp(name, inp_str, '2') - - toolkit = ctypes.CDLL(os.path.join(os.path.join(dir, 'api'), 'toolkit.dll')) - - inp = ctypes.c_char_p(inp_str.encode()) - - handle = ctypes.c_ulonglong() - toolkit.TK_ServiceArea_Start(inp, ctypes.byref(handle)) - - c_nodeCount = ctypes.c_size_t() - toolkit.TK_ServiceArea_GetNodeCount(handle, ctypes.byref(c_nodeCount)) - nodeCount = c_nodeCount.value - - nodeIds: list[str] = [] - - for n in range(0, nodeCount): - id = ctypes.c_char_p() - toolkit.TK_ServiceArea_GetNodeId(handle, ctypes.c_size_t(n), ctypes.byref(id)) - nodeIds.append(id.value.decode()) - - c_timeCount = ctypes.c_size_t() - toolkit.TK_ServiceArea_GetTimeCount(handle, ctypes.byref(c_timeCount)) - timeCount = c_timeCount.value - - results: list[dict[str, list[str]]] = [] - - for t in range(0, timeCount): - c_sourceCount = ctypes.c_size_t() - toolkit.TK_ServiceArea_GetSourceCount(handle, ctypes.c_size_t(t), ctypes.byref(c_sourceCount)) - sourceCount = c_sourceCount.value - - sources = ctypes.POINTER(ctypes.c_size_t)() - toolkit.TK_ServiceArea_GetSources(handle, ctypes.c_size_t(t), ctypes.byref(sources)) - - result: dict[str, list[str]] = {} - for s in range(0, sourceCount): - result[nodeIds[sources[s]]] = [] - - for n in range(0, nodeCount): - concentration = ctypes.POINTER(ctypes.c_double)() - toolkit.TK_ServiceArea_GetConcentration(handle, ctypes.c_size_t(t), ctypes.c_size_t(n), ctypes.byref(concentration)) - - maxS = sources[0] - maxC = concentration[0] - for s in range(1, sourceCount): - if concentration[s] > maxC: - maxS = sources[s] - maxC = concentration[s] - - result[nodeIds[maxS]].append(nodeIds[n]) - - results.append(result) - - toolkit.TK_ServiceArea_End(handle) - - return results - -''' -import sys -import json +import platform +import subprocess +import uuid from queue import Queue -from .database import * -from .s0_base import get_node_links, get_link_nodes +from typing import Any -sys.path.append('..') -from app.infra.epanet.epanet import run_project +from app.infra.epanet.epanet import Output -def _calculate_service_area(name: str, inp, time_index: int = 0) -> dict[str, list[str]]: - sources : dict[str, list[str]] = {} - for node_result in inp['node_results']: +from .inp_out import dump_inp +from .project import have_project +from .s0_base import get_link_nodes, get_node_links +from .s23_options_util import get_option_v3 + + +def _update_section(lines: list[str], section: str, transform) -> list[str]: + result: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.strip() == f'[{section}]': + result.append(line) + i += 1 + section_lines: list[str] = [] + while i < len(lines) and not lines[i].startswith('['): + section_lines.append(lines[i]) + i += 1 + result.extend(transform(section_lines)) + continue + result.append(line) + i += 1 + return result + + +def _build_service_area_input(name: str, inp_path: str) -> None: + dump_inp(name, inp_path, '2') + + with open(inp_path, encoding='utf-8') as file: + lines = file.read().splitlines() + + unbalanced = get_option_v3(name).get('IF_UNBALANCED', '').strip() + if unbalanced != '': + lines = _update_section( + lines, + 'OPTIONS', + lambda option_lines: [ + f'UNBALANCED {unbalanced}' if line.startswith('UNBALANCED ') else line + for line in option_lines + ], + ) + + with open(inp_path, mode='w', encoding='utf-8') as file: + file.write('\n'.join(lines) + '\n') + + +def _run_epanet_output(inp_path: str, rpt_path: str, out_path: str) -> dict[str, Any]: + epanet_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'infra', 'epanet')) + if platform.system() == 'Windows': + exe = os.path.join(epanet_dir, 'windows', 'runepanet.exe') + else: + exe = os.path.join(epanet_dir, 'linux', 'runepanet') + if not os.access(exe, os.X_OK): + os.chmod(exe, 0o755) + + env = os.environ.copy() + if platform.system() == 'Linux': + lib_dir = os.path.dirname(exe) + env['LD_LIBRARY_PATH'] = f"{lib_dir}:{env.get('LD_LIBRARY_PATH', '')}" + + process = subprocess.run([exe, inp_path, rpt_path, out_path], env=env, capture_output=True, text=True) + if process.returncode != 0: + raise RuntimeError( + f'EPANET failed for [{inp_path}] with code {process.returncode}: ' + f'stdout={process.stdout} stderr={process.stderr}' + ) + + return Output(out_path).dump() + + +def _calculate_service_area(name: str, output: dict[str, Any], time_index: int) -> dict[str, list[str]]: + sources: dict[str, list[str]] = {} + for node_result in output['node_results']: result = node_result['result'][time_index] if result['demand'] < 0: sources[node_result['node']] = [] link_flows: dict[str, float] = {} - for link_result in inp['link_results']: + for link_result in output['link_results']: result = link_result['result'][time_index] link_flows[link_result['link']] = float(result['flow']) - # build source to nodes map for source in sources: - queue = Queue() + queue: Queue[str] = Queue() queue.put(source) while not queue.empty(): @@ -107,9 +106,6 @@ def _calculate_service_area(name: str, inp, time_index: int = 0) -> dict[str, li elif node2 == cursor and link_flows[link] < 0: queue.put(node1) - #return sources - - # calculation concentration concentration_map: dict[str, dict[str, float]] = {} node_wip: list[str] = [] for source, nodes in sources.items(): @@ -120,17 +116,15 @@ def _calculate_service_area(name: str, inp, time_index: int = 0) -> dict[str, li if node not in node_wip: node_wip.append(node) - # if only one source, done for node, concentrations in concentration_map.items(): if len(concentrations) == 1: node_wip.remove(node) - for key in concentrations.keys(): - concentration_map[node][key] = 1.0 + for source in concentrations.keys(): + concentration_map[node][source] = 1.0 - node_upstream : dict[str, list[tuple[str, str]]] = {} + node_upstream: dict[str, list[tuple[str, str]]] = {} for node in node_wip: - if node not in node_upstream: - node_upstream[node] = [] + node_upstream[node] = [] links = get_node_links(name, node) for link in links: @@ -141,7 +135,7 @@ def _calculate_service_area(name: str, inp, time_index: int = 0) -> dict[str, li node_upstream[node].append((link, node2)) while len(node_wip) != 0: - done = [] + done: list[str] = [] for node in node_wip: up_link_nodes = node_upstream[node] ready = True @@ -149,33 +143,38 @@ def _calculate_service_area(name: str, inp, time_index: int = 0) -> dict[str, li if link_node[1] in node_wip: ready = False break - if ready: - for link_node in up_link_nodes: - if link_node[1] not in concentration_map.keys(): - continue - for source, concentration in concentration_map[link_node[1]].items(): - concentration_map[node][source] += concentration * abs(link_flows[link_node[0]]) + if not ready: + continue - # normalize - sum = 0.0 - for source, concentration in concentration_map[node].items(): - sum += concentration - for source in concentration_map[node].keys(): - concentration_map[node][source] /= sum + for link, upstream_node in up_link_nodes: + if upstream_node not in concentration_map: + continue + for source, concentration in concentration_map[upstream_node].items(): + concentration_map[node][source] += concentration * abs(link_flows[link]) - done.append(node) + total_concentration = sum(concentration_map[node].values()) + if total_concentration == 0: + raise RuntimeError(f'Failed to normalize service area concentration for node [{node}] at time [{time_index}]') + + for source in concentration_map[node].keys(): + concentration_map[node][source] /= total_concentration + + done.append(node) + + if len(done) == 0: + raise RuntimeError(f'Failed to resolve service area graph for time [{time_index}]') for node in done: node_wip.remove(node) source_to_main_node: dict[str, list[str]] = {} - for node, value in concentration_map.items(): + for node, concentrations in concentration_map.items(): max_source = '' max_concentration = 0.0 - for s, c in value.items(): - if c > max_concentration: - max_concentration = c - max_source = s + for source, concentration in concentrations.items(): + if concentration > max_concentration: + max_concentration = concentration + max_source = source if max_source not in source_to_main_node: source_to_main_node[max_source] = [] source_to_main_node[max_source].append(node) @@ -184,15 +183,28 @@ def _calculate_service_area(name: str, inp, time_index: int = 0) -> dict[str, li def calculate_service_area(name: str) -> list[dict[str, list[str]]]: - inp = json.loads(run_project(name, True)) + if not have_project(name): + raise Exception(f'Not found project [{name}]') - result: list[dict[str, list[str]]] = [] + root = os.path.abspath(os.getcwd()) + token = f'{os.getpid()}_{uuid.uuid4().hex}' + inp_path = os.path.join(root, 'db_inp', f'{name}.service_area.{token}.inp') + rpt_path = os.path.join(root, 'temp', f'{name}.service_area.{token}.rpt') + out_path = os.path.join(root, 'temp', f'{name}.service_area.{token}.opt') - time_count = len(inp['node_results'][0]['result']) + os.makedirs(os.path.dirname(inp_path), exist_ok=True) + os.makedirs(os.path.dirname(rpt_path), exist_ok=True) - for i in range(time_count): - sas = _calculate_service_area(name, inp, i) - result.append(sas) + try: + _build_service_area_input(name, inp_path) + output = _run_epanet_output(inp_path, rpt_path, out_path) - return result -''' + results: list[dict[str, list[str]]] = [] + time_count = len(output['node_results'][0]['result']) + for time_index in range(time_count): + results.append(_calculate_service_area(name, output, time_index)) + return results + finally: + for path in (inp_path, rpt_path, out_path): + if os.path.exists(path): + os.remove(path) From c2ccb7bc4e3c05a4bd69ce3ef5735bbc8f5a35d9 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 26 May 2026 18:49:25 +0800 Subject: [PATCH 17/93] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=92=8C=E4=BB=BF=E7=9C=9F=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_cli_endpoint_scope.md | 424 +++++++++++++++++++++++++++++ app/api/v1/endpoints/misc.py | 22 -- app/api/v1/endpoints/simulation.py | 96 ------- 3 files changed, 424 insertions(+), 118 deletions(-) create mode 100644 agent_cli_endpoint_scope.md diff --git a/agent_cli_endpoint_scope.md b/agent_cli_endpoint_scope.md new file mode 100644 index 0000000..8df0f46 --- /dev/null +++ b/agent_cli_endpoint_scope.md @@ -0,0 +1,424 @@ +# Agent CLI 接口范围确认 + +本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。 + +## 结论 + +首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 + +```text +tjwater auth +tjwater project +tjwater network +tjwater component +tjwater simulation +tjwater analysis +tjwater data +tjwater help +tjwater result +``` + +首批默认不暴露: + +- 会修改 network 的接口:`add*`、`set*`、`delete*`、`generate*` +- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制 +- 数据写入/清理接口:insert、update、delete、clean、clear、batch store +- 用户管理接口:创建、更新、删除、激活、停用 +- 快照回滚和批量命令执行接口:undo、redo、pick、batch + +## 设计原则 + +- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。 +- 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。 +- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。 +- CLI 输出统一使用 JSON;大结果写入 result-ref,只在 stdout 返回摘要、路径和元数据。 +- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。 +- 高频命令可以提供 alias,但文档和 skill 只写规范命令。 + +## 分级约束 + +| 顶层命令 | 二级范围 | 说明 | +|---|---|---| +| `auth` | `me`、`refresh` | 登录态和当前用户 | +| `project` | `list`、`info`、`status`、`export-inp`、`data` | 项目发现和只读项目数据 | +| `network` | `list`、`get`、`schema`、`exists`、`geometry`、`region`、`tag` | 管网拓扑、元素、几何、分区,只读 | +| `component` | `curve`、`pattern`、`option`、`control`、`quality`、`visual` | EPANET 组件类能力 | +| `simulation` | `run`、`run-inp`、`output` | 模拟运行和模拟输出 | +| `analysis` | `burst`、`leakage`、`valve`、`flushing`、`age`、`sensor-placement`、`risk` | 任务级分析 | +| `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 | +| `help` | `--json`、`COMMAND --json` | Agent 能力发现和命令说明 | +| `result` | `show`、`metadata`、`export` | `result-ref` 读取和导出 | + +命令深度建议: + +- 常规命令不超过 3 层:`tjwater component curve list` +- 时序数据允许 4 层:`tjwater data timeseries realtime links` +- `risk` 归入 `analysis risk` +- `scada`、`scheme`、`extension` 归入 `data` + +## 首批 CLI 范围 + +### Auth / Project + +来源: + +```text +app/api/v1/endpoints/auth.py +app/api/v1/endpoints/meta.py +app/api/v1/endpoints/project.py +app/api/v1/endpoints/project_data.py +``` + +| 命令 | 覆盖接口 | 说明 | +|---|---|---| +| `tjwater auth me` | `GET /auth/me` | 当前登录用户 | +| `tjwater auth refresh` | `POST /auth/refresh` | 仅在 CLI 需要维护登录态时暴露 | +| `tjwater project list` | `GET /meta/projects` | 项目列表 | +| `tjwater project info --project PROJECT` | `GET /meta/project` | 项目信息 | +| `tjwater project db-health --project PROJECT` | `GET /meta/db/health` | 项目数据库健康 | +| `tjwater project export-inp --project PROJECT --out-ref` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出 INP,写 `result-ref` | +| `tjwater project data --project PROJECT --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 项目业务数据 | + +暂不暴露: + +```text +POST /auth/register +POST /auth/login +POST /auth/login/simple +GET /listprojects/ +GET /project_info/ +GET /haveproject/ +GET /isprojectopen/ +GET /isprojectlocked/ +GET /isprojectlockedbyme/ +POST /createproject/ +POST /deleteproject/ +POST /openproject/ +POST /closeproject/ +POST /copyproject/ +POST /importinp/ +POST /readinp/ +POST /lockproject/ +POST /unlockproject/ +POST /uploadinp/ +GET /convertv3tov2/ +``` + +### Network + +来源: + +```text +app/api/v1/endpoints/network/*.py +``` + +| 命令 | 覆盖接口 | 说明 | +|---|---|---| +| `tjwater network list --network NET --type nodes\|links` | `GET /getnodes/`、`GET /getlinks/` | 节点/管线 ID 列表 | +| `tjwater network exists --network NET --type node\|link\|junction\|pipe\|... --id ID` | `GET /isnode/`、`GET /islink/` 等 | 元素存在性 | +| `tjwater network type --network NET --id ID` | `GET /getnodetype/`、`GET /getlinktype/`、`GET /getelementtype/` | 元素类型 | +| `tjwater network get --network NET --id ID` | `GET /getelementproperties/`、`GET /getnodeproperties/`、`GET /getlinkproperties/` | 自动识别类型并取属性 | +| `tjwater network get --network NET --type junction\|pipe\|pump\|... --id ID` | 各类 `get*properties` | 指定类型取属性 | +| `tjwater network list-properties --network NET --type junction\|pipe\|pump\|... --out-ref` | 各类 `getall*properties` | 全量属性,写 `result-ref` | +| `tjwater network schema --network NET --type junction\|reservoir\|tank\|pipe\|pump\|valve\|demand\|tag\|region` | 各类 `get*schema` | 属性架构 | +| `tjwater network links-of-node --network NET --node NODE` | `GET /getnodelinks/` | 节点关联管线 | +| `tjwater network geometry --network NET --scope full\|extent\|major-nodes\|major-pipes\|link-nodes --out-ref` | `geometry.py` 下 `GET` 接口 | 几何数据 | +| `tjwater network demand-calc --network NET --scope node\|region\|network --out-ref` | `GET /calculatedemandto*/` | 需水量计算 | +| `tjwater network region get\|list\|schema --network NET --kind dma\|service-area\|virtual-district` | `regions.py` 下 `GET` 查询接口 | 分区信息 | +| `tjwater network region-calc --network NET --kind dma\|service-area\|virtual-district --out-ref` | `GET /calculate*/` | 分区计算 | +| `tjwater network tag get\|list\|schema --network NET` | `GET /gettag/`、`GET /gettags/`、`GET /gettagschema/` | 标签信息 | + +暂不暴露: + +```text +add* +set* +delete* +generate* +POST /generatedistrictmeteringarea/ +POST /generatesubdistrictmeteringarea/ +POST /generateservicearea/ +POST /generatevirtualdistrict/ +``` + +备注:`GET /settitle/` 语义是修改标题,首批不暴露。 + +### Component + +来源: + +```text +app/api/v1/endpoints/components/*.py +``` + +| 命令 | 覆盖接口 | 说明 | +|---|---|---| +| `tjwater component curve schema\|list\|get\|exists` | `curves.py` 下只读接口 | 曲线 | +| `tjwater component pattern schema\|list\|get\|exists` | `patterns.py` 下只读接口 | 模式 | +| `tjwater component option schema\|get --kind time\|energy\|pump-energy\|general` | `options.py` 下只读接口 | 时间、能耗、泵能耗、通用选项 | +| `tjwater component control schema\|get --kind control\|rule` | `controls.py` 下只读接口 | 控制和规则 | +| `tjwater component quality schema\|get --kind quality\|emitter\|source\|reaction\|pipe-reaction\|tank-reaction\|mixing` | `quality.py` 下只读接口 | 水质相关组件 | +| `tjwater component visual schema\|list\|get --kind vertex\|label\|backdrop\|vertex-links\|vertices` | `visuals.py` 下只读接口 | 图形元素、标签、背景 | + +暂不暴露: + +```text +POST /addcurve/ +POST /setcurveproperties/ +POST /deletecurve/ +POST /addpattern/ +POST /setpatternproperties/ +POST /deletepattern/ +POST /settimeproperties/ +POST /setenergyproperties/ +GET /setpumpenergyproperties// +POST /setoptionproperties/ +POST /setcontrolproperties/ +POST /setruleproperties/ +POST /setqualityproperties/ +POST /setemitterproperties/ +POST /setsource/ +POST /addsource/ +POST /deletesource/ +POST /setreaction/ +POST /setpipereaction/ +POST /settankreaction/ +POST /setmixing/ +POST /addmixing/ +POST /deletemixing/ +POST /setvertexproperties/ +POST /addvertex/ +POST /deletevertex/ +POST /setlabelproperties/ +POST /addlabel/ +POST /deletelabel/ +POST /setbackdropproperties/ +``` + +备注: + +- `getsourcechema` 路径拼写疑似错误,CLI 统一使用 `component quality schema --kind source`。 +- `getallvertexlinks`、`getallvertices` 当前返回 JSON 字符串,CLI 应输出标准 JSON。 + +### Simulation / Analysis / Risk + +来源: + +```text +app/api/v1/endpoints/simulation.py +app/api/v1/endpoints/leakage.py +app/api/v1/endpoints/burst_detection.py +app/api/v1/endpoints/burst_location.py +app/api/v1/endpoints/risk.py +``` + +| 命令 | 覆盖接口 | 说明 | +|---|---|---| +| `tjwater simulation run --project PROJECT --out-ref` | `GET /runprojectreturndict/` | 运行项目模拟,使用结构化 JSON 返回 | +| `tjwater simulation run-inp --inp PATH --out-ref` | `GET /runinp/` | 运行 INP | +| `tjwater simulation output --project PROJECT --out-ref` | `GET /dumpoutput/` | 导出模拟输出 | +| `tjwater analysis burst --project PROJECT --start-time TIME --duration SEC --burst ID:SIZE --out-ref` | `GET /burst_analysis/` | 爆管分析,`--burst` 可重复 | +| `tjwater analysis valve --project PROJECT --mode close\|isolation --start-time TIME --valve VALVE --out-ref` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | +| `tjwater analysis flushing --project PROJECT --start-time TIME --valve VALVE:OPENING --drainage-node NODE --flow FLOW --out-ref` | `GET /flushing_analysis/` | 冲洗分析,`--valve` 可重复 | +| `tjwater analysis age --project PROJECT --start-time TIME --duration SEC --out-ref` | `GET /age_analysis/` | 水龄分析 | +| `tjwater analysis contaminant --project PROJECT --start-time TIME --duration SEC --source NODE:VALUE --out-ref` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater analysis sensor-placement --project PROJECT --method sensitivity\|kmeans --count N --out-ref` | 传感器放置分析接口 | 不包含创建方案 | +| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /leakage/identify/` | 漏损识别 | +| `tjwater analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | +| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /burst-detection/detect/` | 爆管检测 | +| `tjwater analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | +| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /burst-location/locate/` | 爆管定位 | +| `tjwater analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | +| `tjwater analysis risk pipe --network NET --pipe PIPE --time-range ...` | `risk.py` 下管道风险 `GET` 接口 | 管道风险 | +| `tjwater analysis risk network --network NET --out-ref` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 全网风险 | + +暂缓或暂不暴露: + +```text +POST /network_project/ +GET /runproject/ +POST /network_update/ +POST /project_management/ +POST /sensorplacementscheme/create +POST /runsimulationmanuallybydate/ +POST /pump_failure/ +POST /pressure_regulation/ +POST /scheduling_analysis/ +POST /daily_scheduling_analysis/ +``` + +### Data + +来源: + +```text +app/api/v1/endpoints/timeseries/*.py +app/api/v1/endpoints/scada.py +app/api/v1/endpoints/schemes.py +app/api/v1/endpoints/extension.py +app/api/v1/endpoints/misc.py +app/api/v1/endpoints/project_data.py +``` + +| 命令 | 覆盖接口 | 说明 | +|---|---|---| +| `tjwater data timeseries realtime links --start-time TIME --end-time TIME --out-ref` | `GET /realtime/links` | 实时管道数据 | +| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME --out-ref` | `GET /realtime/nodes` | 实时节点数据 | +| `tjwater data timeseries realtime simulation --query by-id-time\|by-time-property --id ID --time TIME --property PROPERTY --out-ref` | `GET /realtime/query/*` | 实时模拟查询 | +| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | +| `tjwater data timeseries scheme node-field --node NODE --field FIELD --out-ref` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | +| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY --out-ref` | `GET /scheme/query/*` | 方案模拟查询 | +| `tjwater data timeseries scada query --device-ids ... --time-range ... --out-ref` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序 | +| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME --out-ref` | `GET /composite/*` | 复合查询,`--feature` 可重复 | +| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME --out-ref` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | +| `tjwater data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | +| `tjwater data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | +| `tjwater data scheme schema\|get\|list --network NET` | `schemes.py` 下 `GET` 接口 | 方案查询 | +| `tjwater data extension keys\|get\|list --network NET` | `extension.py` 下 `GET` 查询接口 | 扩展数据查询 | +| `tjwater data misc sensor-placements --network NET --out-ref` | `GET /getallsensorplacements/` | 传感器位置 | +| `tjwater data misc burst-location-results --network NET --out-ref` | `GET /getallburstlocateresults/` | 爆管定位结果 | + +暂不暴露: + +```text +POST /realtime/*/batch +DELETE /realtime/* +PATCH /realtime/* +POST /realtime/simulation/store +POST /scheme/*/batch +PATCH /scheme/* +DELETE /scheme/* +POST /scheme/simulation/store +POST /scada/batch +PATCH /scada/{device_id}/field +DELETE /scada/by-id-time-range +POST /composite/clean-scada +POST /setscadadevice/ +POST /addscadadevice/ +POST /deletescadadevice/ +POST /cleanscadadevice/ +POST /setscadadevicedata/ +POST /addscadadevicedata/ +POST /deletescadadevicedata/ +POST /cleanscadadevicedata/ +POST /setscadaelement/ +POST /addscadaelement/ +POST /deletescadaelement/ +POST /cleanscadaelement/ +POST /setextensiondata/ +POST /test_dict/ +GET /getjson/ +``` + +### 不纳入首批 CLI 的运维接口 + +来源: + +```text +app/api/v1/endpoints/snapshots.py +app/api/v1/endpoints/cache.py +app/api/v1/endpoints/audit.py +app/api/v1/endpoints/users.py +app/api/v1/endpoints/user_management.py +``` + +这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计、用户管理或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。 + +暂不暴露: + +```text +GET /getcurrentoperationid/ +GET /getsnapshots/ +GET /havesnapshot/ +GET /havesnapshotforoperation/ +GET /havesnapshotforcurrentoperation/ +GET /getrestoreoperation/ +POST /undo/ +POST /redo/ +POST /takesnapshot*/ +POST /picksnapshot/ +POST /pickoperation/ +GET /syncwithserver/ +POST /batch/ +POST /compressedbatch/ +POST /setrestoreoperation/ +GET /queryredis/ +POST /clearrediskey/ +POST /clearrediskeys/ +POST /clearallredis/ +GET /audit/logs +GET /audit/logs/my +GET /audit/logs/count +GET /getuserschema/ +GET /getuser/ +GET /getallusers/ +PUT /users/{user_id} +DELETE /users/{user_id} +POST /users/{user_id}/activate +POST /users/{user_id}/deactivate +``` + +## Help / Result + +这两个模块不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。 + +| 命令 | 说明 | +|---|---| +| `tjwater help --json` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | +| `tjwater help COMMAND --json` | 返回某个命令的参数、输出、示例和推荐后续命令 | +| `tjwater result show REF` | 读取 `result-ref` 内容,必要时分页或摘要 | +| `tjwater result metadata REF` | 读取 `result-ref` 元数据 | +| `tjwater result export REF --format json\|csv` | 导出结果 | + +## 输出规范 + +成功: + +```json +{ + "ok": true, + "summary": "读取成功", + "data": {}, + "result_ref": null, + "metadata": {}, + "next_commands": [] +} +``` + +失败: + +```json +{ + "ok": false, + "error": "invalid_argument", + "message": "缺少必要参数 --network", + "recoverable": true, + "suggested_command": "tjwater component curve list --network NET" +} +``` + +大结果: + +```json +{ + "ok": true, + "summary": "查询完成,结果已写入 result-ref", + "result_ref": "TJWaterAgent/data/result-refs/example.json", + "metadata": { + "schema": "network_properties_v1", + "rows": 1200 + } +} +``` + +## 后续开放条件 + +如后续要开放写操作,需要单独设计: + +- 权限校验 +- dry-run / preview +- 显式确认机制 +- 审计日志 +- 变更快照 +- 回滚策略 +- Agent 可读的错误恢复建议 diff --git a/app/api/v1/endpoints/misc.py b/app/api/v1/endpoints/misc.py index 1ebb083..248032f 100644 --- a/app/api/v1/endpoints/misc.py +++ b/app/api/v1/endpoints/misc.py @@ -1,5 +1,4 @@ from typing import Any -import random from fastapi import APIRouter, Query from fastapi.responses import JSONResponse from fastapi import status @@ -63,24 +62,3 @@ async def fastapi_test_dict(data: Item) -> dict[str, str]: """ item = data.dict() return item - -@router.get("/getrealtimedata/", summary="获取实时数据", description="获取实时监测数据") -async def fastapi_get_realtimedata(): - """ - 获取实时数据 - - 返回随机生成的实时监测数据示例 - """ - data = [random.randint(0, 100) for _ in range(100)] - return data - - -@router.get("/getsimulationresult/", summary="获取模拟结果", description="获取仿真计算结果") -async def fastapi_get_simulationresult(): - """ - 获取仿真结果 - - 返回随机生成的仿真计算结果示例 - """ - data = [random.randint(0, 100) for _ in range(100)] - return data diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 25d4e83..3ce8a2d 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -195,26 +195,6 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输 # Analysis Endpoints -@router.get("/burstanalysis/", summary="爆管分析(基础)", description="对管网中的爆管事件进行分析,包括爆管对管网压力和流量的影响。此为基础版本,接收简化的查询参数。") -async def burst_analysis_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - pipe_id: str = Query(..., description="管段ID"), - start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"), - end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"), - burst_flow: float = Query(..., description="爆管流量大小(L/s)"), -): - """ - 爆管分析(基础版本) - - - **network**: 管网名称(或数据库名称) - - **pipe_id**: 管段ID - - **start_time**: 分析开始时间 - - **end_time**: 分析结束时间 - - **burst_flow**: 爆管流量大小 - """ - return burst_analysis(network, pipe_id, start_time, end_time, burst_flow) - - @router.get("/burst_analysis/", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") async def fastapi_burst_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), @@ -247,24 +227,6 @@ async def fastapi_burst_analysis( return "success" -@router.get("/valvecloseanalysis/", summary="阀门关闭分析(基础)", description="对管网中的阀门关闭事件进行分析,评估关闭阀门对管网的影响。此为基础版本。") -async def valve_close_analysis_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - valve_id: str = Query(..., description="阀门ID"), - start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"), - end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"), -): - """ - 阀门关闭分析(基础版本) - - - **network**: 管网名称(或数据库名称) - - **valve_id**: 阀门ID - - **start_time**: 分析开始时间 - - **end_time**: 分析结束时间 - """ - return valve_close_analysis(network, valve_id, start_time, end_time) - - @router.get("/valve_close_analysis/", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。") async def fastapi_valve_close_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), @@ -331,26 +293,6 @@ async def valve_isolation_endpoint( return result -@router.get("/flushinganalysis/", summary="冲洗分析(基础)", description="对管网的冲洗操作进行分析,评估冲洗流量和持续时间对管网的影响。此为基础版本。") -async def flushing_analysis_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)"), - pipe_id: str = Query(..., description="要冲洗的管段ID"), - start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"), - duration: float = Query(..., description="冲洗持续时间(分钟)"), - flow: float = Query(..., description="冲洗流量(L/s)"), -): - """ - 冲洗分析(基础版本) - - - **network**: 管网名称(或数据库名称) - - **pipe_id**: 要冲洗的管段ID - - **start_time**: 冲洗开始时间 - - **duration**: 冲洗持续时间(分钟) - - **flow**: 冲洗流量(L/s) - """ - return flushing_analysis(network, pipe_id, start_time, duration, flow) - - @router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") async def fastapi_flushing_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), @@ -426,23 +368,10 @@ async def fastapi_contaminant_simulation( return result or "success" -@router.get("/ageanalysis/", summary="水龄分析(基础)", description="对管网中的水体停留时间(水龄)进行分析。此为基础版本。") -async def age_analysis_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): - """ - 水龄分析(基础版本) - - - **network**: 管网名称(或数据库名称) - - 分析管网中各节点的水体停留时间。 - """ - return age_analysis(network) - - @router.get("/age_analysis/", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。") async def fastapi_age_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"), - end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"), duration: int = Query(..., description="模拟持续时间(秒)"), ) -> str: """ @@ -450,7 +379,6 @@ async def fastapi_age_analysis( - **network**: 管网名称(或数据库名称) - **start_time**: 分析开始时间 - - **end_time**: 分析结束时间(可选) - **duration**: 模拟持续时间(秒) 分析指定时间段内管网中各节点的水体停留时间。 @@ -520,18 +448,6 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr return "success" -@router.get("/projectmanagement/", summary="项目管理(基础)", description="对管网项目进行基础的管理操作。此为基础版本。") -async def project_management_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): - """ - 项目管理(基础版本) - - - **network**: 管网名称(或数据库名称) - - 进行基础的项目管理操作。 - """ - return project_management(network) - - @router.post("/project_management/", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。") async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str: """ @@ -633,18 +549,6 @@ async def fastapi_network_project(file: UploadFile = File(..., description="INP return run_inp(temp_file_name) -@router.get("/networkupdate/", summary="管网更新(基础)", description="对指定管网项目进行基础的更新操作。此为基础版本。") -async def network_update_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): - """ - 管网更新(基础版本) - - - **network**: 管网名称(或数据库名称) - - 进行管网的基础更新操作。 - """ - return network_update(network) - - @router.post("/network_update/", summary="管网更新(高级)", description="通过上传更新文件对管网进行高级的更新操作。系统将处理更新文件并应用到数据库。") async def fastapi_network_update(file: UploadFile = File(..., description="包含管网更新信息的文件")) -> str: """ From b72e42521c87b6ce6332f063b46ca14330cbb09c Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 1 Jun 2026 16:46:51 +0800 Subject: [PATCH 18/93] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E6=9F=A5=E8=AF=A2=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20UTC=20=E6=97=B6=E9=97=B4=E6=A0=87=E5=87=86=E5=8C=96=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/timeseries/realtime.py | 70 +++++++---- .../db/timescaledb/repositories/realtime.py | 8 +- tests/unit/test_realtime_repository.py | 110 ++++++++++++++++++ 3 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_realtime_repository.py diff --git a/app/api/v1/endpoints/timeseries/realtime.py b/app/api/v1/endpoints/timeseries/realtime.py index 5725eb6..eb6ee8b 100644 --- a/app/api/v1/endpoints/timeseries/realtime.py +++ b/app/api/v1/endpoints/timeseries/realtime.py @@ -8,6 +8,10 @@ from .dependencies import get_timescale_connection router = APIRouter() +TIME_WITH_TZ_DESC = "ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。" +TIME_RANGE_START_DESC = f"时间范围开始时间。{TIME_WITH_TZ_DESC}" +TIME_RANGE_END_DESC = f"时间范围结束时间。{TIME_WITH_TZ_DESC}" + @router.post("/realtime/links/batch", status_code=201, summary="批量插入实时管道数据") async def insert_realtime_links( @@ -29,16 +33,21 @@ async def insert_realtime_links( return {"message": f"Inserted {len(data)} records"} -@router.get("/realtime/links", summary="查询实时管道数据") +@router.get( + "/realtime/links", + summary="查询实时管道数据", + description="按时间范围查询实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", +) async def get_realtime_links( - start_time: datetime = Query(..., description="查询开始时间"), - end_time: datetime = Query(..., description="查询结束时间"), + start_time: datetime = Query(..., description=TIME_RANGE_START_DESC), + end_time: datetime = Query(..., description=TIME_RANGE_END_DESC), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 查询指定时间范围内的实时管道数据 - 根据时间范围查询所有实时管道的监测值。 + 根据时间范围查询所有实时管道的监测值。传入时间必须显式包含时区, + 可以直接使用 UTC+8,服务端会先统一转换为 UTC 再参与数据库查询。 Args: start_time: 查询开始时间 @@ -50,10 +59,14 @@ async def get_realtime_links( return await RealtimeRepository.get_links_by_time_range(conn, start_time, end_time) -@router.delete("/realtime/links", summary="删除实时管道数据") +@router.delete( + "/realtime/links", + summary="删除实时管道数据", + description="按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", +) async def delete_realtime_links( - start_time: datetime = Query(..., description="删除开始时间"), - end_time: datetime = Query(..., description="删除结束时间"), + start_time: datetime = Query(..., description=TIME_RANGE_START_DESC), + end_time: datetime = Query(..., description=TIME_RANGE_END_DESC), conn: AsyncConnection = Depends(get_timescale_connection), ): """ @@ -75,7 +88,7 @@ async def delete_realtime_links( @router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段") async def update_realtime_link_field( link_id: str = Path(..., description="管道ID"), - time: datetime = Query(..., description="更新数据的时间戳"), + time: datetime = Query(..., description=f"要更新记录的时间戳。{TIME_WITH_TZ_DESC}"), field: str = Query(..., description="要更新的字段名称"), value: float = Query(..., description="更新的字段值"), conn: AsyncConnection = Depends(get_timescale_connection), @@ -124,16 +137,21 @@ async def insert_realtime_nodes( return {"message": f"Inserted {len(data)} records"} -@router.get("/realtime/nodes", summary="查询实时节点数据") +@router.get( + "/realtime/nodes", + summary="查询实时节点数据", + description="按时间范围查询实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", +) async def get_realtime_nodes( - start_time: datetime = Query(..., description="查询开始时间"), - end_time: datetime = Query(..., description="查询结束时间"), + start_time: datetime = Query(..., description=TIME_RANGE_START_DESC), + end_time: datetime = Query(..., description=TIME_RANGE_END_DESC), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 查询指定时间范围内的实时节点数据 - 根据时间范围查询所有实时节点的监测值。 + 根据时间范围查询所有实时节点的监测值。传入时间必须显式包含时区, + 可以直接使用 UTC+8,服务端会先统一转换为 UTC 再参与数据库查询。 Args: start_time: 查询开始时间 @@ -145,10 +163,14 @@ async def get_realtime_nodes( return await RealtimeRepository.get_nodes_by_time_range(conn, start_time, end_time) -@router.delete("/realtime/nodes", summary="删除实时节点数据") +@router.delete( + "/realtime/nodes", + summary="删除实时节点数据", + description="按时间范围删除实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", +) async def delete_realtime_nodes( - start_time: datetime = Query(..., description="删除开始时间"), - end_time: datetime = Query(..., description="删除结束时间"), + start_time: datetime = Query(..., description=TIME_RANGE_START_DESC), + end_time: datetime = Query(..., description=TIME_RANGE_END_DESC), conn: AsyncConnection = Depends(get_timescale_connection), ): """ @@ -173,7 +195,7 @@ async def delete_realtime_nodes( async def store_realtime_simulation_result( node_result_list: List[dict] = Body(..., description="节点模拟结果列表"), link_result_list: List[dict] = Body(..., description="管道模拟结果列表"), - result_start_time: str = Query(..., description="模拟结果开始时间"), + result_start_time: str = Query(..., description=f"模拟结果开始时间。{TIME_WITH_TZ_DESC}"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ @@ -195,9 +217,13 @@ async def store_realtime_simulation_result( return {"message": "Simulation results stored successfully"} -@router.get("/realtime/query/by-time-property", summary="按时间和属性查询实时数据") +@router.get( + "/realtime/query/by-time-property", + summary="按时间和属性查询实时数据", + description="查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", +) async def query_realtime_records_by_time_property( - query_time: str = Query(..., description="查询时间"), + query_time: str = Query(..., description=f"查询时间。{TIME_WITH_TZ_DESC}"), type: str = Query(..., description="数据类型,pipe(管道)或 junction(节点)"), property: str = Query(..., description="要查询的属性名称"), conn: AsyncConnection = Depends(get_timescale_connection), @@ -227,11 +253,15 @@ async def query_realtime_records_by_time_property( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/realtime/query/by-id-time", summary="按ID和时间查询实时模拟数据") +@router.get( + "/realtime/query/by-id-time", + summary="按ID和时间查询实时模拟数据", + description="查询指定元素在某一时间点的实时模拟结果。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", +) async def query_realtime_simulation_by_id_time( id: str = Query(..., description="元素ID(管道ID或节点ID)"), type: str = Query(..., description="元素类型,pipe(管道)或 junction(节点)"), - query_time: str = Query(..., description="查询时间"), + query_time: str = Query(..., description=f"查询时间。{TIME_WITH_TZ_DESC}"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ diff --git a/app/infra/db/timescaledb/repositories/realtime.py b/app/infra/db/timescaledb/repositories/realtime.py index 7c0facb..6b26fa4 100644 --- a/app/infra/db/timescaledb/repositories/realtime.py +++ b/app/infra/db/timescaledb/repositories/realtime.py @@ -100,10 +100,12 @@ class RealtimeRepository: async def get_links_by_time_range( conn: AsyncConnection, start_time: datetime, end_time: datetime ) -> List[dict]: + normalized_start_time = parse_utc_time(start_time, field_name="start_time") + normalized_end_time = parse_utc_time(end_time, field_name="end_time") async with conn.cursor() as cur: await cur.execute( "SELECT * FROM realtime.link_simulation WHERE time >= %s AND time <= %s", - (start_time, end_time), + (normalized_start_time, normalized_end_time), ) return await cur.fetchall() @@ -296,10 +298,12 @@ class RealtimeRepository: async def get_nodes_by_time_range( conn: AsyncConnection, start_time: datetime, end_time: datetime ) -> List[dict]: + normalized_start_time = parse_utc_time(start_time, field_name="start_time") + normalized_end_time = parse_utc_time(end_time, field_name="end_time") async with conn.cursor() as cur: await cur.execute( "SELECT * FROM realtime.node_simulation WHERE time >= %s AND time <= %s", - (start_time, end_time), + (normalized_start_time, normalized_end_time), ) return await cur.fetchall() diff --git a/tests/unit/test_realtime_repository.py b/tests/unit/test_realtime_repository.py new file mode 100644 index 0000000..31f64f6 --- /dev/null +++ b/tests/unit/test_realtime_repository.py @@ -0,0 +1,110 @@ +import asyncio +from datetime import datetime, timezone +import importlib.util +from pathlib import Path +import sys +from types import ModuleType + + +def _load_time_api_module(): + module_path = ( + Path(__file__).resolve().parents[2] / "app" / "services" / "time_api.py" + ) + spec = importlib.util.spec_from_file_location("tests_time_api_under_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def _load_realtime_repository(): + time_api_module = _load_time_api_module() + app_module = ModuleType("app") + services_module = ModuleType("app.services") + services_module.time_api = time_api_module + app_module.services = services_module + sys.modules["app"] = app_module + sys.modules["app.services"] = services_module + sys.modules["app.services.time_api"] = time_api_module + + module_path = ( + Path(__file__).resolve().parents[2] + / "app" + / "infra" + / "db" + / "timescaledb" + / "repositories" + / "realtime.py" + ) + spec = importlib.util.spec_from_file_location( + "tests_realtime_repo_under_test", module_path + ) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module.RealtimeRepository + + +class _FakeCursor: + def __init__(self): + self.calls: list[tuple[str, tuple]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query, params): + self.calls.append((str(query), params)) + + async def fetchall(self): + return [] + + +class _FakeConnection: + def __init__(self): + self.cursor_instance = _FakeCursor() + + def cursor(self): + return self.cursor_instance + + +def test_get_links_by_time_range_normalizes_inputs_to_utc(): + RealtimeRepository = _load_realtime_repository() + conn = _FakeConnection() + + asyncio.run( + RealtimeRepository.get_links_by_time_range( + conn, + datetime.fromisoformat("2026-06-01T08:00:00+08:00"), + datetime.fromisoformat("2026-06-01T09:00:00+08:00"), + ) + ) + + assert len(conn.cursor_instance.calls) == 1 + _, params = conn.cursor_instance.calls[0] + assert params == ( + datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc), + datetime(2026, 6, 1, 1, 0, tzinfo=timezone.utc), + ) + + +def test_get_nodes_by_time_range_normalizes_inputs_to_utc(): + RealtimeRepository = _load_realtime_repository() + conn = _FakeConnection() + + asyncio.run( + RealtimeRepository.get_nodes_by_time_range( + conn, + datetime.fromisoformat("2026-06-01T08:00:00+08:00"), + datetime.fromisoformat("2026-06-01T09:00:00+08:00"), + ) + ) + + assert len(conn.cursor_instance.calls) == 1 + _, params = conn.cursor_instance.calls[0] + assert params == ( + datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc), + datetime(2026, 6, 1, 1, 0, tzinfo=timezone.utc), + ) From 60db2a719348fe0347bff5a692bc0d597df39c2c Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 1 Jun 2026 17:05:26 +0800 Subject: [PATCH 19/93] =?UTF-8?q?=E4=BC=98=E5=8C=96=20cli=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_cli_endpoint_scope.md | 269 ++++++++++++++++++++++++------------ 1 file changed, 180 insertions(+), 89 deletions(-) diff --git a/agent_cli_endpoint_scope.md b/agent_cli_endpoint_scope.md index 8df0f46..0a3d837 100644 --- a/agent_cli_endpoint_scope.md +++ b/agent_cli_endpoint_scope.md @@ -7,7 +7,6 @@ 首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 ```text -tjwater auth tjwater project tjwater network tjwater component @@ -15,7 +14,6 @@ tjwater simulation tjwater analysis tjwater data tjwater help -tjwater result ``` 首批默认不暴露: @@ -31,7 +29,9 @@ tjwater result - CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。 - 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。 - CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。 -- CLI 输出统一使用 JSON;大结果写入 result-ref,只在 stdout 返回摘要、路径和元数据。 +- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。 +- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`。 +- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。 - 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。 - 高频命令可以提供 alias,但文档和 skill 只写规范命令。 @@ -39,26 +39,60 @@ tjwater result | 顶层命令 | 二级范围 | 说明 | |---|---|---| -| `auth` | `me`、`refresh` | 登录态和当前用户 | -| `project` | `list`、`info`、`status`、`export-inp`、`data` | 项目发现和只读项目数据 | -| `network` | `list`、`get`、`schema`、`exists`、`geometry`、`region`、`tag` | 管网拓扑、元素、几何、分区,只读 | -| `component` | `curve`、`pattern`、`option`、`control`、`quality`、`visual` | EPANET 组件类能力 | -| `simulation` | `run`、`run-inp`、`output` | 模拟运行和模拟输出 | -| `analysis` | `burst`、`leakage`、`valve`、`flushing`、`age`、`sensor-placement`、`risk` | 任务级分析 | +| `project` | `list`、`info`、`db-health`、`export-inp`、`data` | 项目发现和只读项目数据 | +| `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 | +| `component` | `option` | EPANET 选项设置,只读 | +| `simulation` | `run` | 模拟运行 | +| `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 | | `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 | | `help` | `--json`、`COMMAND --json` | Agent 能力发现和命令说明 | -| `result` | `show`、`metadata`、`export` | `result-ref` 读取和导出 | 命令深度建议: -- 常规命令不超过 3 层:`tjwater component curve list` +- 常规命令不超过 3 层:`tjwater component option get` - 时序数据允许 4 层:`tjwater data timeseries realtime links` - `risk` 归入 `analysis risk` - `scada`、`scheme`、`extension` 归入 `data` +## 全局上下文与通用参数 + +首批 CLI 建议统一支持以下全局参数: + +```text +--server URL +--auth-context PATH +--scheme SCHEME +--timeout SEC +--request-id ID +``` + +参数含义: + +| 参数 | 含义 | 作用域 | 说明 | +|---|---|---|---| +| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 | +| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 | +| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 | +| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 | +| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 | + +约束: + +- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。 +- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。 +- `--server`、`--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。 +- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。 + +参数表达建议: + +- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。 +- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。 +- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。 +- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help --json` 给出 schema。 + ## 首批 CLI 范围 -### Auth / Project +### Project 来源: @@ -67,17 +101,48 @@ app/api/v1/endpoints/auth.py app/api/v1/endpoints/meta.py app/api/v1/endpoints/project.py app/api/v1/endpoints/project_data.py +TJWaterFrontend_Refine/src/lib/requestHeaders.ts +TJWaterFrontend_Refine/src/lib/api.ts +TJWaterFrontend_Refine/src/lib/apiFetch.ts ``` +认证模式: + +- **Non-interactive / Agent** + - 面向 agent、脚本、多用户多 agent 并发调用。 + - 必须显式传入认证上下文。 + - 不得隐式回退到本机默认状态。 + +Agent 调用认证上下文: + +- 当前前端调用链会自动附加以下请求头: + - `Authorization: Bearer ` + - `X-Project-Id: ` + - `X-User-Id: ` +- 其中 `Authorization` 来自访问令牌,`X-Project-Id` 来自当前项目上下文,`X-User-Id` 来自当前登录用户。 +- 因此前端触发的 agent 调用,应默认支持直接消费这三个字段;不再设计额外的本地 `login` 流程。 +- CLI 侧建议提供两类显式注入方式: + - `--auth-context PATH` + - 环境变量 / 调用方 header 映射 + +认证解析优先级建议固定为: + +1. 命令行显式参数(如 `--auth-context`) +2. 调用方显式注入的环境变量 / header 映射 + +约束: + +- Agent 模式下,若未显式提供认证上下文,应返回明确错误,而不是尝试复用默认登录态。 +- `X-Project-Id` 是当前 project scope 的默认来源;CLI 命令默认直接使用该上下文,不要求重复传参。 +- `X-User-Id` 主要用于审计、结果归属和多用户隔离,不应用来替代 access token 做认证。 + | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater auth me` | `GET /auth/me` | 当前登录用户 | -| `tjwater auth refresh` | `POST /auth/refresh` | 仅在 CLI 需要维护登录态时暴露 | | `tjwater project list` | `GET /meta/projects` | 项目列表 | -| `tjwater project info --project PROJECT` | `GET /meta/project` | 项目信息 | -| `tjwater project db-health --project PROJECT` | `GET /meta/db/health` | 项目数据库健康 | -| `tjwater project export-inp --project PROJECT --out-ref` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出 INP,写 `result-ref` | -| `tjwater project data --project PROJECT --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 项目业务数据 | +| `tjwater project info` | `GET /meta/project` | 当前 project 信息 | +| `tjwater project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | +| `tjwater project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | +| `tjwater project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | 暂不暴露: @@ -85,6 +150,8 @@ app/api/v1/endpoints/project_data.py POST /auth/register POST /auth/login POST /auth/login/simple +GET /auth/me +POST /auth/refresh GET /listprojects/ GET /project_info/ GET /haveproject/ @@ -114,19 +181,8 @@ app/api/v1/endpoints/network/*.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater network list --network NET --type nodes\|links` | `GET /getnodes/`、`GET /getlinks/` | 节点/管线 ID 列表 | -| `tjwater network exists --network NET --type node\|link\|junction\|pipe\|... --id ID` | `GET /isnode/`、`GET /islink/` 等 | 元素存在性 | -| `tjwater network type --network NET --id ID` | `GET /getnodetype/`、`GET /getlinktype/`、`GET /getelementtype/` | 元素类型 | -| `tjwater network get --network NET --id ID` | `GET /getelementproperties/`、`GET /getnodeproperties/`、`GET /getlinkproperties/` | 自动识别类型并取属性 | -| `tjwater network get --network NET --type junction\|pipe\|pump\|... --id ID` | 各类 `get*properties` | 指定类型取属性 | -| `tjwater network list-properties --network NET --type junction\|pipe\|pump\|... --out-ref` | 各类 `getall*properties` | 全量属性,写 `result-ref` | -| `tjwater network schema --network NET --type junction\|reservoir\|tank\|pipe\|pump\|valve\|demand\|tag\|region` | 各类 `get*schema` | 属性架构 | -| `tjwater network links-of-node --network NET --node NODE` | `GET /getnodelinks/` | 节点关联管线 | -| `tjwater network geometry --network NET --scope full\|extent\|major-nodes\|major-pipes\|link-nodes --out-ref` | `geometry.py` 下 `GET` 接口 | 几何数据 | -| `tjwater network demand-calc --network NET --scope node\|region\|network --out-ref` | `GET /calculatedemandto*/` | 需水量计算 | -| `tjwater network region get\|list\|schema --network NET --kind dma\|service-area\|virtual-district` | `regions.py` 下 `GET` 查询接口 | 分区信息 | -| `tjwater network region-calc --network NET --kind dma\|service-area\|virtual-district --out-ref` | `GET /calculate*/` | 分区计算 | -| `tjwater network tag get\|list\|schema --network NET` | `GET /gettag/`、`GET /gettags/`、`GET /gettagschema/` | 标签信息 | +| `tjwater network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | +| `tjwater network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | 暂不暴露: @@ -153,12 +209,14 @@ app/api/v1/endpoints/components/*.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater component curve schema\|list\|get\|exists` | `curves.py` 下只读接口 | 曲线 | -| `tjwater component pattern schema\|list\|get\|exists` | `patterns.py` 下只读接口 | 模式 | -| `tjwater component option schema\|get --kind time\|energy\|pump-energy\|general` | `options.py` 下只读接口 | 时间、能耗、泵能耗、通用选项 | -| `tjwater component control schema\|get --kind control\|rule` | `controls.py` 下只读接口 | 控制和规则 | -| `tjwater component quality schema\|get --kind quality\|emitter\|source\|reaction\|pipe-reaction\|tank-reaction\|mixing` | `quality.py` 下只读接口 | 水质相关组件 | -| `tjwater component visual schema\|list\|get --kind vertex\|label\|backdrop\|vertex-links\|vertices` | `visuals.py` 下只读接口 | 图形元素、标签、背景 | +| `tjwater component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema | +| `tjwater component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 | +| `tjwater component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema | +| `tjwater component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 | +| `tjwater component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema | +| `tjwater component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 | +| `tjwater component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema | +| `tjwater component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 | 暂不暴露: @@ -197,8 +255,9 @@ POST /setbackdropproperties/ 备注: -- `getsourcechema` 路径拼写疑似错误,CLI 统一使用 `component quality schema --kind source`。 -- `getallvertexlinks`、`getallvertices` 当前返回 JSON 字符串,CLI 应输出标准 JSON。 +- `options` 当前实际只读接口分为 4 组:`time`、`energy`、`pump-energy`、`network`。 +- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`。 +- 后端现有路径 `GET /getpumpenergyproperties//` 和 `GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。 ### Simulation / Analysis / Risk @@ -214,23 +273,22 @@ app/api/v1/endpoints/risk.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater simulation run --project PROJECT --out-ref` | `GET /runprojectreturndict/` | 运行项目模拟,使用结构化 JSON 返回 | -| `tjwater simulation run-inp --inp PATH --out-ref` | `GET /runinp/` | 运行 INP | -| `tjwater simulation output --project PROJECT --out-ref` | `GET /dumpoutput/` | 导出模拟输出 | -| `tjwater analysis burst --project PROJECT --start-time TIME --duration SEC --burst ID:SIZE --out-ref` | `GET /burst_analysis/` | 爆管分析,`--burst` 可重复 | -| `tjwater analysis valve --project PROJECT --mode close\|isolation --start-time TIME --valve VALVE --out-ref` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | -| `tjwater analysis flushing --project PROJECT --start-time TIME --valve VALVE:OPENING --drainage-node NODE --flow FLOW --out-ref` | `GET /flushing_analysis/` | 冲洗分析,`--valve` 可重复 | -| `tjwater analysis age --project PROJECT --start-time TIME --duration SEC --out-ref` | `GET /age_analysis/` | 水龄分析 | -| `tjwater analysis contaminant --project PROJECT --start-time TIME --duration SEC --source NODE:VALUE --out-ref` | `GET /contaminant_simulation/` | 污染物模拟 | -| `tjwater analysis sensor-placement --project PROJECT --method sensitivity\|kmeans --count N --out-ref` | 传感器放置分析接口 | 不包含创建方案 | -| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /leakage/identify/` | 漏损识别 | +| `tjwater simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater data timeseries realtime *` 查询 | +| `tjwater analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | +| `tjwater analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | +| `tjwater analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | +| `tjwater analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | +| `tjwater analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | +| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | | `tjwater analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | -| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /burst-detection/detect/` | 爆管检测 | +| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | | `tjwater analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | -| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `POST /burst-location/locate/` | 爆管定位 | +| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | | `tjwater analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | -| `tjwater analysis risk pipe --network NET --pipe PIPE --time-range ...` | `risk.py` 下管道风险 `GET` 接口 | 管道风险 | -| `tjwater analysis risk network --network NET --out-ref` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 全网风险 | +| `tjwater analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | +| `tjwater analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | +| `tjwater analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | 暂缓或暂不暴露: @@ -240,13 +298,21 @@ GET /runproject/ POST /network_update/ POST /project_management/ POST /sensorplacementscheme/create -POST /runsimulationmanuallybydate/ POST /pump_failure/ POST /pressure_regulation/ POST /scheduling_analysis/ POST /daily_scheduling_analysis/ ``` +执行模型: + +- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。 +- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。 +- 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。 +- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。 +- 模拟结果读取统一走 `tjwater data timeseries realtime *`,而不是再单独设计 `simulation output`。 +- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。 + ### Data 来源: @@ -262,21 +328,25 @@ app/api/v1/endpoints/project_data.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater data timeseries realtime links --start-time TIME --end-time TIME --out-ref` | `GET /realtime/links` | 实时管道数据 | -| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME --out-ref` | `GET /realtime/nodes` | 实时节点数据 | -| `tjwater data timeseries realtime simulation --query by-id-time\|by-time-property --id ID --time TIME --property PROPERTY --out-ref` | `GET /realtime/query/*` | 实时模拟查询 | -| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME --out-ref` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | -| `tjwater data timeseries scheme node-field --node NODE --field FIELD --out-ref` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | -| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY --out-ref` | `GET /scheme/query/*` | 方案模拟查询 | -| `tjwater data timeseries scada query --device-ids ... --time-range ... --out-ref` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序 | -| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME --out-ref` | `GET /composite/*` | 复合查询,`--feature` 可重复 | -| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME --out-ref` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | +| `tjwater data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 | +| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 | +| `tjwater data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 | +| `tjwater data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 | +| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | +| `tjwater data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | +| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 | +| `tjwater data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | +| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | +| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | | `tjwater data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | | `tjwater data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | -| `tjwater data scheme schema\|get\|list --network NET` | `schemes.py` 下 `GET` 接口 | 方案查询 | -| `tjwater data extension keys\|get\|list --network NET` | `extension.py` 下 `GET` 查询接口 | 扩展数据查询 | -| `tjwater data misc sensor-placements --network NET --out-ref` | `GET /getallsensorplacements/` | 传感器位置 | -| `tjwater data misc burst-location-results --network NET --out-ref` | `GET /getallburstlocateresults/` | 爆管定位结果 | +| `tjwater data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | +| `tjwater data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 | +| `tjwater data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 | +| `tjwater data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 | + +- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。 +- `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 暂不暴露: @@ -358,28 +428,49 @@ POST /users/{user_id}/activate POST /users/{user_id}/deactivate ``` -## Help / Result +## Help -这两个模块不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。 +`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。 | 命令 | 说明 | |---|---| | `tjwater help --json` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | | `tjwater help COMMAND --json` | 返回某个命令的参数、输出、示例和推荐后续命令 | -| `tjwater result show REF` | 读取 `result-ref` 内容,必要时分页或摘要 | -| `tjwater result metadata REF` | 读取 `result-ref` 元数据 | -| `tjwater result export REF --format json\|csv` | 导出结果 | + +输出补充约束: + +- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。 +- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`,例如 `project export-inp --output PATH`。 +- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。 ## 输出规范 +进程级契约: + +- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。 +- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。 +- 退出码必须稳定,不能简单透传底层 HTTP status。 + +建议退出码: + +| 退出码 | 含义 | +|---|---| +| `0` | 成功 | +| `2` | CLI 参数错误 / 用法错误 | +| `3` | 认证失败 | +| `4` | 权限不足 | +| `5` | 资源不存在 | +| `6` | 冲突、前置条件不满足或非法状态 | +| `7` | 服务端错误 | + 成功: ```json { "ok": true, + "schema_version": "tjwater-cli/v1", "summary": "读取成功", "data": {}, - "result_ref": null, "metadata": {}, "next_commands": [] } @@ -390,26 +481,26 @@ POST /users/{user_id}/deactivate ```json { "ok": false, - "error": "invalid_argument", - "message": "缺少必要参数 --network", - "recoverable": true, - "suggested_command": "tjwater component curve list --network NET" + "schema_version": "tjwater-cli/v1", + "summary": "认证失败", + "error": { + "code": "UNAUTHENTICATED", + "message": "missing access token for agent context", + "retryable": false + }, + "data": null, + "metadata": {}, + "next_commands": [ + "tjwater --auth-context /path/to/auth-context.json" + ] } ``` -大结果: +补充约束: -```json -{ - "ok": true, - "summary": "查询完成,结果已写入 result-ref", - "result_ref": "TJWaterAgent/data/result-refs/example.json", - "metadata": { - "schema": "network_properties_v1", - "rows": 1200 - } -} -``` +- `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。 +- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。 +- 所有 `help --json` 输出也应带 `schema_version`,便于 agent 做能力协商。 ## 后续开放条件 From f274cf5122f5149f4e093e25dde187c071a1f17c Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 11:11:56 +0800 Subject: [PATCH 20/93] =?UTF-8?q?=E6=95=B4=E7=90=86=20tjwater-cli=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=92=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/README.md | 68 ++ cli/pyrightconfig.json | 14 + cli/requirements.txt | 3 + cli/tests/conftest.py | 6 + cli/tests/unit/test_tjwater_cli.py | 306 +++++++++ cli/tjwater | 17 + cli/tjwater_agent_cli/__init__.py | 3 + cli/tjwater_agent_cli/__main__.py | 5 + cli/tjwater_agent_cli/apps.py | 83 +++ cli/tjwater_agent_cli/commands_analysis.py | 531 ++++++++++++++ cli/tjwater_agent_cli/commands_data.py | 573 ++++++++++++++++ cli/tjwater_agent_cli/commands_project.py | 224 ++++++ cli/tjwater_agent_cli/common.py | 54 ++ cli/tjwater_agent_cli/core.py | 647 ++++++++++++++++++ cli/tjwater_agent_cli/helping.py | 403 +++++++++++ cli/tjwater_agent_cli/main.py | 115 ++++ cli/tjwater_agent_cli/registry.py | 450 ++++++++++++ .../tjwater_cli_endpoint_scope.md | 0 18 files changed, 3502 insertions(+) create mode 100644 cli/README.md create mode 100644 cli/pyrightconfig.json create mode 100644 cli/requirements.txt create mode 100644 cli/tests/conftest.py create mode 100644 cli/tests/unit/test_tjwater_cli.py create mode 100755 cli/tjwater create mode 100644 cli/tjwater_agent_cli/__init__.py create mode 100644 cli/tjwater_agent_cli/__main__.py create mode 100644 cli/tjwater_agent_cli/apps.py create mode 100644 cli/tjwater_agent_cli/commands_analysis.py create mode 100644 cli/tjwater_agent_cli/commands_data.py create mode 100644 cli/tjwater_agent_cli/commands_project.py create mode 100644 cli/tjwater_agent_cli/common.py create mode 100644 cli/tjwater_agent_cli/core.py create mode 100644 cli/tjwater_agent_cli/helping.py create mode 100644 cli/tjwater_agent_cli/main.py create mode 100644 cli/tjwater_agent_cli/registry.py rename agent_cli_endpoint_scope.md => cli/tjwater_cli_endpoint_scope.md (100%) diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..c31819e --- /dev/null +++ b/cli/README.md @@ -0,0 +1,68 @@ +# TJWater CLI + +独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器**直接调用并通过 stdout/stderr 参与管道**。 + +## 直接使用 + +```bash +cd TJWaterServerBinary/cli +./tjwater help --json +``` + +这个入口文件可以直接参与管道: + +```bash +./tjwater help --json | jq +``` + +它会优先使用: +1. `cli/.venv/bin/python` +2. 环境变量 `PYTHON` +3. 当前环境里的 `python` +4. 最后回退到 `python3` + +如果需要,也可以显式走 Python: + +```bash +python -m tjwater_agent_cli help --json +``` + +## 部署到 agent 服务器 + +最简单的方式是把整个 `TJWaterServerBinary/cli/` 文件夹同步到 agent 服务器,然后直接执行: + +```bash +cd TJWaterServerBinary/cli +./tjwater help --json +``` + +如果希望放到 PATH 中: + +```bash +chmod +x tjwater +ln -s /path/to/TJWaterServerBinary/cli/tjwater /usr/local/bin/tjwater +tjwater help --json +``` + +## Python 依赖 + +```bash +cd TJWaterServerBinary/cli +python -m pip install -r requirements.txt +``` + +只保留运行 CLI 必需依赖,不再包含安装包构建相关内容。 + +## 认证上下文 + +CLI 通过 `--auth-context` 读取 JSON 文件。常用字段: + +```json +{ + "server": "http://backend-host:8000", + "access_token": "...", + "project_id": "...", + "network": "...", + "username": "..." +} +``` diff --git a/cli/pyrightconfig.json b/cli/pyrightconfig.json new file mode 100644 index 0000000..39b6dd1 --- /dev/null +++ b/cli/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "include": [ + "tjwater_agent_cli", + "tests" + ], + "executionEnvironments": [ + { + "root": ".", + "extraPaths": [ + "." + ] + } + ] +} diff --git a/cli/requirements.txt b/cli/requirements.txt new file mode 100644 index 0000000..8eb3f9b --- /dev/null +++ b/cli/requirements.txt @@ -0,0 +1,3 @@ +click>=8.1,<9 +requests>=2.31,<3 +typer>=0.12,<1 diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..17cdbe1 --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,6 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py new file mode 100644 index 0000000..c546396 --- /dev/null +++ b/cli/tests/unit/test_tjwater_cli.py @@ -0,0 +1,306 @@ +from pathlib import Path + +from typer.testing import CliRunner + +from tjwater_agent_cli import core +from tjwater_agent_cli.main import app, main + + +runner = CliRunner() + + +class DummyResponse: + def __init__(self, *, status_code=200, json_data=None, text="", headers=None, content=None): + self.status_code = status_code + self._json_data = json_data + self.text = text + self.headers = headers or {"content-type": "application/json"} + self.content = content if content is not None else text.encode("utf-8") + + @property + def ok(self): + return 200 <= self.status_code < 300 + + def json(self): + if self._json_data is None: + raise ValueError("no json") + return self._json_data + + +def test_load_auth_context_supports_aliases(tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"base_url":"http://server","token":"abc","projectId":"p1","userId":"u1","username":"tester","projectCode":"net1"}', + encoding="utf-8", + ) + + auth = core.load_auth_context(auth_path) + + assert auth.server == "http://server" + assert auth.access_token == "abc" + assert auth.project_id == "p1" + assert auth.user_id == "u1" + assert auth.username == "tester" + assert auth.network == "net1" + + +def test_build_runtime_context_uses_default_server(monkeypatch): + monkeypatch.delenv("TJWATER_SERVER", raising=False) + monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False) + monkeypatch.delenv("TJWATER_USER_ID", raising=False) + monkeypatch.delenv("TJWATER_USERNAME", raising=False) + monkeypatch.delenv("TJWATER_NETWORK", raising=False) + monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False) + + runtime = core.build_runtime_context( + server=None, + auth_context_path=None, + scheme=None, + timeout=core.DEFAULT_TIMEOUT, + request_id="req-1", + ) + + assert runtime.server == core.DEFAULT_SERVER + + +def test_help_json_lists_commands(): + result = runner.invoke(app, ["help", "--json"]) + + assert result.exit_code == 0 + assert '"schema_version": "tjwater-cli/v1"' in result.stdout + assert '"command": "project"' in result.stdout + assert '"command": "analysis"' in result.stdout + assert '"menu_level": 1' in result.stdout + assert '"command": "project list"' not in result.stdout + + +def test_help_defaults_to_text(): + result = runner.invoke(app, ["help"]) + + assert result.exit_code == 0 + assert "Commands:" in result.stdout + assert "project: 项目与项目级元数据相关命令。" in result.stdout + assert "analysis: 分析计算与诊断相关命令。" in result.stdout + assert "Use `tjwater help` to see subcommands." in result.stdout + assert "usage: tjwater project help" not in result.stdout + assert "example: tjwater project help" not in result.stdout + assert "project list: 列出当前用户可访问项目" not in result.stdout + assert '"schema_version": "tjwater-cli/v1"' not in result.stdout + + +def test_simulation_help_lists_subcommands(): + result = runner.invoke(app, ["simulation", "help"]) + + assert result.exit_code == 0 + assert "模拟运行与调度相关命令。" in result.stdout + assert "simulation run: 触发指定绝对时间的模拟运行" in result.stdout + assert "usage: tjwater simulation run --start-time --duration " in result.stdout + assert "example: tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + + +def test_nested_group_help_lists_examples(): + result = runner.invoke(app, ["analysis", "leakage", "help"]) + + assert result.exit_code == 0 + assert "漏损分析相关命令。" in result.stdout + assert "analysis leakage identify: 执行漏损识别" in result.stdout + assert "example: tjwater --auth-context auth.json analysis leakage identify" in result.stdout + + +def test_analysis_help_uses_group_summaries_for_nested_groups(): + result = runner.invoke(app, ["analysis", "help"]) + + assert result.exit_code == 0 + assert "analysis leakage: 漏损分析相关命令。" in result.stdout + assert "analysis burst-detection: 爆管检测相关命令。" in result.stdout + assert "analysis burst-location" not in result.stdout + assert "analysis risk" not in result.stdout + assert "analysis leakage: 执行漏损识别" not in result.stdout + assert "example: tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" in result.stdout + assert "example: tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" in result.stdout + + +def test_bare_analysis_uses_typer_help_with_descriptions(): + result = runner.invoke(app, ["analysis"]) + + assert result.exit_code == 2 + assert "分析计算与诊断相关命令。" in result.stdout + assert "burst 执行爆管分析" in result.stdout + assert "valve 执行阀门关闭或隔离分析" in result.stdout + assert "leakage 漏损分析相关命令。" in result.stdout + assert "burst-location" not in result.stdout + assert "risk" not in result.stdout + + +def test_leaf_help_shows_usage_and_example(): + result = runner.invoke(app, ["help", "simulation", "run"]) + + assert result.exit_code == 0 + assert "Command: simulation run" in result.stdout + assert "结果需后续通过 data timeseries 在对应时间段查询" in result.stdout + assert "Usage: tjwater simulation run --start-time --duration " in result.stdout + assert "Examples:" in result.stdout + assert "tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + + +def test_project_help_uses_legal_kind_example(): + result = runner.invoke(app, ["project", "help"]) + + assert result.exit_code == 0 + assert "example: tjwater --auth-context auth.json project data --kind scada-info" in result.stdout + assert "--kind time" not in result.stdout + + +def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","network":"demo"}', + encoding="utf-8", + ) + burst_path = tmp_path / "burst.json" + burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8") + + def fake_request(**kwargs): + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "--auth-context", + str(auth_path), + "analysis", + "burst", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "30", + "--burst-file", + str(burst_path), + "--scheme", + "burst_case_01", + ], + ) + + assert result.exit_code == 0 + assert '"summary": "爆管分析执行成功"' in result.stdout + assert '"tjwater --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout + assert '"tjwater --auth-context auth.json data scheme list"' in result.stdout + + +def test_main_missing_option_error_includes_usage_and_next_step(capsys): + exit_code = main(["simulation", "run"]) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"summary": "缺少参数"' in stdout + assert '"code": "MISSING_PARAMETER"' in stdout + assert '"usage": "tjwater simulation run --start-time --duration "' in stdout + assert '"tjwater help simulation run"' in stdout + + +def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): + exit_code = main(["analysis"]) + stdout = capsys.readouterr().out + + assert exit_code == 0 + assert "Usage: tjwater analysis" in stdout + assert "分析计算与诊断相关命令。" in stdout + assert '"ok": false' not in stdout + + +def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","project_id":"pid","network":"demo"}', + encoding="utf-8", + ) + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(json_data=[{"project_id": "pid", "name": "Demo"}]) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke(app, ["--auth-context", str(auth_path), "project", "list"]) + + assert result.exit_code == 0 + assert '"ok": true' in result.stdout + assert captured["headers"]["Authorization"] == "Bearer abc" + assert captured["url"] == "http://server/api/v1/meta/projects" + + +def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","network":"demo"}', + encoding="utf-8", + ) + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(json_data={"status": "success", "message": "Simulation started"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "--auth-context", + str(auth_path), + "simulation", + "run", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "30", + ], + ) + + assert result.exit_code == 0 + assert captured["json"] == { + "name": "demo", + "simulation_date": "2025-01-02", + "start_time": "03:04:05+08:00", + "duration": 30, + } + assert '"tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + assert '"tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + + +def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","network":"demo"}', + encoding="utf-8", + ) + output = tmp_path / "demo.inp" + calls = [] + + def fake_request(**kwargs): + calls.append(kwargs["url"]) + if kwargs["url"].endswith("/dumpinp/"): + return DummyResponse(json_data=True) + return DummyResponse( + headers={"content-type": "application/octet-stream"}, + content=b"inp-content", + text="inp-content", + ) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + ["--auth-context", str(auth_path), "project", "export-inp", "--output", str(output)], + ) + + assert result.exit_code == 0 + assert output.read_bytes() == b"inp-content" + assert calls == [ + "http://server/api/v1/dumpinp/", + "http://server/api/v1/downloadinp/", + ] diff --git a/cli/tjwater b/cli/tjwater new file mode 100755 index 0000000..e2428c8 --- /dev/null +++ b/cli/tjwater @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -x "$ROOT/.venv/bin/python" ]; then + PYTHON_BIN="$ROOT/.venv/bin/python" +elif [ -n "${PYTHON:-}" ]; then + PYTHON_BIN="$PYTHON" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +else + PYTHON_BIN="python3" +fi + +export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" +exec "$PYTHON_BIN" -m tjwater_agent_cli "$@" diff --git a/cli/tjwater_agent_cli/__init__.py b/cli/tjwater_agent_cli/__init__.py new file mode 100644 index 0000000..d1b1862 --- /dev/null +++ b/cli/tjwater_agent_cli/__init__.py @@ -0,0 +1,3 @@ +from .main import app, main + +__all__ = ["app", "main"] diff --git a/cli/tjwater_agent_cli/__main__.py b/cli/tjwater_agent_cli/__main__.py new file mode 100644 index 0000000..8462220 --- /dev/null +++ b/cli/tjwater_agent_cli/__main__.py @@ -0,0 +1,5 @@ +from .main import console_entry + + +if __name__ == "__main__": + console_entry() diff --git a/cli/tjwater_agent_cli/apps.py b/cli/tjwater_agent_cli/apps.py new file mode 100644 index 0000000..13de6b5 --- /dev/null +++ b/cli/tjwater_agent_cli/apps.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import typer + +app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True) +project_app = typer.Typer(no_args_is_help=True) +network_app = typer.Typer(no_args_is_help=True) +component_app = typer.Typer(no_args_is_help=True) +component_option_app = typer.Typer(no_args_is_help=True) +simulation_app = typer.Typer(no_args_is_help=True) +analysis_app = typer.Typer(no_args_is_help=True) +analysis_leakage_app = typer.Typer(no_args_is_help=True) +analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True) +analysis_burst_detection_app = typer.Typer(no_args_is_help=True) +analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True) +analysis_burst_location_app = typer.Typer(no_args_is_help=True) +analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True) +analysis_risk_app = typer.Typer(no_args_is_help=True) +analysis_sensor_placement_app = typer.Typer(no_args_is_help=True) +data_app = typer.Typer(no_args_is_help=True) +data_timeseries_app = typer.Typer(no_args_is_help=True) +data_timeseries_realtime_app = typer.Typer(no_args_is_help=True) +data_timeseries_scheme_app = typer.Typer(no_args_is_help=True) +data_timeseries_scada_app = typer.Typer(no_args_is_help=True) +data_timeseries_composite_app = typer.Typer(no_args_is_help=True) +data_scada_app = typer.Typer(no_args_is_help=True) +data_scheme_app = typer.Typer(no_args_is_help=True) +data_extension_app = typer.Typer(no_args_is_help=True) +data_misc_app = typer.Typer(no_args_is_help=True) + +app.add_typer(project_app, name="project") +app.add_typer(network_app, name="network") +app.add_typer(component_app, name="component") +component_app.add_typer(component_option_app, name="option") +app.add_typer(simulation_app, name="simulation") +app.add_typer(analysis_app, name="analysis") +analysis_app.add_typer(analysis_sensor_placement_app, name="sensor-placement") +analysis_app.add_typer(analysis_leakage_app, name="leakage") +analysis_leakage_app.add_typer(analysis_leakage_schemes_app, name="schemes") +analysis_app.add_typer(analysis_burst_detection_app, name="burst-detection") +analysis_burst_detection_app.add_typer(analysis_burst_detection_schemes_app, name="schemes") +analysis_app.add_typer(analysis_burst_location_app, name="burst-location") +analysis_burst_location_app.add_typer(analysis_burst_location_schemes_app, name="schemes") +analysis_app.add_typer(analysis_risk_app, name="risk") +app.add_typer(data_app, name="data") +data_app.add_typer(data_timeseries_app, name="timeseries") +data_timeseries_app.add_typer(data_timeseries_realtime_app, name="realtime") +data_timeseries_app.add_typer(data_timeseries_scheme_app, name="scheme") +data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada") +data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite") +data_app.add_typer(data_scada_app, name="scada") +data_app.add_typer(data_scheme_app, name="scheme") +data_app.add_typer(data_extension_app, name="extension") +data_app.add_typer(data_misc_app, name="misc") + +GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ + (project_app, ("project",)), + (network_app, ("network",)), + (component_app, ("component",)), + (component_option_app, ("component", "option")), + (simulation_app, ("simulation",)), + (analysis_app, ("analysis",)), + (analysis_sensor_placement_app, ("analysis", "sensor-placement")), + (analysis_leakage_app, ("analysis", "leakage")), + (analysis_leakage_schemes_app, ("analysis", "leakage", "schemes")), + (analysis_burst_detection_app, ("analysis", "burst-detection")), + (analysis_burst_detection_schemes_app, ("analysis", "burst-detection", "schemes")), + (analysis_burst_location_app, ("analysis", "burst-location")), + (analysis_burst_location_schemes_app, ("analysis", "burst-location", "schemes")), + (analysis_risk_app, ("analysis", "risk")), + (data_app, ("data",)), + (data_timeseries_app, ("data", "timeseries")), + (data_timeseries_realtime_app, ("data", "timeseries", "realtime")), + (data_timeseries_scheme_app, ("data", "timeseries", "scheme")), + (data_timeseries_scada_app, ("data", "timeseries", "scada")), + (data_timeseries_composite_app, ("data", "timeseries", "composite")), + (data_scada_app, ("data", "scada")), + (data_scheme_app, ("data", "scheme")), + (data_extension_app, ("data", "extension")), + (data_misc_app, ("data", "misc")), +] + +TOP_LEVEL_COMMANDS = {"help", "project", "network", "component", "simulation", "analysis", "data"} diff --git a/cli/tjwater_agent_cli/commands_analysis.py b/cli/tjwater_agent_cli/commands_analysis.py new file mode 100644 index 0000000..4a3121a --- /dev/null +++ b/cli/tjwater_agent_cli/commands_analysis.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path +from typing import Annotated + +import typer + +from .apps import ( + analysis_app, + analysis_burst_detection_app, + analysis_burst_detection_schemes_app, + analysis_burst_location_app, + analysis_burst_location_schemes_app, + analysis_leakage_app, + analysis_leakage_schemes_app, + analysis_risk_app, + analysis_sensor_placement_app, + simulation_app, +) +from .common import emit_api, runtime_context +from .core import ( + CLIError, + emit_success, + parse_burst_file, + parse_optional_dataset_file, + parse_time_with_timezone, + parse_valve_setting_file, + request_json, + require_network, + require_username, + resolve_scheme, +) + + +@simulation_app.command("run") +def simulation_run( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续分钟数")], +) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + parsed = parse_time_with_timezone(start_time, option_name="--start-time") + end_time = (parsed + timedelta(minutes=duration)).isoformat() + body = { + "name": network, + "simulation_date": parsed.date().isoformat(), + "start_time": parsed.timetz().replace(microsecond=0).isoformat(), + "duration": duration, + } + emit_api( + ctx, + summary="触发模拟成功", + method="POST", + path="/runsimulationmanuallybydate/", + json_body=body, + require_auth=True, + require_network_ctx=True, + next_commands=[ + f"tjwater --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", + ], + ) + + +@analysis_app.command("burst") +def analysis_burst( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续秒数")], + burst_file: Annotated[Path, typer.Option("--burst-file", help="爆管输入 JSON 文件")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + ids, sizes = parse_burst_file(burst_file) + scheme_name = resolve_scheme(runtime, scheme, required=True) + params = { + "network": require_network(runtime), + "modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "burst_ID": ids, + "burst_size": sizes, + "modify_total_duration": duration, + "scheme_name": scheme_name, + } + emit_api( + ctx, + summary="爆管分析执行成功", + method="GET", + path="/burst_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + next_commands=[ + f"tjwater --auth-context auth.json data scheme get --name {scheme_name}", + "tjwater --auth-context auth.json data scheme list", + ], + ) + + +@analysis_app.command("valve") +def analysis_valve( + ctx: typer.Context, + mode: Annotated[str, typer.Option("--mode", help="close|isolation")], + start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None, + valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None, + element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, + disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None, + duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None, +) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + if mode == "close": + if not start_time or not valve: + raise CLIError( + "CLI 参数错误", + code="INVALID_VALVE_CLOSE_ARGS", + message="close mode requires --start-time and at least one --valve", + exit_code=2, + ) + params = { + "network": network, + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "valves": valve, + "duration": duration or 900, + } + emit_api( + ctx, + summary="阀门关闭分析执行成功", + method="GET", + path="/valve_close_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + return + if mode == "isolation": + if not element: + raise CLIError( + "CLI 参数错误", + code="INVALID_VALVE_ISOLATION_ARGS", + message="isolation mode requires at least one --element", + exit_code=2, + ) + params = {"network": network, "accident_element": element} + if disabled_valve: + params["disabled_valves"] = disabled_valve + emit_api( + ctx, + summary="阀门隔离分析执行成功", + method="GET", + path="/valve_isolation_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + return + raise CLIError( + "CLI 参数错误", + code="INVALID_MODE", + message="--mode must be close or isolation", + exit_code=2, + ) + + +@analysis_app.command("flushing") +def analysis_flushing( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + valve_setting_file: Annotated[Path, typer.Option("--valve-setting-file", help="阀门开度 JSON 文件")], + drainage_node: Annotated[str, typer.Option("--drainage-node", help="排污节点")], + flow: Annotated[float, typer.Option("--flow", help="冲洗流量")], + duration: Annotated[int | None, typer.Option("--duration", help="持续秒数")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + valves, openings = parse_valve_setting_file(valve_setting_file) + params = { + "network": require_network(runtime), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "valves": valves, + "valves_k": openings, + "drainage_node_ID": drainage_node, + "flush_flow": flow, + "duration": duration or 900, + } + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + emit_api( + ctx, + summary="冲洗分析执行成功", + method="GET", + path="/flushing_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_app.command("age") +def analysis_age( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续秒数")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="水龄分析执行成功", + method="GET", + path="/age_analysis/", + params={ + "network": require_network(runtime), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "duration": duration, + }, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_app.command("contaminant") +def analysis_contaminant( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续秒数")], + source_node: Annotated[str, typer.Option("--source-node", help="污染源节点")], + concentration: Annotated[float, typer.Option("--concentration", help="浓度")], + pattern: Annotated[str | None, typer.Option("--pattern", help="模式 ID")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + params = { + "network": require_network(runtime), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "source": source_node, + "concentration": concentration, + "duration": duration, + } + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + if pattern: + params["pattern"] = pattern + emit_api( + ctx, + summary="污染物模拟执行成功", + method="GET", + path="/contaminant_simulation/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_sensor_placement_app.command("kmeans") +def analysis_sensor_placement_kmeans( + ctx: typer.Context, + count: Annotated[int, typer.Option("--count", help="传感器数量")], + min_diameter: Annotated[int, typer.Option("--min-diameter", help="最小管径")] = 0, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + body = { + "name": require_network(runtime), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "sensor_number": count, + "min_diameter": min_diameter, + "username": require_username(runtime), + } + emit_api( + ctx, + summary="传感器选址执行成功", + method="POST", + path="/pressure_sensor_placement_kmeans/", + json_body=body, + require_auth=True, + require_network_ctx=True, + require_username_ctx=True, + ) + + +@analysis_leakage_app.command("identify") +def analysis_leakage_identify( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + body = { + "network": require_network(runtime), + "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + } + emit_api( + ctx, + summary="漏损识别执行成功", + method="POST", + path="/leakage/identify/", + json_body=body, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_leakage_schemes_app.command("list") +def analysis_leakage_schemes_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取漏损方案列表成功", + method="GET", + path="/leakage/schemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_leakage_schemes_app.command("get") +def analysis_leakage_schemes_get( + ctx: typer.Context, + scheme_name: Annotated[str, typer.Argument(help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取漏损方案详情成功", + method="GET", + path=f"/leakage/schemes/{scheme_name}", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_detection_app.command("detect") +def analysis_burst_detection_detect( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + body = { + "network": require_network(runtime), + "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + } + emit_api( + ctx, + summary="爆管检测执行成功", + method="POST", + path="/burst-detection/detect/", + json_body=body, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_detection_schemes_app.command("list") +def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管检测方案列表成功", + method="GET", + path="/burst-detection/schemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_detection_schemes_app.command("get") +def analysis_burst_detection_schemes_get( + ctx: typer.Context, + scheme_name: Annotated[str, typer.Argument(help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管检测方案详情成功", + method="GET", + path=f"/burst-detection/schemes/{scheme_name}", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_location_app.command("locate") +def analysis_burst_location_locate( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], + burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring", + pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None, + flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None, + pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None, + flow_file: Annotated[Path | None, typer.Option("--flow-file", help="包含 burst_flow/normal_flow 的 JSON 文件")] = None, + use_scada_flow: Annotated[bool, typer.Option("--use-scada-flow", help="启用 SCADA 流量")] = False, +) -> None: + runtime = runtime_context(ctx) + pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {} + flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {} + body = { + "network": require_network(runtime), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "data_source": data_source, + "scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + "burst_leakage": burst_leakage, + "use_scada_flow": use_scada_flow, + } + if pressure_scada_id: + body["pressure_scada_ids"] = pressure_scada_id + if flow_scada_id: + body["flow_scada_ids"] = flow_scada_id + if isinstance(pressure_payload, dict): + body.update({key: value for key, value in pressure_payload.items() if key in {"burst_pressure", "normal_pressure"}}) + if isinstance(flow_payload, dict): + body.update({key: value for key, value in flow_payload.items() if key in {"burst_flow", "normal_flow"}}) + emit_api( + ctx, + summary="爆管定位执行成功", + method="POST", + path="/burst-location/locate/", + json_body=body, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_location_schemes_app.command("list") +def analysis_burst_location_schemes_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管定位方案列表成功", + method="GET", + path="/burst-location/schemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_location_schemes_app.command("get") +def analysis_burst_location_schemes_get( + ctx: typer.Context, + scheme_name: Annotated[str, typer.Argument(help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管定位方案详情成功", + method="GET", + path=f"/burst-location/schemes/{scheme_name}", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_risk_app.command("pipe-now") +def analysis_risk_pipe_now( + ctx: typer.Context, + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取当前管道风险成功", + method="GET", + path="/getpiperiskprobabilitynow/", + params={"network": require_network(runtime), "pipe_id": pipe}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_risk_app.command("pipe-history") +def analysis_risk_pipe_history( + ctx: typer.Context, + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取历史管道风险成功", + method="GET", + path="/getpiperiskprobability/", + params={"network": require_network(runtime), "pipe_id": pipe}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_risk_app.command("network") +def analysis_risk_network(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + probabilities, duration_prob = request_json( + runtime, + method="GET", + path="/getnetworkpiperiskprobabilitynow/", + params={"network": network}, + require_auth=True, + require_network_ctx=True, + ) + geometries, duration_geo = request_json( + runtime, + method="GET", + path="/getpiperiskprobabilitygeometries/", + params={"network": network}, + require_auth=True, + require_network_ctx=True, + ) + emit_success( + summary="读取全网风险成功", + data={"probabilities": probabilities, "geometries": geometries}, + ctx=runtime, + duration_ms=duration_prob + duration_geo, + ) diff --git a/cli/tjwater_agent_cli/commands_data.py b/cli/tjwater_agent_cli/commands_data.py new file mode 100644 index 0000000..4491630 --- /dev/null +++ b/cli/tjwater_agent_cli/commands_data.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from .apps import ( + data_extension_app, + data_misc_app, + data_scada_app, + data_scheme_app, + data_timeseries_composite_app, + data_timeseries_realtime_app, + data_timeseries_scada_app, + data_timeseries_scheme_app, +) +from .common import emit_api, runtime_context +from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme + + +def _scheme_type_option(scheme_type: str | None) -> str: + return scheme_type or "simulation" + + +@data_timeseries_realtime_app.command("links") +def data_realtime_links( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], +) -> None: + emit_api( + ctx, + summary="读取实时管道数据成功", + method="GET", + path="/realtime/links", + params={ + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_realtime_app.command("nodes") +def data_realtime_nodes( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], +) -> None: + emit_api( + ctx, + summary="读取实时节点数据成功", + method="GET", + path="/realtime/nodes", + params={ + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_realtime_app.command("simulation-by-id-time") +def data_realtime_simulation_by_id_time( + ctx: typer.Context, + id: Annotated[str, typer.Option("--id", help="元素 ID")], + type: Annotated[str, typer.Option("--type", help="pipe|junction")], + time: Annotated[str, typer.Option("--time", help="查询时间")], +) -> None: + emit_api( + ctx, + summary="读取实时模拟数据成功", + method="GET", + path="/realtime/query/by-id-time", + params={ + "id": id, + "type": type, + "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_realtime_app.command("simulation-by-time-property") +def data_realtime_simulation_by_time_property( + ctx: typer.Context, + type: Annotated[str, typer.Option("--type", help="pipe|junction")], + time: Annotated[str, typer.Option("--time", help="查询时间")], + property: Annotated[str, typer.Option("--property", help="属性名")], +) -> None: + emit_api( + ctx, + summary="读取实时属性聚合数据成功", + method="GET", + path="/realtime/query/by-time-property", + params={ + "type": type, + "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), + "property": property, + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_scheme_app.command("links") +def data_scheme_links( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案管道数据成功", + method="GET", + path="/scheme/links", + params={ + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "scheme_type": _scheme_type_option(scheme_type), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_scheme_app.command("node-field") +def data_scheme_node_field( + ctx: typer.Context, + node: Annotated[str, typer.Option("--node", help="节点 ID")], + field: Annotated[str, typer.Option("--field", help="字段名")], + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案节点字段成功", + method="GET", + path=f"/scheme/nodes/{node}/field", + params={ + "field": field, + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "scheme_type": _scheme_type_option(scheme_type), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_scheme_app.command("simulation") +def data_scheme_simulation( + ctx: typer.Context, + query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, + id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None, + time: Annotated[str, typer.Option("--time", help="查询时间")] = "", + type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe", + property: Annotated[str | None, typer.Option("--property", help="属性名")] = None, +) -> None: + runtime = runtime_context(ctx) + params = { + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "scheme_type": _scheme_type_option(scheme_type), + "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), + "type": type, + } + if query == "by-id-time": + if not id: + raise CLIError( + "CLI 参数错误", + code="ID_REQUIRED", + message="--id is required for --query by-id-time", + exit_code=2, + ) + params["id"] = id + emit_api( + ctx, + summary="读取方案单点模拟数据成功", + method="GET", + path="/scheme/query/by-id-time", + params=params, + require_auth=True, + require_project=True, + ) + return + if query == "by-scheme-time-property": + if not property: + raise CLIError( + "CLI 参数错误", + code="PROPERTY_REQUIRED", + message="--property is required for --query by-scheme-time-property", + exit_code=2, + ) + params["property"] = property + emit_api( + ctx, + summary="读取方案属性聚合数据成功", + method="GET", + path="/scheme/query/by-scheme-time-property", + params=params, + require_auth=True, + require_project=True, + ) + return + raise CLIError( + "CLI 参数错误", + code="INVALID_QUERY", + message="--query must be by-id-time or by-scheme-time-property", + exit_code=2, + ) + + +@data_timeseries_scada_app.command("query") +def data_scada_query( + ctx: typer.Context, + device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")], + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], + field: Annotated[str | None, typer.Option("--field", help="字段名")] = None, +) -> None: + path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range" + params = { + "device_ids": ",".join(device_id), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + } + if field: + params["field"] = field + emit_api( + ctx, + summary="读取 SCADA 时序成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_composite_app.callback(invoke_without_command=True) +def data_timeseries_composite( + ctx: typer.Context, + kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None, + feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None, + start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None, + end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None, + pipe: Annotated[str | None, typer.Option("--pipe", help="pipeline-health 用管道 ID")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, + use_cleaned: Annotated[bool, typer.Option("--use-cleaned", help="element-scada 使用清洗值")] = False, +) -> None: + _ = pipe + if ctx.invoked_subcommand is not None: + return + if not kind or not start_time or not end_time: + raise CLIError( + "CLI 参数错误", + code="INVALID_COMPOSITE_ARGS", + message="composite query requires --kind, --start-time, and --end-time", + exit_code=2, + ) + runtime = runtime_context(ctx) + params = { + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + } + if kind == "scada-simulation": + if not feature: + raise CLIError( + "CLI 参数错误", + code="FEATURE_REQUIRED", + message="--feature is required for scada-simulation", + exit_code=2, + ) + params["device_ids"] = ",".join(feature) + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + params["scheme_type"] = _scheme_type_option(scheme_type) + emit_api( + ctx, + summary="读取复合 SCADA-模拟数据成功", + method="GET", + path="/composite/scada-simulation", + params=params, + require_auth=True, + require_project=True, + ) + return + if kind == "element-simulation": + if not feature: + raise CLIError( + "CLI 参数错误", + code="FEATURE_REQUIRED", + message="--feature is required for element-simulation", + exit_code=2, + ) + params["feature_infos"] = ",".join(feature) + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + params["scheme_type"] = _scheme_type_option(scheme_type) + emit_api( + ctx, + summary="读取复合元素模拟数据成功", + method="GET", + path="/composite/element-simulation", + params=params, + require_auth=True, + require_project=True, + ) + return + if kind == "element-scada": + if not feature or len(feature) != 1: + raise CLIError( + "CLI 参数错误", + code="FEATURE_REQUIRED", + message="element-scada requires exactly one --feature as element_id", + exit_code=2, + ) + params["element_id"] = feature[0] + params["use_cleaned"] = use_cleaned + emit_api( + ctx, + summary="读取元素关联 SCADA 数据成功", + method="GET", + path="/composite/element-scada", + params=params, + require_auth=True, + require_project=True, + ) + return + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="--kind must be scada-simulation, element-simulation, or element-scada", + exit_code=2, + ) + + +@data_timeseries_composite_app.command("pipeline-health") +def data_composite_pipeline_health( + ctx: typer.Context, + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], +) -> None: + _ = pipe, start_time + emit_api( + ctx, + summary="读取管道健康预测成功", + method="GET", + path="/composite/pipeline-health-prediction", + params={ + "network_name": require_network(runtime_context(ctx)), + "query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + require_network_ctx=True, + ) + + +def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: + mapping = { + ("device", "schema"): ("/getscadadeviceschema/", {}), + ("device", "get"): ("/getscadadevice/", {"id_param": "id"}), + ("device", "list"): ("/getallscadadevices/", {}), + ("device-data", "schema"): ("/getscadadevicedataschema/", {}), + ("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}), + ("element", "schema"): ("/getscadaelementschema/", {}), + ("element", "get"): ("/getscadaelement/", {"id_param": "id"}), + ("element", "list"): ("/getscadaelements/", {}), + ("info", "schema"): ("/getscadainfoschema/", {}), + ("info", "get"): ("/getscadainfo/", {"id_param": "id"}), + ("info", "list"): ("/getallscadainfo/", {}), + } + result = mapping.get((kind, action)) + if result is None: + raise CLIError( + "CLI 参数错误", + code="INVALID_SCADA_KIND", + message=f"unsupported scada {action} kind: {kind}", + exit_code=2, + ) + return result + + +@data_scada_app.command("schema") +def data_scada_schema( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], +) -> None: + runtime = runtime_context(ctx) + path, _ = _scada_mapping(kind, "schema") + emit_api( + ctx, + summary="读取 SCADA schema 成功", + method="GET", + path=path, + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scada_app.command("get") +def data_scada_get( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], + id: Annotated[str, typer.Option("--id", help="记录 ID")], +) -> None: + runtime = runtime_context(ctx) + path, meta = _scada_mapping(kind, "get") + params = {"network": require_network(runtime), meta["id_param"]: id} + emit_api( + ctx, + summary="读取 SCADA 数据成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scada_app.command("list") +def data_scada_list( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="device|element|info")], +) -> None: + runtime = runtime_context(ctx) + path, _ = _scada_mapping(kind, "list") + emit_api( + ctx, + summary="读取 SCADA 列表成功", + method="GET", + path=path, + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scheme_app.command("schema") +def data_scheme_schema(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案 schema 成功", + method="GET", + path="/getschemeschema/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scheme_app.command("get") +def data_scheme_get( + ctx: typer.Context, + name: Annotated[str, typer.Option("--name", help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案成功", + method="GET", + path="/getscheme/", + params={"network": require_network(runtime), "schema_name": name}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scheme_app.command("list") +def data_scheme_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案列表成功", + method="GET", + path="/getallschemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_extension_app.command("keys") +def data_extension_keys(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取扩展数据键成功", + method="GET", + path="/getallextensiondatakeys/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_extension_app.command("get") +def data_extension_get( + ctx: typer.Context, + key: Annotated[str, typer.Option("--key", help="扩展键")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取扩展数据成功", + method="GET", + path="/getextensiondata/", + params={"network": require_network(runtime), "key": key}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_extension_app.command("list") +def data_extension_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取扩展数据列表成功", + method="GET", + path="/getallextensiondata/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_misc_app.command("sensor-placements") +def data_misc_sensor_placements(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取传感器位置成功", + method="GET", + path="/getallsensorplacements/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_misc_app.command("burst-location-results") +def data_misc_burst_location_results(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管定位结果成功", + method="GET", + path="/getallburstlocateresults/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) diff --git a/cli/tjwater_agent_cli/commands_project.py b/cli/tjwater_agent_cli/commands_project.py new file mode 100644 index 0000000..4345967 --- /dev/null +++ b/cli/tjwater_agent_cli/commands_project.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any + +import typer + +from .apps import component_option_app, network_app, project_app +from .common import emit_api, runtime_context +from .core import CLIError, emit_success, request_bytes, request_json, require_network + + +@project_app.command("list") +def project_list(ctx: typer.Context) -> None: + emit_api(ctx, summary="读取项目列表成功", method="GET", path="/meta/projects", require_auth=True) + + +@project_app.command("info") +def project_info(ctx: typer.Context) -> None: + emit_api( + ctx, + summary="读取项目信息成功", + method="GET", + path="/meta/project", + require_auth=True, + require_project=True, + ) + + +@project_app.command("db-health") +def project_db_health(ctx: typer.Context) -> None: + emit_api( + ctx, + summary="读取数据库健康状态成功", + method="GET", + path="/meta/db/health", + require_auth=True, + require_project=True, + ) + + +@project_app.command("data") +def project_data( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="scada-info|scheme-list|burst-locate-result")], +) -> None: + kind_map = { + "scada-info": "/scada-info", + "scheme-list": "/scheme-list", + "burst-locate-result": "/burst-locate-result", + } + path = kind_map.get(kind) + if path is None: + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="kind must be one of: scada-info, scheme-list, burst-locate-result", + exit_code=2, + ) + emit_api( + ctx, + summary="读取项目数据成功", + method="GET", + path=path, + require_auth=True, + require_project=True, + ) + + +@project_app.command("export-inp") +def project_export_inp( + ctx: typer.Context, + output: Annotated[Path, typer.Option("--output", help="本地输出路径")], +) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + output.parent.mkdir(parents=True, exist_ok=True) + temp_name = f"{output.stem}-{runtime.request_id}.inp" + _, duration_dump = request_json( + runtime, + method="GET", + path="/dumpinp/", + params={"network": network, "inp": temp_name}, + require_auth=True, + require_network_ctx=True, + ) + content, duration_download = request_bytes( + runtime, + method="GET", + path="/downloadinp/", + params={"name": temp_name}, + require_auth=True, + require_network_ctx=True, + ) + output.write_bytes(content) + emit_success( + summary="导出 INP 成功", + data={"output": str(output), "bytes": len(content)}, + ctx=runtime, + duration_ms=duration_dump + duration_download, + next_commands=["tjwater project info"], + ) + + +@network_app.command("get-node-properties") +def network_get_node_properties( + ctx: typer.Context, + node: Annotated[str, typer.Option("--node", help="节点 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取节点属性成功", + method="GET", + path="/getnodeproperties/", + params={"network": require_network(runtime), "node": node}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-link-properties") +def network_get_link_properties( + ctx: typer.Context, + link: Annotated[str, typer.Option("--link", help="管线 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取管线属性成功", + method="GET", + path="/getlinkproperties/", + params={"network": require_network(runtime), "link": link}, + require_auth=True, + require_network_ctx=True, + ) + + +def _component_option_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: + if kind == "time": + return "/gettimeschema", {} + if kind == "energy": + return "/getenergyschema/", {} + if kind == "pump-energy": + if not pump: + raise CLIError( + "CLI 参数错误", + code="PUMP_REQUIRED", + message="--pump is required when --kind pump-energy", + exit_code=2, + ) + return "/getpumpenergyschema/", {"pump": pump} + if kind == "network": + return "/getoptionschema/", {} + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="kind must be one of: time, energy, pump-energy, network", + exit_code=2, + ) + + +def _component_option_get_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: + if kind == "time": + return "/gettimeproperties/", {} + if kind == "energy": + return "/getenergyproperties/", {} + if kind == "pump-energy": + if not pump: + raise CLIError( + "CLI 参数错误", + code="PUMP_REQUIRED", + message="--pump is required when --kind pump-energy", + exit_code=2, + ) + return "/getpumpenergyproperties/", {"pump": pump} + if kind == "network": + return "/getoptionproperties/", {} + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="kind must be one of: time, energy, pump-energy, network", + exit_code=2, + ) + + +@component_option_app.command("schema") +def component_option_schema( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path, extra = _component_option_mapping(kind, pump) + params = {"network": require_network(runtime)} | extra + emit_api( + ctx, + summary="读取选项 schema 成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@component_option_app.command("get") +def component_option_get( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path, extra = _component_option_get_mapping(kind, pump) + params = {"network": require_network(runtime)} | extra + emit_api( + ctx, + summary="读取选项属性成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) diff --git a/cli/tjwater_agent_cli/common.py b/cli/tjwater_agent_cli/common.py new file mode 100644 index 0000000..b03624c --- /dev/null +++ b/cli/tjwater_agent_cli/common.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer + +from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_json + + +def runtime_context(ctx: typer.Context): + obj = ctx.obj or {} + return build_runtime_context( + server=obj.get("server"), + auth_context_path=obj.get("auth_context"), + scheme=obj.get("scheme"), + timeout=obj.get("timeout", DEFAULT_TIMEOUT), + request_id=obj.get("request_id"), + ) + + +def emit_api( + ctx: typer.Context, + *, + summary: str, + method: str, + path: str, + params: dict[str, Any] | None = None, + json_body: Any = None, + require_auth: bool = True, + require_project: bool = False, + require_network_ctx: bool = False, + require_username_ctx: bool = False, + next_commands: list[str] | None = None, +) -> None: + runtime = runtime_context(ctx) + data, duration_ms = request_json( + runtime, + method=method, + path=path, + params=params, + json_body=json_body, + require_auth=require_auth, + require_project=require_project, + require_network_ctx=require_network_ctx, + require_username_ctx=require_username_ctx, + ) + emit_success( + summary=summary, + data=data, + ctx=runtime, + duration_ms=duration_ms, + next_commands=next_commands, + ) diff --git a/cli/tjwater_agent_cli/core.py b/cli/tjwater_agent_cli/core.py new file mode 100644 index 0000000..1881042 --- /dev/null +++ b/cli/tjwater_agent_cli/core.py @@ -0,0 +1,647 @@ +from __future__ import annotations + +import json +import os +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +import requests +import typer + +SCHEMA_VERSION = "tjwater-cli/v1" +DEFAULT_TIMEOUT = 60 +DEFAULT_SERVER = "http://192.168.1.114:8000" + + +class CLIError(Exception): + def __init__( + self, + summary: str, + *, + code: str, + message: str, + exit_code: int, + retryable: bool = False, + next_commands: list[str] | None = None, + data: Any = None, + ) -> None: + super().__init__(message) + self.summary = summary + self.code = code + self.message = message + self.exit_code = exit_code + self.retryable = retryable + self.next_commands = next_commands or [] + self.data = data + + +@dataclass(frozen=True) +class AuthContext: + server: str | None = None + access_token: str | None = None + project_id: str | None = None + user_id: str | None = None + username: str | None = None + network: str | None = None + headers: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RuntimeContext: + server: str | None + auth: AuthContext + scheme: str | None + timeout: int + request_id: str + + +@dataclass(frozen=True) +class CommandOptionDoc: + name: str + description: str + required: bool = False + repeated: bool = False + default: Any = None + + +@dataclass(frozen=True) +class CommandDoc: + path: tuple[str, ...] + summary: str + description: str + options: tuple[CommandOptionDoc, ...] = () + examples: tuple[str, ...] = () + next_commands: tuple[str, ...] = () + output: str = "标准 JSON 输出" + + +def _read_json_file(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CLIError( + "认证失败", + code="AUTH_CONTEXT_NOT_FOUND", + message=f"auth context file not found: {path}", + exit_code=3, + ) from exc + except json.JSONDecodeError as exc: + raise CLIError( + "认证失败", + code="AUTH_CONTEXT_INVALID", + message=f"auth context file is not valid JSON: {path}", + exit_code=3, + ) from exc + + +def _pick(mapping: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + value = mapping.get(key) + if value not in (None, ""): + return value + return None + + +def load_auth_context(auth_context_path: Path | None) -> AuthContext: + raw: dict[str, Any] = {} + if auth_context_path is not None: + raw = _read_json_file(auth_context_path) + else: + extra_headers = os.getenv("TJWATER_EXTRA_HEADERS") + raw = { + "server": os.getenv("TJWATER_SERVER"), + "access_token": os.getenv("TJWATER_ACCESS_TOKEN"), + "project_id": os.getenv("TJWATER_PROJECT_ID"), + "user_id": os.getenv("TJWATER_USER_ID"), + "username": os.getenv("TJWATER_USERNAME"), + "network": os.getenv("TJWATER_NETWORK"), + "headers": json.loads(extra_headers) if extra_headers else {}, + } + + headers = raw.get("headers") or {} + if not isinstance(headers, dict): + raise CLIError( + "认证失败", + code="AUTH_CONTEXT_INVALID", + message="auth context headers must be a JSON object", + exit_code=3, + ) + + return AuthContext( + server=_pick(raw, "server", "base_url"), + access_token=_pick(raw, "access_token", "token", "accessToken"), + project_id=_pick(raw, "project_id", "projectId", "x_project_id"), + user_id=_pick(raw, "user_id", "userId", "x_user_id"), + username=_pick(raw, "username", "preferred_username"), + network=_pick(raw, "network", "project_code", "projectCode", "project"), + headers={str(key): str(value) for key, value in headers.items()}, + ) + + +def build_runtime_context( + *, + server: str | None, + auth_context_path: Path | None, + scheme: str | None, + timeout: int, + request_id: str | None, +) -> RuntimeContext: + auth = load_auth_context(auth_context_path) + resolved_request_id = request_id or str(uuid.uuid4()) + return RuntimeContext( + server=server or auth.server or DEFAULT_SERVER, + auth=auth, + scheme=scheme, + timeout=timeout, + request_id=resolved_request_id, + ) + + +def require_server(ctx: RuntimeContext) -> str: + if ctx.server: + return ctx.server.rstrip("/") + raise CLIError( + "认证失败", + code="SERVER_REQUIRED", + message="missing server URL; use --server or include server in auth context", + exit_code=3, + ) + + +def require_access_token(ctx: RuntimeContext) -> str: + if ctx.auth.access_token: + return ctx.auth.access_token + raise CLIError( + "认证失败", + code="UNAUTHENTICATED", + message="missing access token for agent context", + exit_code=3, + next_commands=["tjwater --auth-context /path/to/auth-context.json"], + ) + + +def require_project_id(ctx: RuntimeContext) -> str: + if ctx.auth.project_id: + return ctx.auth.project_id + raise CLIError( + "认证失败", + code="PROJECT_CONTEXT_REQUIRED", + message="missing project_id for agent context", + exit_code=3, + next_commands=["add project_id to the auth context file"], + ) + + +def require_network(ctx: RuntimeContext) -> str: + if ctx.auth.network: + return ctx.auth.network + raise CLIError( + "认证失败", + code="NETWORK_CONTEXT_REQUIRED", + message="missing network in auth context for legacy network-based endpoints", + exit_code=3, + next_commands=["add network to the auth context file"], + ) + + +def require_username(ctx: RuntimeContext) -> str: + if ctx.auth.username: + return ctx.auth.username + raise CLIError( + "认证失败", + code="USERNAME_CONTEXT_REQUIRED", + message="missing username in auth context", + exit_code=3, + next_commands=["add username to the auth context file"], + ) + + +def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None: + scheme = explicit_scheme or ctx.scheme + if required and not scheme: + raise CLIError( + "CLI 参数错误", + code="SCHEME_REQUIRED", + message="missing scheme; use --scheme", + exit_code=2, + ) + return scheme + + +def parse_time_with_timezone(value: str, *, option_name: str) -> datetime: + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise CLIError( + "CLI 参数错误", + code="INVALID_TIME", + message=f"{option_name} must be a valid ISO 8601 / RFC 3339 timestamp", + exit_code=2, + ) from exc + if parsed.tzinfo is None: + raise CLIError( + "CLI 参数错误", + code="TIMEZONE_REQUIRED", + message=f"{option_name} must include an explicit timezone offset", + exit_code=2, + ) + return parsed + + +def read_json_input(path: Path, *, label: str) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CLIError( + "CLI 参数错误", + code="INPUT_NOT_FOUND", + message=f"{label} file not found: {path}", + exit_code=2, + ) from exc + except json.JSONDecodeError as exc: + raise CLIError( + "CLI 参数错误", + code="INPUT_INVALID_JSON", + message=f"{label} file must be valid JSON: {path}", + exit_code=2, + ) from exc + + +def parse_burst_file(path: Path) -> tuple[list[str], list[float]]: + raw = read_json_input(path, label="burst") + if isinstance(raw, dict) and "bursts" in raw: + raw = raw["bursts"] + if isinstance(raw, dict) and "burst_ID" in raw and "burst_size" in raw: + ids = [str(item) for item in raw["burst_ID"]] + sizes = [float(item) for item in raw["burst_size"]] + if len(ids) != len(sizes): + raise CLIError( + "CLI 参数错误", + code="BURST_FILE_INVALID", + message="burst file burst_ID and burst_size must have the same length", + exit_code=2, + ) + return ids, sizes + if isinstance(raw, list): + ids: list[str] = [] + sizes: list[float] = [] + for item in raw: + if not isinstance(item, dict) or "id" not in item or "size" not in item: + raise CLIError( + "CLI 参数错误", + code="BURST_FILE_INVALID", + message="burst file items must contain id and size", + exit_code=2, + ) + ids.append(str(item["id"])) + sizes.append(float(item["size"])) + return ids, sizes + raise CLIError( + "CLI 参数错误", + code="BURST_FILE_INVALID", + message="burst file must be a JSON array or object with burst_ID/burst_size", + exit_code=2, + ) + + +def parse_valve_setting_file(path: Path) -> tuple[list[str], list[float]]: + raw = read_json_input(path, label="valve-setting") + if isinstance(raw, dict) and "valves" in raw and "valves_k" in raw: + valves = [str(item) for item in raw["valves"]] + openings = [float(item) for item in raw["valves_k"]] + if len(valves) != len(openings): + raise CLIError( + "CLI 参数错误", + code="VALVE_SETTING_INVALID", + message="valves and valves_k must have the same length", + exit_code=2, + ) + return valves, openings + if isinstance(raw, list): + valves: list[str] = [] + openings: list[float] = [] + for item in raw: + if not isinstance(item, dict) or "valve" not in item or "opening" not in item: + raise CLIError( + "CLI 参数错误", + code="VALVE_SETTING_INVALID", + message="valve-setting items must contain valve and opening", + exit_code=2, + ) + valves.append(str(item["valve"])) + openings.append(float(item["opening"])) + return valves, openings + raise CLIError( + "CLI 参数错误", + code="VALVE_SETTING_INVALID", + message="valve-setting file must be a JSON array or object with valves/valves_k", + exit_code=2, + ) + + +def parse_optional_dataset_file(path: Path | None, *, label: str) -> Any: + if path is None: + return None + return read_json_input(path, label=label) + + +def build_headers( + ctx: RuntimeContext, + *, + require_auth: bool, + require_project: bool, +) -> dict[str, str]: + headers = { + "Accept": "application/json, text/plain, */*", + "X-Request-Id": ctx.request_id, + } + headers.update(ctx.auth.headers) + if require_auth: + headers["Authorization"] = f"Bearer {require_access_token(ctx)}" + elif ctx.auth.access_token: + headers["Authorization"] = f"Bearer {ctx.auth.access_token}" + if require_project: + headers["X-Project-Id"] = require_project_id(ctx) + elif ctx.auth.project_id: + headers["X-Project-Id"] = ctx.auth.project_id + if ctx.auth.user_id: + headers["X-User-Id"] = ctx.auth.user_id + return headers + + +def _extract_error_message(response: requests.Response) -> str: + try: + payload = response.json() + except ValueError: + text = response.text.strip() + return text or f"http {response.status_code}" + + if isinstance(payload, dict): + detail = payload.get("detail") + if isinstance(detail, str): + return detail + if isinstance(detail, list): + return "; ".join(json.dumps(item, ensure_ascii=False) for item in detail) + message = payload.get("message") + if isinstance(message, str): + return message + return json.dumps(payload, ensure_ascii=False) + + +def map_http_status_to_exit_code(status_code: int) -> int: + if status_code in (400, 422): + return 2 + if status_code == 401: + return 3 + if status_code == 403: + return 4 + if status_code == 404: + return 5 + if status_code in (409, 412): + return 6 + return 7 + + +def _parse_response_body(response: requests.Response) -> Any: + if response.status_code == 204 or not response.content: + return {} + content_type = response.headers.get("content-type", "").lower() + if "application/json" in content_type: + payload = response.json() + if isinstance(payload, dict) and payload.get("status") == "error": + raise CLIError( + "服务端错误", + code="SERVER_ERROR", + message=str(payload.get("message") or "server returned error status"), + exit_code=7, + data=payload, + ) + return payload + text = response.text + if text: + return {"report": text} + return {} + + +def request_json( + ctx: RuntimeContext, + *, + method: str, + path: str, + params: dict[str, Any] | None = None, + json_body: Any = None, + require_auth: bool = True, + require_project: bool = False, + require_network_ctx: bool = False, + require_username_ctx: bool = False, +) -> tuple[Any, int]: + require_server(ctx) + if require_network_ctx: + require_network(ctx) + if require_username_ctx: + require_username(ctx) + + url = f"{require_server(ctx)}/api/v1{path}" + headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) + started = time.monotonic() + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + json=json_body, + headers=headers, + timeout=ctx.timeout, + ) + except requests.Timeout as exc: + raise CLIError( + "请求超时", + code="REQUEST_TIMEOUT", + message=f"request timed out after {ctx.timeout} seconds", + exit_code=7, + retryable=True, + ) from exc + except requests.RequestException as exc: + raise CLIError( + "连接失败", + code="REQUEST_FAILED", + message=str(exc), + exit_code=7, + retryable=True, + ) from exc + duration_ms = int((time.monotonic() - started) * 1000) + + if not response.ok: + raise CLIError( + "请求失败", + code=f"HTTP_{response.status_code}", + message=_extract_error_message(response), + exit_code=map_http_status_to_exit_code(response.status_code), + retryable=response.status_code >= 500, + ) + return _parse_response_body(response), duration_ms + + +def request_bytes( + ctx: RuntimeContext, + *, + method: str, + path: str, + params: dict[str, Any] | None = None, + require_auth: bool = True, + require_project: bool = False, + require_network_ctx: bool = False, +) -> tuple[bytes, int]: + require_server(ctx) + if require_network_ctx: + require_network(ctx) + + url = f"{require_server(ctx)}/api/v1{path}" + headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) + started = time.monotonic() + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + headers=headers, + timeout=ctx.timeout, + ) + except requests.Timeout as exc: + raise CLIError( + "请求超时", + code="REQUEST_TIMEOUT", + message=f"request timed out after {ctx.timeout} seconds", + exit_code=7, + retryable=True, + ) from exc + except requests.RequestException as exc: + raise CLIError( + "连接失败", + code="REQUEST_FAILED", + message=str(exc), + exit_code=7, + retryable=True, + ) from exc + duration_ms = int((time.monotonic() - started) * 1000) + + if not response.ok: + raise CLIError( + "请求失败", + code=f"HTTP_{response.status_code}", + message=_extract_error_message(response), + exit_code=map_http_status_to_exit_code(response.status_code), + retryable=response.status_code >= 500, + ) + return response.content, duration_ms + + +def build_success_payload( + *, + summary: str, + data: Any, + server: str | None, + request_id: str, + duration_ms: int, + next_commands: list[str] | None = None, +) -> dict[str, Any]: + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": summary, + "data": data, + "metadata": { + "request_id": request_id, + "server": server, + "duration_ms": duration_ms, + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + }, + "next_commands": next_commands or [], + } + + +def build_failure_payload( + *, + summary: str, + code: str, + message: str, + retryable: bool, + server: str | None, + request_id: str | None, + next_commands: list[str] | None = None, + data: Any = None, +) -> dict[str, Any]: + return { + "ok": False, + "schema_version": SCHEMA_VERSION, + "summary": summary, + "error": { + "code": code, + "message": message, + "retryable": retryable, + }, + "data": data, + "metadata": { + "request_id": request_id, + "server": server, + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + }, + "next_commands": next_commands or [], + } + + +def emit_success( + *, + summary: str, + data: Any, + ctx: RuntimeContext, + duration_ms: int, + next_commands: list[str] | None = None, +) -> None: + typer.echo( + json.dumps( + build_success_payload( + summary=summary, + data=data, + server=ctx.server, + request_id=ctx.request_id, + duration_ms=duration_ms, + next_commands=next_commands, + ), + ensure_ascii=False, + ) + ) + + +def emit_failure( + *, + summary: str, + code: str, + message: str, + exit_code: int, + retryable: bool, + server: str | None, + request_id: str | None, + next_commands: list[str] | None = None, + data: Any = None, +) -> int: + typer.echo( + json.dumps( + build_failure_payload( + summary=summary, + code=code, + message=message, + retryable=retryable, + server=server, + request_id=request_id, + next_commands=next_commands, + data=data, + ), + ensure_ascii=False, + ) + ) + return exit_code diff --git a/cli/tjwater_agent_cli/helping.py b/cli/tjwater_agent_cli/helping.py new file mode 100644 index 0000000..1461fd4 --- /dev/null +++ b/cli/tjwater_agent_cli/helping.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import json +from typing import Annotated, Any + +import click +import typer + +from .apps import GROUP_HELP_APPS, TOP_LEVEL_COMMANDS, app +from .core import CLIError +from .registry import ( + get_command_doc, + get_group_summary, + has_subcommands, + is_hidden_path, + list_capabilities, + list_subcommands, +) + + +def _click_root_command() -> click.Command: + # Must stay lazy: the click tree is only complete after command modules import. + return typer.main.get_command(app) + + +def _normalize_command_path(tokens: list[str]) -> tuple[str, ...]: + while tokens and tokens[0] not in TOP_LEVEL_COMMANDS: + tokens = tokens[1:] + return tuple(tokens) + + +def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]: + if click_ctx is None: + return () + return _normalize_command_path(click_ctx.command_path.split()) + + +def _build_click_context(path: tuple[str, ...]) -> click.Context | None: + root = _click_root_command() + ctx: click.Context = click.Context(root, info_name="tjwater") + command: click.Command = root + for token in path: + if not isinstance(command, click.Group): + return None + next_command = command.commands.get(token) + if next_command is None: + return None + ctx = click.Context(next_command, info_name=token, parent=ctx) + command = next_command + return ctx + + +def build_usage(path: tuple[str, ...]) -> str | None: + ctx = _build_click_context(path) + if ctx is None: + return None + parts = ["tjwater", *path] + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option): + continue + if "--help" in parameter.opts: + continue + option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + if parameter.is_flag: + parts.append(f"--{option_name}" if parameter.required else f"[--{option_name}]") + continue + placeholder = option_name.upper().replace("-", "_") + if parameter.required: + parts.extend([f"--{option_name}", f"<{placeholder}>"]) + else: + parts.append(f"[--{option_name} <{placeholder}>]") + return " ".join(parts) + + +def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]: + ctx = _build_click_context(path) + if ctx is None: + return [] + options: list[dict[str, Any]] = [] + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option): + continue + if "--help" in parameter.opts: + continue + cli_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + options.append( + { + "name": cli_name, + "description": parameter.help or "", + "required": parameter.required, + "repeated": parameter.multiple, + "default": parameter.default, + } + ) + return options + + +def _sample_option_value(path: tuple[str, ...], option_name: str) -> str: + path_specific_samples: dict[tuple[tuple[str, ...], str], str] = { + (("project", "data"), "kind"): "scada-info", + (("component", "option", "schema"), "kind"): "time", + (("component", "option", "get"), "kind"): "time", + (("data", "timeseries", "composite"), "kind"): "scada-simulation", + (("data", "scada", "schema"), "kind"): "device", + (("data", "scada", "get"), "kind"): "device", + (("data", "scada", "list"), "kind"): "device", + } + if (path, option_name) in path_specific_samples: + return path_specific_samples[(path, option_name)] + if option_name == "start-time": + return "2025-01-02T03:04:05+08:00" + if option_name == "end-time": + return "2025-01-02T04:04:05+08:00" + if option_name == "date": + return "2025-01-02" + if option_name == "duration": + return "30" + if option_name == "kind": + return "time" + if option_name == "mode": + return "close" + if option_name == "scheme": + return "baseline" + if option_name == "output": + return "./demo.inp" if "export-inp" in path else "./output.json" + if option_name == "pump": + return "PUMP-1" + if option_name == "node": + return "J1" + if option_name == "source-node": + return "J1" + if option_name == "drainage-node": + return "J2" + if option_name in {"link", "pipe", "pipe-id", "element-id", "element"}: + return "P1" + if option_name == "flow": + return "120.5" + if option_name == "concentration": + return "0.8" + if option_name == "device-id": + return "SCADA-001" + if option_name == "burst-file": + return "./burst.json" + if option_name == "valve-setting-file": + return "./valves.json" + if option_name.endswith("-file"): + return "./input.json" + if option_name.endswith("-id"): + return "demo-id" + return "demo" + + +def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None = None) -> str: + ctx = _build_click_context(path) + required_option_names: list[str] = [] + if ctx is not None: + required_option_names = [ + next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + for parameter in ctx.command.params + if isinstance(parameter, click.Option) and "--help" not in parameter.opts and parameter.required + ] + if existing_examples: + for example in existing_examples: + has_auth = "--auth-context" in example + has_required_options = all(f"--{option_name}" in example for option_name in required_option_names) + if has_auth and has_required_options: + return example + parts = ["tjwater", "--auth-context", "auth.json", *path] + if ctx is None: + return " ".join(parts) + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option): + continue + if "--help" in parameter.opts or not parameter.required: + continue + option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + parts.extend([f"--{option_name}", _sample_option_value(path, option_name)]) + return " ".join(parts) + + +def _enrich_leaf_payload(payload: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]: + enriched = dict(payload) + enriched["usage"] = build_usage(path) or payload.get("usage") + click_options = _click_option_docs(path) + if click_options: + enriched["options"] = click_options + enriched["examples"] = payload.get("examples") or [] + if not enriched["examples"] or all("<" in example and ">" in example for example in enriched["examples"]): + enriched["examples"] = [_build_example(path, existing_examples=enriched["examples"])] + return enriched + + +def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]: + enriched = dict(payload) + commands: list[dict[str, Any]] = [] + for command in payload.get("commands", []): + command_item = dict(command) + path = tuple(command_item["command"].split()) + doc = get_command_doc(path) + if doc is None and has_subcommands(path): + command_item["usage"] = f"tjwater {' '.join(path)} help" + command_item["example"] = f"tjwater {' '.join(path)} help" + else: + existing_examples = [] if doc is None else list(doc.get("examples", [])) + command_item["usage"] = build_usage(path) or command_item.get("usage") + command_item["example"] = _build_example(path, existing_examples=existing_examples) + commands.append(command_item) + enriched["commands"] = commands + return enriched + + +def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, bool]: + if not path: + return list_capabilities(), True + payload = get_command_doc(path) + if payload is not None: + return _enrich_leaf_payload(payload, path), False + if has_subcommands(path): + return _enrich_index_payload(list_subcommands(path, get_group_summary(path))), True + return None, False + + +def emit_help_payload(payload: dict[str, Any], *, json_output: bool, is_index: bool) -> None: + if json_output: + typer.echo(json.dumps(payload, ensure_ascii=False)) + else: + typer.echo(render_help_text(payload, is_index=is_index)) + + +def merge_next_commands(*groups: list[str] | None) -> list[str]: + merged: list[str] = [] + seen: set[str] = set() + for group in groups: + for command in group or []: + if command in seen: + continue + seen.add(command) + merged.append(command) + return merged + + +def merge_error_data(primary: Any, secondary: Any) -> Any: + if primary is None: + return secondary + if secondary is None: + return primary + if isinstance(primary, dict) and isinstance(secondary, dict): + return {**secondary, **primary} + return primary + + +def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str]]: + command_path = context_command_path(click_ctx) + usage = build_usage(command_path) if command_path else None + if command_path: + if command_path[-1] == "help": + group_path = command_path[:-1] + if group_path: + return ( + { + "command_group": " ".join(group_path), + "usage": f"tjwater {' '.join(group_path)} help", + "examples": [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], + }, + merge_next_commands( + [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], + ["tjwater help"], + ), + ) + payload, is_index = resolve_help_payload(command_path) + if payload is not None and not is_index: + return ( + { + "command": payload["command"], + "usage": payload.get("usage") or usage, + "examples": payload.get("examples", []), + }, + merge_next_commands([f"tjwater help {' '.join(command_path)}"], ["tjwater help"]), + ) + if payload is not None and is_index: + return ( + { + "command_group": " ".join(command_path), + "usage": f"tjwater {' '.join(command_path)} help", + "examples": [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], + }, + merge_next_commands( + [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], + ["tjwater help"], + ), + ) + return ({"usage": usage} if usage else None, ["tjwater help"]) + + +def classify_click_error(exc: click.ClickException) -> tuple[str, str]: + if isinstance(exc, click.NoSuchOption): + return "未知选项", "UNKNOWN_OPTION" + if isinstance(exc, click.MissingParameter): + return "缺少参数", "MISSING_PARAMETER" + if isinstance(exc, click.BadParameter): + return "参数无效", "INVALID_PARAMETER" + message = exc.format_message() + if "No such command" in message: + return "未找到命令", "COMMAND_NOT_FOUND" + return "CLI 参数错误", "USAGE_ERROR" + + +def render_help_text(payload: dict[str, Any], *, is_index: bool) -> str: + lines: list[str] = [str(payload.get("summary", ""))] + if is_index: + is_top_level = payload.get("menu_level") == 1 + lines.append("") + lines.append("Commands:") + for command in payload.get("commands", []): + lines.append(f" {command['command']}: {command['summary']}") + if not is_top_level and command.get("usage"): + lines.append(f" usage: {command['usage']}") + if not is_top_level and command.get("example"): + lines.append(f" example: {command['example']}") + lines.append("") + if is_top_level: + lines.append("Use `tjwater help` to see subcommands.") + else: + lines.append("Use `tjwater help --json` for structured output.") + return "\n".join(lines) + + lines.append("") + lines.append(f"Command: {payload['command']}") + lines.append(f"Description: {payload['description']}") + if payload.get("usage"): + lines.append(f"Usage: {payload['usage']}") + + options = payload.get("options", []) + if options: + lines.append("") + lines.append("Options:") + for option in options: + suffix = " (required)" if option.get("required") else "" + lines.append(f" --{option['name']}{suffix}: {option['description']}") + + examples = payload.get("examples", []) + if examples: + lines.append("") + lines.append("Examples:") + for example in examples: + lines.append(f" {example}") + + lines.append("") + lines.append("Use `tjwater help --json` for structured output.") + return "\n".join(lines) + + +def make_group_help_handler(path_prefix: tuple[str, ...]): + def group_help( + json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, + ) -> None: + payload, is_index = resolve_help_payload(path_prefix) + if payload is None: + raise CLIError( + "未找到命令", + code="COMMAND_NOT_FOUND", + message=f"unknown command path: {' '.join(path_prefix)}", + exit_code=2, + next_commands=["tjwater help"], + ) + emit_help_payload(payload, json_output=json_output, is_index=is_index) + + group_help.__name__ = f"{'_'.join(path_prefix)}_help" + return group_help + + +def register_group_help_commands() -> None: + for group_app, path_prefix in GROUP_HELP_APPS: + group_app.command("help")(make_group_help_handler(path_prefix)) + + +def apply_typer_help_metadata() -> None: + app.help = "TJWater agent CLI" + app.short_help = "TJWater agent CLI" + for group_app, path_prefix in GROUP_HELP_APPS: + for command_info in group_app.registered_commands: + command_path = (*path_prefix, command_info.name) + if command_info.name == "help": + command_info.help = f"显示 {' '.join(path_prefix)} 的帮助信息。" + command_info.short_help = command_info.help + command_info.hidden = False + continue + payload = get_command_doc(command_path) + command_info.help = None if payload is None else str(payload.get("summary", "")) + command_info.short_help = command_info.help + command_info.hidden = is_hidden_path(command_path) + for group_info in group_app.registered_groups: + group_path = (*path_prefix, group_info.name) + summary = get_group_summary(group_path) + group_info.help = summary + group_info.short_help = summary + group_info.hidden = is_hidden_path(group_path) + for group_info in app.registered_groups: + group_path = (group_info.name,) + summary = get_group_summary(group_path) + group_info.help = summary + group_info.short_help = summary + group_info.hidden = is_hidden_path(group_path) diff --git a/cli/tjwater_agent_cli/main.py b/cli/tjwater_agent_cli/main.py new file mode 100644 index 0000000..ebc3d14 --- /dev/null +++ b/cli/tjwater_agent_cli/main.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Annotated + +import click +import typer +from click.exceptions import NoArgsIsHelpError + +from . import commands_analysis, commands_data, commands_project # noqa: F401 +from .apps import app +from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure +from .helping import ( + apply_typer_help_metadata, + build_error_guidance, + classify_click_error, + emit_help_payload, + merge_error_data, + merge_next_commands, + register_group_help_commands, + resolve_help_payload, +) + + +@app.callback() +def root_callback( + ctx: typer.Context, + server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None, + auth_context: Annotated[Path | None, typer.Option("--auth-context", help="认证上下文 JSON 文件")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None, + timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT, + request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None, +) -> None: + ctx.obj = { + "server": server, + "auth_context": auth_context, + "scheme": scheme, + "timeout": timeout, + "request_id": request_id, + } + + +register_group_help_commands() + + +@app.command("help", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) +def help_command( + ctx: typer.Context, + json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, +) -> None: + command_path = list(ctx.args) + payload, is_index = resolve_help_payload(tuple(command_path)) + if payload is None: + emit_failure( + summary="未找到命令", + code="COMMAND_NOT_FOUND", + message=f"unknown command path: {' '.join(command_path)}", + exit_code=2, + retryable=False, + server=None, + request_id=None, + data={ + "usage": "tjwater help ", + "examples": ["tjwater help simulation run", "tjwater simulation help"], + }, + next_commands=["tjwater help", "tjwater help simulation"], + ) + raise typer.Exit(code=2) + emit_help_payload(payload, json_output=json_output, is_index=is_index) + + +# Must run at import time because tests call runner.invoke(app, ...) directly. +apply_typer_help_metadata() + + +def main(argv: list[str] | None = None) -> int: + try: + app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater", standalone_mode=False) + return 0 + except CLIError as exc: + click_ctx = click.get_current_context(silent=True) + error_data, next_commands = build_error_guidance(click_ctx) + return emit_failure( + summary=exc.summary, + code=exc.code, + message=exc.message, + exit_code=exc.exit_code, + retryable=exc.retryable, + server=None, + request_id=None, + next_commands=merge_next_commands(exc.next_commands, next_commands), + data=merge_error_data(exc.data, error_data), + ) + except NoArgsIsHelpError: + return 0 + except click.ClickException as exc: + click_ctx = click.get_current_context(silent=True) or exc.ctx + error_data, next_commands = build_error_guidance(click_ctx) + summary, code = classify_click_error(exc) + return emit_failure( + summary=summary, + code=code, + message=exc.format_message(), + exit_code=2, + retryable=False, + server=None, + request_id=None, + next_commands=next_commands, + data=error_data, + ) + + +def console_entry() -> None: + raise SystemExit(main()) diff --git a/cli/tjwater_agent_cli/registry.py b/cli/tjwater_agent_cli/registry.py new file mode 100644 index 0000000..d83d782 --- /dev/null +++ b/cli/tjwater_agent_cli/registry.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION + +GROUP_SUMMARIES: dict[tuple[str, ...], str] = { + ("project",): "项目与项目级元数据相关命令。", + ("network",): "管网节点、管线等基础属性查询命令。", + ("component",): "组件选项与配置读取命令。", + ("component", "option"): "组件选项查询命令。", + ("simulation",): "模拟运行与调度相关命令。", + ("analysis",): "分析计算与诊断相关命令。", + ("analysis", "leakage"): "漏损分析相关命令。", + ("analysis", "leakage", "schemes"): "漏损方案查询命令。", + ("analysis", "burst-detection"): "爆管检测相关命令。", + ("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。", + ("analysis", "burst-location"): "爆管定位相关命令。", + ("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。", + ("analysis", "risk"): "风险分析相关命令。", + ("analysis", "sensor-placement"): "传感器选址相关命令。", + ("data",): "时序、SCADA、方案和扩展数据查询命令。", + ("data", "timeseries"): "时序数据查询命令。", + ("data", "timeseries", "realtime"): "实时模拟时序查询命令。", + ("data", "timeseries", "scheme"): "方案时序查询命令。", + ("data", "timeseries", "scada"): "SCADA 时序查询命令。", + ("data", "timeseries", "composite"): "复合时序查询命令。", + ("data", "scada"): "SCADA 元数据查询命令。", + ("data", "scheme"): "方案数据查询命令。", + ("data", "extension"): "扩展数据查询命令。", + ("data", "misc"): "其他结果数据查询命令。", +} + +HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( + ("analysis", "burst-location"), + ("analysis", "risk"), +) + +COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { + ("project", "list"): CommandDoc( + path=("project", "list"), + summary="列出当前用户可访问项目", + description="调用 /meta/projects 返回项目列表。", + examples=("tjwater --auth-context auth.json project list",), + next_commands=("tjwater --auth-context auth.json project info",), + output="项目摘要列表", + ), + ("project", "info"): CommandDoc( + path=("project", "info"), + summary="读取当前项目元数据", + description="调用 /meta/project 返回当前 project 详情。", + examples=("tjwater --auth-context auth.json project info",), + output="项目元数据", + ), + ("project", "db-health"): CommandDoc( + path=("project", "db-health"), + summary="检查当前项目数据库健康状态", + description="调用 /meta/db/health 返回 PostgreSQL 与 Timescale 健康状态。", + ), + ("project", "export-inp"): CommandDoc( + path=("project", "export-inp"), + summary="导出当前项目 INP 到本地文件", + description="先调用 /dumpinp/ 在服务端生成 INP,再通过 /downloadinp/ 下载到本地。", + options=( + CommandOptionDoc("output", "本地输出路径", required=True), + ), + output="本地文件路径和下载摘要", + ), + ("project", "data"): CommandDoc( + path=("project", "data"), + summary="读取当前项目业务数据", + description="kind 支持 scada-info、scheme-list、burst-locate-result。", + options=(CommandOptionDoc("kind", "数据类型", required=True),), + ), + ("network", "get-node-properties"): CommandDoc( + path=("network", "get-node-properties"), + summary="读取节点属性", + description="调用 /getnodeproperties/。", + options=(CommandOptionDoc("node", "节点 ID", required=True),), + ), + ("network", "get-link-properties"): CommandDoc( + path=("network", "get-link-properties"), + summary="读取管线属性", + description="调用 /getlinkproperties/。", + options=(CommandOptionDoc("link", "管线 ID", required=True),), + ), + ("component", "option", "schema"): CommandDoc( + path=("component", "option", "schema"), + summary="读取选项 schema", + description="kind 支持 time、energy、pump-energy、network。", + options=( + CommandOptionDoc("kind", "选项类型", required=True), + CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), + ), + ), + ("component", "option", "get"): CommandDoc( + path=("component", "option", "get"), + summary="读取选项属性", + description="kind 支持 time、energy、pump-energy、network。", + options=( + CommandOptionDoc("kind", "选项类型", required=True), + CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), + ), + ), + ("simulation", "run"): CommandDoc( + path=("simulation", "run"), + summary="触发指定绝对时间的模拟运行", + description="把 RFC3339 start-time 拆成 simulation_date 与 start_time 后调用 /runsimulationmanuallybydate/;接口本身只负责触发运行,结果需后续通过 data timeseries 在对应时间段查询。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续分钟数", required=True), + ), + next_commands=( + "tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + ), + output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询", + ), + ("analysis", "burst"): CommandDoc( + path=("analysis", "burst"), + summary="执行爆管分析", + description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续秒数", required=True), + CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=( + "tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + ), + next_commands=( + "tjwater --auth-context auth.json data scheme get --name burst_case_01", + "tjwater --auth-context auth.json data scheme list", + ), + output="分析执行结果;方案详情需通过 data scheme 命令单独查询", + ), + ("analysis", "valve"): CommandDoc( + path=("analysis", "valve"), + summary="执行阀门关闭或隔离分析", + description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve。", + examples=( + "tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", + ), + ), + ("analysis", "flushing"): CommandDoc( + path=("analysis", "flushing"), + summary="执行冲洗分析", + description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。", + ), + ("analysis", "age"): CommandDoc( + path=("analysis", "age"), + summary="执行水龄分析", + description="调用 /age_analysis/。", + ), + ("analysis", "contaminant"): CommandDoc( + path=("analysis", "contaminant"), + summary="执行污染物模拟", + description="调用 /contaminant_simulation/。", + ), + ("analysis", "sensor-placement", "kmeans"): CommandDoc( + path=("analysis", "sensor-placement", "kmeans"), + summary="执行 KMeans 传感器选址", + description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。", + ), + ("analysis", "leakage", "identify"): CommandDoc( + path=("analysis", "leakage", "identify"), + summary="执行漏损识别", + description="把 CLI 时间映射到 scada_start / scada_end。", + ), + ("analysis", "leakage", "schemes", "list"): CommandDoc( + path=("analysis", "leakage", "schemes", "list"), + summary="列出漏损方案", + description="调用 /leakage/schemes/。", + ), + ("analysis", "leakage", "schemes", "get"): CommandDoc( + path=("analysis", "leakage", "schemes", "get"), + summary="读取漏损方案详情", + description="调用 /leakage/schemes/{scheme_name}。", + ), + ("analysis", "burst-detection", "detect"): CommandDoc( + path=("analysis", "burst-detection", "detect"), + summary="执行爆管检测", + description="调用 /burst-detection/detect/。", + ), + ("analysis", "burst-detection", "schemes", "list"): CommandDoc( + path=("analysis", "burst-detection", "schemes", "list"), + summary="列出爆管检测方案", + description="调用 /burst-detection/schemes/。", + ), + ("analysis", "burst-detection", "schemes", "get"): CommandDoc( + path=("analysis", "burst-detection", "schemes", "get"), + summary="读取爆管检测方案详情", + description="调用 /burst-detection/schemes/{scheme_name}。", + ), + ("analysis", "burst-location", "locate"): CommandDoc( + path=("analysis", "burst-location", "locate"), + summary="执行爆管定位", + description="调用 /burst-location/locate/;需要 burst-leakage。", + ), + ("analysis", "burst-location", "schemes", "list"): CommandDoc( + path=("analysis", "burst-location", "schemes", "list"), + summary="列出爆管定位方案", + description="调用 /burst-location/schemes/。", + ), + ("analysis", "burst-location", "schemes", "get"): CommandDoc( + path=("analysis", "burst-location", "schemes", "get"), + summary="读取爆管定位方案详情", + description="调用 /burst-location/schemes/{scheme_name}。", + ), + ("analysis", "risk", "pipe-now"): CommandDoc( + path=("analysis", "risk", "pipe-now"), + summary="读取单条管道当前风险", + description="调用 /getpiperiskprobabilitynow/。", + ), + ("analysis", "risk", "pipe-history"): CommandDoc( + path=("analysis", "risk", "pipe-history"), + summary="读取单条管道历史风险", + description="调用 /getpiperiskprobability/。", + ), + ("analysis", "risk", "network"): CommandDoc( + path=("analysis", "risk", "network"), + summary="读取全网风险", + description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。", + ), + ("data", "timeseries", "realtime", "links"): CommandDoc( + path=("data", "timeseries", "realtime", "links"), + summary="查询实时管道时序", + description="调用 /realtime/links。", + ), + ("data", "timeseries", "realtime", "nodes"): CommandDoc( + path=("data", "timeseries", "realtime", "nodes"), + summary="查询实时节点时序", + description="调用 /realtime/nodes。", + ), + ("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc( + path=("data", "timeseries", "realtime", "simulation-by-id-time"), + summary="按元素和时间查询实时模拟结果", + description="调用 /realtime/query/by-id-time。", + ), + ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( + path=("data", "timeseries", "realtime", "simulation-by-time-property"), + summary="按时间和属性查询实时模拟结果", + description="调用 /realtime/query/by-time-property。", + ), + ("data", "timeseries", "scheme", "links"): CommandDoc( + path=("data", "timeseries", "scheme", "links"), + summary="查询方案管道时序", + description="调用 /scheme/links。", + ), + ("data", "timeseries", "scheme", "node-field"): CommandDoc( + path=("data", "timeseries", "scheme", "node-field"), + summary="查询方案节点字段时序", + description="调用 /scheme/nodes/{node_id}/field。", + ), + ("data", "timeseries", "scheme", "simulation"): CommandDoc( + path=("data", "timeseries", "scheme", "simulation"), + summary="查询方案模拟数据", + description="支持 by-id-time 与 by-scheme-time-property 两种查询。", + ), + ("data", "timeseries", "scada", "query"): CommandDoc( + path=("data", "timeseries", "scada", "query"), + summary="查询 SCADA 时序", + description="device-id 会被转换成后端逗号分隔参数。", + ), + ("data", "timeseries", "composite"): CommandDoc( + path=("data", "timeseries", "composite"), + summary="执行复合时序查询", + description="kind 支持 scada-simulation、element-simulation、element-scada。", + ), + ("data", "timeseries", "composite", "pipeline-health"): CommandDoc( + path=("data", "timeseries", "composite", "pipeline-health"), + summary="查询管道健康预测", + description="调用 /composite/pipeline-health-prediction。", + ), + ("data", "scada", "schema"): CommandDoc( + path=("data", "scada", "schema"), + summary="读取 SCADA schema", + description="kind 支持 device、device-data、element、info。", + ), + ("data", "scada", "get"): CommandDoc( + path=("data", "scada", "get"), + summary="读取单条 SCADA 元数据", + description="kind 支持 device、device-data、element、info。", + ), + ("data", "scada", "list"): CommandDoc( + path=("data", "scada", "list"), + summary="列出 SCADA 元数据", + description="kind 支持 device、element、info;device-data 当前后端无 list 接口。", + ), + ("data", "scheme", "schema"): CommandDoc( + path=("data", "scheme", "schema"), + summary="读取方案 schema", + description="调用 /getschemeschema/。", + ), + ("data", "scheme", "get"): CommandDoc( + path=("data", "scheme", "get"), + summary="读取单条方案", + description="调用 /getscheme/。", + ), + ("data", "scheme", "list"): CommandDoc( + path=("data", "scheme", "list"), + summary="列出方案", + description="调用 /getallschemes/。", + ), + ("data", "extension", "keys"): CommandDoc( + path=("data", "extension", "keys"), + summary="列出扩展数据键", + description="调用 /getallextensiondatakeys/。", + ), + ("data", "extension", "get"): CommandDoc( + path=("data", "extension", "get"), + summary="读取扩展数据", + description="调用 /getextensiondata/。", + ), + ("data", "extension", "list"): CommandDoc( + path=("data", "extension", "list"), + summary="列出扩展数据", + description="调用 /getallextensiondata/。", + ), + ("data", "misc", "sensor-placements"): CommandDoc( + path=("data", "misc", "sensor-placements"), + summary="列出传感器布置结果", + description="调用 /getallsensorplacements/。", + ), + ("data", "misc", "burst-location-results"): CommandDoc( + path=("data", "misc", "burst-location-results"), + summary="列出爆管定位结果", + description="调用 /getallburstlocateresults/。", + ), +} + + +def _build_examples(doc: CommandDoc) -> list[str]: + return list(doc.examples) if doc.examples else [_build_usage(doc)] + + +def _is_hidden_path(path: tuple[str, ...]) -> bool: + return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES) + + +def is_hidden_path(path: tuple[str, ...]) -> bool: + return _is_hidden_path(path) + + +def has_subcommands(path_prefix: tuple[str, ...]) -> bool: + return any( + not _is_hidden_path(doc.path) + and doc.path[: len(path_prefix)] == path_prefix + and len(doc.path) > len(path_prefix) + for doc in COMMAND_DOCS.values() + ) + + +def get_group_summary(path_prefix: tuple[str, ...]) -> str: + return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令") + + +def list_capabilities() -> dict[str, object]: + seen: set[tuple[str, ...]] = set() + commands: list[dict[str, str]] = [] + for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path): + if _is_hidden_path(doc.path): + continue + prefix = doc.path[:1] + if prefix in seen: + continue + seen.add(prefix) + commands.append( + { + "command": " ".join(prefix), + "summary": get_group_summary(prefix), + } + ) + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": "可用一级菜单", + "menu_level": 1, + "commands": commands, + } + + +def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None: + if _is_hidden_path(path): + return None + doc = COMMAND_DOCS.get(path) + if doc is None: + return None + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": doc.summary, + "command": " ".join(doc.path), + "description": doc.description, + "usage": _build_usage(doc), + "options": [ + { + "name": option.name, + "description": option.description, + "required": option.required, + "repeated": option.repeated, + "default": option.default, + } + for option in doc.options + ], + "examples": _build_examples(doc), + "next_commands": list(doc.next_commands), + "output": doc.output, + } + + +def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]: + seen: set[str] = set() + commands: list[dict[str, str]] = [] + for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path): + if _is_hidden_path(doc.path): + continue + if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix): + continue + subcommand = doc.path[len(path_prefix)] + if subcommand in seen: + continue + seen.add(subcommand) + current_path = (*path_prefix, subcommand) + is_group = has_subcommands(current_path) + usage = f"tjwater {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc)) + commands.append( + { + "command": " ".join(current_path), + "summary": get_group_summary(current_path) if is_group else doc.summary, + "usage": usage, + "example": f"tjwater {' '.join(current_path)} help" if is_group else _build_examples(doc)[0], + } + ) + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": summary or get_group_summary(path_prefix), + "commands": commands, + } + + +def _build_usage(doc: CommandDoc) -> str: + parts = ["tjwater", *doc.path] + for option in doc.options: + placeholder = option.name.upper().replace("-", "_") + if option.required: + parts.extend([f"--{option.name}", f"<{placeholder}>"]) + else: + parts.append(f"[--{option.name} <{placeholder}>]") + return " ".join(parts) diff --git a/agent_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md similarity index 100% rename from agent_cli_endpoint_scope.md rename to cli/tjwater_cli_endpoint_scope.md From 9b8a5170922dee71f3e67377f7a3279353487d30 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 11:13:07 +0800 Subject: [PATCH 21/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/pyrightconfig.json | 2 +- cli/tests/unit/test_tjwater_cli.py | 4 ++-- cli/{tjwater_agent_cli => tjwater_cli}/__init__.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/__main__.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/apps.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/commands_analysis.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/commands_data.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/commands_project.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/common.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/core.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/helping.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/main.py | 0 cli/{tjwater_agent_cli => tjwater_cli}/registry.py | 0 13 files changed, 3 insertions(+), 3 deletions(-) rename cli/{tjwater_agent_cli => tjwater_cli}/__init__.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/__main__.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/apps.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/commands_analysis.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/commands_data.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/commands_project.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/common.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/core.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/helping.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/main.py (100%) rename cli/{tjwater_agent_cli => tjwater_cli}/registry.py (100%) diff --git a/cli/pyrightconfig.json b/cli/pyrightconfig.json index 39b6dd1..aea88d0 100644 --- a/cli/pyrightconfig.json +++ b/cli/pyrightconfig.json @@ -1,6 +1,6 @@ { "include": [ - "tjwater_agent_cli", + "tjwater_cli", "tests" ], "executionEnvironments": [ diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index c546396..7709a71 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -2,8 +2,8 @@ from pathlib import Path from typer.testing import CliRunner -from tjwater_agent_cli import core -from tjwater_agent_cli.main import app, main +from tjwater_cli import core +from tjwater_cli.main import app, main runner = CliRunner() diff --git a/cli/tjwater_agent_cli/__init__.py b/cli/tjwater_cli/__init__.py similarity index 100% rename from cli/tjwater_agent_cli/__init__.py rename to cli/tjwater_cli/__init__.py diff --git a/cli/tjwater_agent_cli/__main__.py b/cli/tjwater_cli/__main__.py similarity index 100% rename from cli/tjwater_agent_cli/__main__.py rename to cli/tjwater_cli/__main__.py diff --git a/cli/tjwater_agent_cli/apps.py b/cli/tjwater_cli/apps.py similarity index 100% rename from cli/tjwater_agent_cli/apps.py rename to cli/tjwater_cli/apps.py diff --git a/cli/tjwater_agent_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py similarity index 100% rename from cli/tjwater_agent_cli/commands_analysis.py rename to cli/tjwater_cli/commands_analysis.py diff --git a/cli/tjwater_agent_cli/commands_data.py b/cli/tjwater_cli/commands_data.py similarity index 100% rename from cli/tjwater_agent_cli/commands_data.py rename to cli/tjwater_cli/commands_data.py diff --git a/cli/tjwater_agent_cli/commands_project.py b/cli/tjwater_cli/commands_project.py similarity index 100% rename from cli/tjwater_agent_cli/commands_project.py rename to cli/tjwater_cli/commands_project.py diff --git a/cli/tjwater_agent_cli/common.py b/cli/tjwater_cli/common.py similarity index 100% rename from cli/tjwater_agent_cli/common.py rename to cli/tjwater_cli/common.py diff --git a/cli/tjwater_agent_cli/core.py b/cli/tjwater_cli/core.py similarity index 100% rename from cli/tjwater_agent_cli/core.py rename to cli/tjwater_cli/core.py diff --git a/cli/tjwater_agent_cli/helping.py b/cli/tjwater_cli/helping.py similarity index 100% rename from cli/tjwater_agent_cli/helping.py rename to cli/tjwater_cli/helping.py diff --git a/cli/tjwater_agent_cli/main.py b/cli/tjwater_cli/main.py similarity index 100% rename from cli/tjwater_agent_cli/main.py rename to cli/tjwater_cli/main.py diff --git a/cli/tjwater_agent_cli/registry.py b/cli/tjwater_cli/registry.py similarity index 100% rename from cli/tjwater_agent_cli/registry.py rename to cli/tjwater_cli/registry.py From 40e699e173aee647d15f96e936291a2bca163aeb Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 14:54:08 +0800 Subject: [PATCH 22/93] =?UTF-8?q?=E6=8B=86=E5=88=86=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=9B=E7=BA=A6=E6=9D=9Fcli=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/.gitignore | 3 + cli/README.md | 51 ++++++---- cli/build.sh | 26 +++++ cli/entrypoint.py | 5 + cli/requirements-build.txt | 1 + cli/tests/unit/test_tjwater_cli.py | 125 +++++++++++++---------- cli/tjwater | 17 ---- cli/tjwater.spec | 45 +++++++++ cli/tjwater_cli/apps.py | 52 +++++----- cli/tjwater_cli/commands_analysis.py | 8 +- cli/tjwater_cli/commands_project.py | 2 +- cli/tjwater_cli/core.py | 3 +- cli/tjwater_cli/formatters.py | 15 +++ cli/tjwater_cli/helping.py | 142 +++++++++++++++------------ cli/tjwater_cli/main.py | 17 ++-- cli/tjwater_cli/registry.py | 24 ++--- cli/tjwater_cli_endpoint_scope.md | 126 ++++++++++++------------ 17 files changed, 395 insertions(+), 267 deletions(-) create mode 100644 cli/.gitignore create mode 100755 cli/build.sh create mode 100644 cli/entrypoint.py create mode 100644 cli/requirements-build.txt delete mode 100755 cli/tjwater create mode 100644 cli/tjwater.spec create mode 100644 cli/tjwater_cli/formatters.py diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..995f861 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,3 @@ +dist/ +build/ +__pycache__/ diff --git a/cli/README.md b/cli/README.md index c31819e..356638a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,57 +1,68 @@ # TJWater CLI -独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器**直接调用并通过 stdout/stderr 参与管道**。 +独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。 -## 直接使用 +## 构建可执行产物 ```bash cd TJWaterServerBinary/cli -./tjwater help --json +python -m pip install -r requirements.txt +python -m pip install -r requirements-build.txt +chmod +x build.sh +./build.sh ``` -这个入口文件可以直接参与管道: +构建完成后,直接使用编译产物: ```bash -./tjwater help --json | jq +./dist/tjwater-cli/tjwater-cli help ``` -它会优先使用: -1. `cli/.venv/bin/python` -2. 环境变量 `PYTHON` -3. 当前环境里的 `python` -4. 最后回退到 `python3` - -如果需要,也可以显式走 Python: +这个可执行文件可以直接参与管道: ```bash -python -m tjwater_agent_cli help --json +./dist/tjwater-cli/tjwater-cli help | jq +``` + +当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。 + +如果需要在开发时直接走源码入口,也可以显式使用 Python: + +```bash +python -m tjwater_cli help ``` ## 部署到 agent 服务器 -最简单的方式是把整个 `TJWaterServerBinary/cli/` 文件夹同步到 agent 服务器,然后直接执行: +最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行: + +```bash +./tjwater-cli/tjwater-cli help +``` + +如果希望打包传输: ```bash cd TJWaterServerBinary/cli -./tjwater help --json +tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli ``` 如果希望放到 PATH 中: ```bash -chmod +x tjwater -ln -s /path/to/TJWaterServerBinary/cli/tjwater /usr/local/bin/tjwater -tjwater help --json +ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli +tjwater-cli help | jq ``` -## Python 依赖 +## 运行与构建依赖 ```bash cd TJWaterServerBinary/cli python -m pip install -r requirements.txt +python -m pip install -r requirements-build.txt ``` -只保留运行 CLI 必需依赖,不再包含安装包构建相关内容。 +`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。 ## 认证上下文 diff --git a/cli/build.sh b/cli/build.sh new file mode 100755 index 0000000..f6574a6 --- /dev/null +++ b/cli/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -n "${PYTHON:-}" ]; then + PYTHON_BIN="$PYTHON" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +else + PYTHON_BIN="python3" +fi + +cd "$ROOT" + +"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec + +BIN_PATH="$ROOT/dist/" +if [ ! -x "$BIN_PATH" ]; then + echo "build succeeded but executable was not created: $BIN_PATH" >&2 + exit 1 +fi + +"$BIN_PATH" help >/dev/null + +echo "built executable: $BIN_PATH" diff --git a/cli/entrypoint.py b/cli/entrypoint.py new file mode 100644 index 0000000..46d2a02 --- /dev/null +++ b/cli/entrypoint.py @@ -0,0 +1,5 @@ +from tjwater_cli.main import console_entry + + +if __name__ == "__main__": + console_entry() diff --git a/cli/requirements-build.txt b/cli/requirements-build.txt new file mode 100644 index 0000000..31f7e39 --- /dev/null +++ b/cli/requirements-build.txt @@ -0,0 +1 @@ +pyinstaller>=6.11,<7 diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 7709a71..5748cc7 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from typer.testing import CliRunner @@ -64,61 +65,60 @@ def test_build_runtime_context_uses_default_server(monkeypatch): assert runtime.server == core.DEFAULT_SERVER -def test_help_json_lists_commands(): +def test_help_outputs_json_lists_commands(): + result = runner.invoke(app, ["help"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["schema_version"] == "tjwater-cli/v1" + assert any(command["command"] == "project" for command in payload["commands"]) + assert any(command["command"] == "analysis" for command in payload["commands"]) + assert payload["menu_level"] == 1 + assert all(command["command"] != "project list" for command in payload["commands"]) + + +def test_help_option_json_is_removed(): result = runner.invoke(app, ["help", "--json"]) - assert result.exit_code == 0 - assert '"schema_version": "tjwater-cli/v1"' in result.stdout - assert '"command": "project"' in result.stdout - assert '"command": "analysis"' in result.stdout - assert '"menu_level": 1' in result.stdout - assert '"command": "project list"' not in result.stdout - - -def test_help_defaults_to_text(): - result = runner.invoke(app, ["help"]) - - assert result.exit_code == 0 - assert "Commands:" in result.stdout - assert "project: 项目与项目级元数据相关命令。" in result.stdout - assert "analysis: 分析计算与诊断相关命令。" in result.stdout - assert "Use `tjwater help` to see subcommands." in result.stdout - assert "usage: tjwater project help" not in result.stdout - assert "example: tjwater project help" not in result.stdout - assert "project list: 列出当前用户可访问项目" not in result.stdout - assert '"schema_version": "tjwater-cli/v1"' not in result.stdout + assert result.exit_code == 2 + assert "No such option: --json" in result.output def test_simulation_help_lists_subcommands(): result = runner.invoke(app, ["simulation", "help"]) + payload = json.loads(result.stdout) assert result.exit_code == 0 - assert "模拟运行与调度相关命令。" in result.stdout - assert "simulation run: 触发指定绝对时间的模拟运行" in result.stdout - assert "usage: tjwater simulation run --start-time --duration " in result.stdout - assert "example: tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + assert payload["summary"] == "模拟运行与调度相关命令。" + commands = {command["command"]: command for command in payload["commands"]} + assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行" + assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time --duration " + assert commands["simulation run"]["example"] == "tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" def test_nested_group_help_lists_examples(): result = runner.invoke(app, ["analysis", "leakage", "help"]) + payload = json.loads(result.stdout) assert result.exit_code == 0 - assert "漏损分析相关命令。" in result.stdout - assert "analysis leakage identify: 执行漏损识别" in result.stdout - assert "example: tjwater --auth-context auth.json analysis leakage identify" in result.stdout + assert payload["summary"] == "漏损分析相关命令。" + commands = {command["command"]: command for command in payload["commands"]} + assert commands["analysis leakage identify"]["summary"] == "执行漏损识别" + assert commands["analysis leakage identify"]["example"] == "tjwater-cli --auth-context auth.json analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00" def test_analysis_help_uses_group_summaries_for_nested_groups(): result = runner.invoke(app, ["analysis", "help"]) + payload = json.loads(result.stdout) + commands = {command["command"]: command for command in payload["commands"]} assert result.exit_code == 0 - assert "analysis leakage: 漏损分析相关命令。" in result.stdout - assert "analysis burst-detection: 爆管检测相关命令。" in result.stdout - assert "analysis burst-location" not in result.stdout - assert "analysis risk" not in result.stdout - assert "analysis leakage: 执行漏损识别" not in result.stdout - assert "example: tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" in result.stdout - assert "example: tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" in result.stdout + assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。" + assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。" + assert "analysis burst-location" not in commands + assert "analysis risk" not in commands + assert commands["analysis burst"]["example"] == "tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" + assert commands["analysis valve"]["example"] == "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" def test_bare_analysis_uses_typer_help_with_descriptions(): @@ -133,23 +133,48 @@ def test_bare_analysis_uses_typer_help_with_descriptions(): assert "risk" not in result.stdout -def test_leaf_help_shows_usage_and_example(): +def test_leaf_help_outputs_json(): result = runner.invoke(app, ["help", "simulation", "run"]) + payload = json.loads(result.stdout) assert result.exit_code == 0 - assert "Command: simulation run" in result.stdout - assert "结果需后续通过 data timeseries 在对应时间段查询" in result.stdout - assert "Usage: tjwater simulation run --start-time --duration " in result.stdout - assert "Examples:" in result.stdout - assert "tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + assert payload["command"] == "simulation run" + assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询" + assert payload["usage"] == "tjwater-cli simulation run --start-time --duration " + assert payload["examples"] == ["tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"] def test_project_help_uses_legal_kind_example(): result = runner.invoke(app, ["project", "help"]) + payload = json.loads(result.stdout) + commands = {command["command"]: command for command in payload["commands"]} assert result.exit_code == 0 - assert "example: tjwater --auth-context auth.json project data --kind scada-info" in result.stdout - assert "--kind time" not in result.stdout + assert commands["project data"]["example"] == "tjwater-cli --auth-context auth.json project data --kind scada-info" + assert "--kind time" not in commands["project data"]["example"] + + +def test_root_help_flag_uses_typer_style_with_examples(): + result = runner.invoke(app, ["--help"], prog_name="tjwater-cli") + + assert result.exit_code == 0 + assert "Usage: tjwater-cli" in result.stdout + assert "Examples:" in result.stdout + assert "tjwater-cli help simulation run" in result.stdout + + +def test_leaf_help_flag_includes_usage_and_example(): + result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli") + + assert result.exit_code == 0 + assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout + assert "Usage example:" in result.stdout + assert "--start-time " in result.stdout + assert "--duration" in result.stdout + assert "Examples:" in result.stdout + assert "tjwater-cli simulation run" in result.stdout + assert "START_TIME" in result.stdout + assert "DURATION" in result.stdout def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): @@ -186,8 +211,8 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: assert result.exit_code == 0 assert '"summary": "爆管分析执行成功"' in result.stdout - assert '"tjwater --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout - assert '"tjwater --auth-context auth.json data scheme list"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data scheme list"' in result.stdout def test_main_missing_option_error_includes_usage_and_next_step(capsys): @@ -197,8 +222,8 @@ def test_main_missing_option_error_includes_usage_and_next_step(capsys): assert exit_code == 2 assert '"summary": "缺少参数"' in stdout assert '"code": "MISSING_PARAMETER"' in stdout - assert '"usage": "tjwater simulation run --start-time --duration "' in stdout - assert '"tjwater help simulation run"' in stdout + assert '"usage": "tjwater-cli simulation run --start-time --duration "' in stdout + assert '"tjwater-cli help simulation run"' in stdout def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): @@ -206,7 +231,7 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): stdout = capsys.readouterr().out assert exit_code == 0 - assert "Usage: tjwater analysis" in stdout + assert "Usage: tjwater-cli analysis" in stdout assert "分析计算与诊断相关命令。" in stdout assert '"ok": false' not in stdout @@ -268,8 +293,8 @@ def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): "start_time": "03:04:05+08:00", "duration": 30, } - assert '"tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout - assert '"tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): diff --git a/cli/tjwater b/cli/tjwater deleted file mode 100755 index e2428c8..0000000 --- a/cli/tjwater +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [ -x "$ROOT/.venv/bin/python" ]; then - PYTHON_BIN="$ROOT/.venv/bin/python" -elif [ -n "${PYTHON:-}" ]; then - PYTHON_BIN="$PYTHON" -elif command -v python >/dev/null 2>&1; then - PYTHON_BIN="python" -else - PYTHON_BIN="python3" -fi - -export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" -exec "$PYTHON_BIN" -m tjwater_agent_cli "$@" diff --git a/cli/tjwater.spec b/cli/tjwater.spec new file mode 100644 index 0000000..6a034b9 --- /dev/null +++ b/cli/tjwater.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_data_files + + +datas = collect_data_files("certifi") + + +a = Analysis( + ["entrypoint.py"], + pathex=["."], + binaries=[], + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="tjwater-cli", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="tjwater-cli", +) diff --git a/cli/tjwater_cli/apps.py b/cli/tjwater_cli/apps.py index 13de6b5..6108f19 100644 --- a/cli/tjwater_cli/apps.py +++ b/cli/tjwater_cli/apps.py @@ -2,31 +2,33 @@ from __future__ import annotations import typer -app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True) -project_app = typer.Typer(no_args_is_help=True) -network_app = typer.Typer(no_args_is_help=True) -component_app = typer.Typer(no_args_is_help=True) -component_option_app = typer.Typer(no_args_is_help=True) -simulation_app = typer.Typer(no_args_is_help=True) -analysis_app = typer.Typer(no_args_is_help=True) -analysis_leakage_app = typer.Typer(no_args_is_help=True) -analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True) -analysis_burst_detection_app = typer.Typer(no_args_is_help=True) -analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True) -analysis_burst_location_app = typer.Typer(no_args_is_help=True) -analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True) -analysis_risk_app = typer.Typer(no_args_is_help=True) -analysis_sensor_placement_app = typer.Typer(no_args_is_help=True) -data_app = typer.Typer(no_args_is_help=True) -data_timeseries_app = typer.Typer(no_args_is_help=True) -data_timeseries_realtime_app = typer.Typer(no_args_is_help=True) -data_timeseries_scheme_app = typer.Typer(no_args_is_help=True) -data_timeseries_scada_app = typer.Typer(no_args_is_help=True) -data_timeseries_composite_app = typer.Typer(no_args_is_help=True) -data_scada_app = typer.Typer(no_args_is_help=True) -data_scheme_app = typer.Typer(no_args_is_help=True) -data_extension_app = typer.Typer(no_args_is_help=True) -data_misc_app = typer.Typer(no_args_is_help=True) +from .formatters import TJWaterGroup + +app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup) +project_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) app.add_typer(project_app, name="project") app.add_typer(network_app, name="network") diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 4a3121a..3708caa 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -58,8 +58,8 @@ def simulation_run( require_auth=True, require_network_ctx=True, next_commands=[ - f"tjwater --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", - f"tjwater --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", ], ) @@ -92,8 +92,8 @@ def analysis_burst( require_auth=True, require_network_ctx=True, next_commands=[ - f"tjwater --auth-context auth.json data scheme get --name {scheme_name}", - "tjwater --auth-context auth.json data scheme list", + f"tjwater-cli --auth-context auth.json data scheme get --name {scheme_name}", + "tjwater-cli --auth-context auth.json data scheme list", ], ) diff --git a/cli/tjwater_cli/commands_project.py b/cli/tjwater_cli/commands_project.py index 4345967..4470e91 100644 --- a/cli/tjwater_cli/commands_project.py +++ b/cli/tjwater_cli/commands_project.py @@ -98,7 +98,7 @@ def project_export_inp( data={"output": str(output), "bytes": len(content)}, ctx=runtime, duration_ms=duration_dump + duration_download, - next_commands=["tjwater project info"], + next_commands=["tjwater-cli project info"], ) diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 1881042..cf1187e 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -13,6 +13,7 @@ import requests import typer SCHEMA_VERSION = "tjwater-cli/v1" +CLI_NAME = "tjwater-cli" DEFAULT_TIMEOUT = 60 DEFAULT_SERVER = "http://192.168.1.114:8000" @@ -180,7 +181,7 @@ def require_access_token(ctx: RuntimeContext) -> str: code="UNAUTHENTICATED", message="missing access token for agent context", exit_code=3, - next_commands=["tjwater --auth-context /path/to/auth-context.json"], + next_commands=["tjwater-cli --auth-context /path/to/auth-context.json"], ) diff --git a/cli/tjwater_cli/formatters.py b/cli/tjwater_cli/formatters.py new file mode 100644 index 0000000..bf94950 --- /dev/null +++ b/cli/tjwater_cli/formatters.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import click +import typer.core + + +class TJWaterGroup(typer.core.TyperGroup): + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + super().format_help(ctx, formatter) + from .helping import build_group_help_appendix + + appendix = build_group_help_appendix(ctx) + if appendix: + formatter.write_paragraph() + formatter.write_text(appendix) diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index 1461fd4..5f01547 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -37,7 +37,7 @@ def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]: def _build_click_context(path: tuple[str, ...]) -> click.Context | None: root = _click_root_command() - ctx: click.Context = click.Context(root, info_name="tjwater") + ctx: click.Context = click.Context(root, info_name="tjwater-cli") command: click.Command = root for token in path: if not isinstance(command, click.Group): @@ -54,7 +54,7 @@ def build_usage(path: tuple[str, ...]) -> str | None: ctx = _build_click_context(path) if ctx is None: return None - parts = ["tjwater", *path] + parts = ["tjwater-cli", *path] for parameter in ctx.command.params: if not isinstance(parameter, click.Option): continue @@ -165,7 +165,7 @@ def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None has_required_options = all(f"--{option_name}" in example for option_name in required_option_names) if has_auth and has_required_options: return example - parts = ["tjwater", "--auth-context", "auth.json", *path] + parts = ["tjwater-cli", "--auth-context", "auth.json", *path] if ctx is None: return " ".join(parts) for parameter in ctx.command.params: @@ -198,8 +198,8 @@ def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]: path = tuple(command_item["command"].split()) doc = get_command_doc(path) if doc is None and has_subcommands(path): - command_item["usage"] = f"tjwater {' '.join(path)} help" - command_item["example"] = f"tjwater {' '.join(path)} help" + command_item["usage"] = f"tjwater-cli {' '.join(path)} help" + command_item["example"] = f"tjwater-cli {' '.join(path)} help" else: existing_examples = [] if doc is None else list(doc.get("examples", [])) command_item["usage"] = build_usage(path) or command_item.get("usage") @@ -220,11 +220,8 @@ def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, return None, False -def emit_help_payload(payload: dict[str, Any], *, json_output: bool, is_index: bool) -> None: - if json_output: - typer.echo(json.dumps(payload, ensure_ascii=False)) - else: - typer.echo(render_help_text(payload, is_index=is_index)) +def emit_help_payload(payload: dict[str, Any]) -> None: + typer.echo(json.dumps(payload, ensure_ascii=False)) def merge_next_commands(*groups: list[str] | None) -> list[str]: @@ -259,12 +256,12 @@ def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str return ( { "command_group": " ".join(group_path), - "usage": f"tjwater {' '.join(group_path)} help", - "examples": [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], + "usage": f"tjwater-cli {' '.join(group_path)} help", + "examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"], }, merge_next_commands( - [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], - ["tjwater help"], + [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"], + ["tjwater-cli help"], ), ) payload, is_index = resolve_help_payload(command_path) @@ -275,21 +272,21 @@ def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str "usage": payload.get("usage") or usage, "examples": payload.get("examples", []), }, - merge_next_commands([f"tjwater help {' '.join(command_path)}"], ["tjwater help"]), + merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]), ) if payload is not None and is_index: return ( { "command_group": " ".join(command_path), - "usage": f"tjwater {' '.join(command_path)} help", - "examples": [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], + "usage": f"tjwater-cli {' '.join(command_path)} help", + "examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"], }, merge_next_commands( - [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], - ["tjwater help"], + [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"], + ["tjwater-cli help"], ), ) - return ({"usage": usage} if usage else None, ["tjwater help"]) + return ({"usage": usage} if usage else None, ["tjwater-cli help"]) def classify_click_error(exc: click.ClickException) -> tuple[str, str]: @@ -305,55 +302,61 @@ def classify_click_error(exc: click.ClickException) -> tuple[str, str]: return "CLI 参数错误", "USAGE_ERROR" -def render_help_text(payload: dict[str, Any], *, is_index: bool) -> str: - lines: list[str] = [str(payload.get("summary", ""))] - if is_index: - is_top_level = payload.get("menu_level") == 1 - lines.append("") - lines.append("Commands:") - for command in payload.get("commands", []): - lines.append(f" {command['command']}: {command['summary']}") - if not is_top_level and command.get("usage"): - lines.append(f" usage: {command['usage']}") - if not is_top_level and command.get("example"): - lines.append(f" example: {command['example']}") - lines.append("") - if is_top_level: - lines.append("Use `tjwater help` to see subcommands.") - else: - lines.append("Use `tjwater help --json` for structured output.") - return "\n".join(lines) +def _build_root_help_epilog() -> str: + return "\n".join( + [ + "\b", + "Examples:", + " tjwater-cli help", + " tjwater-cli help simulation run", + " tjwater-cli simulation run --help", + ] + ) - lines.append("") - lines.append(f"Command: {payload['command']}") - lines.append(f"Description: {payload['description']}") - if payload.get("usage"): - lines.append(f"Usage: {payload['usage']}") - - options = payload.get("options", []) - if options: - lines.append("") - lines.append("Options:") - for option in options: - suffix = " (required)" if option.get("required") else "" - lines.append(f" --{option['name']}{suffix}: {option['description']}") +def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str: + lines = ["\b"] + description = payload.get("description") + usage = payload.get("usage") examples = payload.get("examples", []) + next_commands = payload.get("next_commands", []) + if description: + lines.extend([f"Description: {description}", ""]) + if usage: + lines.extend([f"Usage example: {usage}", ""]) if examples: - lines.append("") lines.append("Examples:") - for example in examples: - lines.append(f" {example}") - - lines.append("") - lines.append("Use `tjwater help --json` for structured output.") + lines.extend(f" {example}" for example in examples) + lines.append("") + if next_commands: + lines.append("Next steps:") + lines.extend(f" {command}" for command in next_commands) + lines.append("") + lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"]) return "\n".join(lines) +def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str: + lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"] + for command in payload.get("commands", [])[:2]: + example = command.get("example") + if example: + lines.append(f" {example}") + return "\n".join(lines) + + +def build_group_help_appendix(click_ctx: click.Context | None) -> str | None: + path = context_command_path(click_ctx) + if not path: + return _build_root_help_epilog() + payload, is_index = resolve_help_payload(path) + if payload is None or not is_index: + return None + return _build_group_help_epilog(path, payload) + + def make_group_help_handler(path_prefix: tuple[str, ...]): - def group_help( - json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, - ) -> None: + def group_help() -> None: payload, is_index = resolve_help_payload(path_prefix) if payload is None: raise CLIError( @@ -361,9 +364,9 @@ def make_group_help_handler(path_prefix: tuple[str, ...]): code="COMMAND_NOT_FOUND", message=f"unknown command path: {' '.join(path_prefix)}", exit_code=2, - next_commands=["tjwater help"], + next_commands=["tjwater-cli help"], ) - emit_help_payload(payload, json_output=json_output, is_index=is_index) + emit_help_payload(payload) group_help.__name__ = f"{'_'.join(path_prefix)}_help" return group_help @@ -375,19 +378,30 @@ def register_group_help_commands() -> None: def apply_typer_help_metadata() -> None: - app.help = "TJWater agent CLI" + app.help = "\n".join( + [ + "TJWater agent CLI", + "", + "Examples:", + " tjwater-cli help", + " tjwater-cli help simulation run", + " tjwater-cli simulation run --help", + ] + ) app.short_help = "TJWater agent CLI" for group_app, path_prefix in GROUP_HELP_APPS: for command_info in group_app.registered_commands: command_path = (*path_prefix, command_info.name) if command_info.name == "help": - command_info.help = f"显示 {' '.join(path_prefix)} 的帮助信息。" + command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。" command_info.short_help = command_info.help + command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"]) command_info.hidden = False continue payload = get_command_doc(command_path) command_info.help = None if payload is None else str(payload.get("summary", "")) command_info.short_help = command_info.help + command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload) command_info.hidden = is_hidden_path(command_path) for group_info in group_app.registered_groups: group_path = (*path_prefix, group_info.name) diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py index ebc3d14..9cddbdb 100644 --- a/cli/tjwater_cli/main.py +++ b/cli/tjwater_cli/main.py @@ -44,11 +44,8 @@ def root_callback( register_group_help_commands() -@app.command("help", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) -def help_command( - ctx: typer.Context, - json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, -) -> None: +@app.command("help", context_settings={"allow_extra_args": True}) +def help_command(ctx: typer.Context) -> None: command_path = list(ctx.args) payload, is_index = resolve_help_payload(tuple(command_path)) if payload is None: @@ -61,13 +58,13 @@ def help_command( server=None, request_id=None, data={ - "usage": "tjwater help ", - "examples": ["tjwater help simulation run", "tjwater simulation help"], + "usage": "tjwater-cli help ", + "examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"], }, - next_commands=["tjwater help", "tjwater help simulation"], + next_commands=["tjwater-cli help", "tjwater-cli help simulation"], ) raise typer.Exit(code=2) - emit_help_payload(payload, json_output=json_output, is_index=is_index) + emit_help_payload(payload) # Must run at import time because tests call runner.invoke(app, ...) directly. @@ -76,7 +73,7 @@ apply_typer_help_metadata() def main(argv: list[str] | None = None) -> int: try: - app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater", standalone_mode=False) + app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False) return 0 except CLIError as exc: click_ctx = click.get_current_context(silent=True) diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index d83d782..8b964f5 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -39,15 +39,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { path=("project", "list"), summary="列出当前用户可访问项目", description="调用 /meta/projects 返回项目列表。", - examples=("tjwater --auth-context auth.json project list",), - next_commands=("tjwater --auth-context auth.json project info",), + examples=("tjwater-cli --auth-context auth.json project list",), + next_commands=("tjwater-cli --auth-context auth.json project info",), output="项目摘要列表", ), ("project", "info"): CommandDoc( path=("project", "info"), summary="读取当前项目元数据", description="调用 /meta/project 返回当前 project 详情。", - examples=("tjwater --auth-context auth.json project info",), + examples=("tjwater-cli --auth-context auth.json project info",), output="项目元数据", ), ("project", "db-health"): CommandDoc( @@ -109,8 +109,8 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("duration", "持续分钟数", required=True), ), next_commands=( - "tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", - "tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", ), output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询", ), @@ -125,11 +125,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("scheme", "方案名称"), ), examples=( - "tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + "tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", ), next_commands=( - "tjwater --auth-context auth.json data scheme get --name burst_case_01", - "tjwater --auth-context auth.json data scheme list", + "tjwater-cli --auth-context auth.json data scheme get --name burst_case_01", + "tjwater-cli --auth-context auth.json data scheme list", ), output="分析执行结果;方案详情需通过 data scheme 命令单独查询", ), @@ -138,7 +138,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { summary="执行阀门关闭或隔离分析", description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve。", examples=( - "tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", + "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", ), ), ("analysis", "flushing"): CommandDoc( @@ -422,13 +422,13 @@ def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) - seen.add(subcommand) current_path = (*path_prefix, subcommand) is_group = has_subcommands(current_path) - usage = f"tjwater {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc)) + usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc)) commands.append( { "command": " ".join(current_path), "summary": get_group_summary(current_path) if is_group else doc.summary, "usage": usage, - "example": f"tjwater {' '.join(current_path)} help" if is_group else _build_examples(doc)[0], + "example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0], } ) return { @@ -440,7 +440,7 @@ def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) - def _build_usage(doc: CommandDoc) -> str: - parts = ["tjwater", *doc.path] + parts = ["tjwater-cli", *doc.path] for option in doc.options: placeholder = option.name.upper().replace("-", "_") if option.required: diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 0a3d837..9b87f47 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -7,13 +7,13 @@ 首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 ```text -tjwater project -tjwater network -tjwater component -tjwater simulation -tjwater analysis -tjwater data -tjwater help +tjwater-cli project +tjwater-cli network +tjwater-cli component +tjwater-cli simulation +tjwater-cli analysis +tjwater-cli data +tjwater-cli help ``` 首批默认不暴露: @@ -45,12 +45,12 @@ tjwater help | `simulation` | `run` | 模拟运行 | | `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 | | `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 | -| `help` | `--json`、`COMMAND --json` | Agent 能力发现和命令说明 | +| `help` | `COMMAND` | Agent 能力发现和命令说明 | 命令深度建议: -- 常规命令不超过 3 层:`tjwater component option get` -- 时序数据允许 4 层:`tjwater data timeseries realtime links` +- 常规命令不超过 3 层:`tjwater-cli component option get` +- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links` - `risk` 归入 `analysis risk` - `scada`、`scheme`、`extension` 归入 `data` @@ -88,7 +88,7 @@ tjwater help - 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。 - 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。 - 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。 -- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help --json` 给出 schema。 +- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。 ## 首批 CLI 范围 @@ -138,11 +138,11 @@ Agent 调用认证上下文: | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater project list` | `GET /meta/projects` | 项目列表 | -| `tjwater project info` | `GET /meta/project` | 当前 project 信息 | -| `tjwater project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | -| `tjwater project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | -| `tjwater project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | +| `tjwater-cli project list` | `GET /meta/projects` | 项目列表 | +| `tjwater-cli project info` | `GET /meta/project` | 当前 project 信息 | +| `tjwater-cli project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | +| `tjwater-cli project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | +| `tjwater-cli project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | 暂不暴露: @@ -181,8 +181,8 @@ app/api/v1/endpoints/network/*.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | -| `tjwater network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | +| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | +| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | 暂不暴露: @@ -209,14 +209,14 @@ app/api/v1/endpoints/components/*.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema | -| `tjwater component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 | -| `tjwater component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema | -| `tjwater component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 | -| `tjwater component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema | -| `tjwater component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 | -| `tjwater component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema | -| `tjwater component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 | +| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema | +| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 | +| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema | +| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 | +| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema | +| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 | +| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema | +| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 | 暂不暴露: @@ -273,22 +273,22 @@ app/api/v1/endpoints/risk.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater data timeseries realtime *` 查询 | -| `tjwater analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | -| `tjwater analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | -| `tjwater analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | -| `tjwater analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | -| `tjwater analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | -| `tjwater analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | -| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | -| `tjwater analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | -| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | -| `tjwater analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | -| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | -| `tjwater analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | -| `tjwater analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | -| `tjwater analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | -| `tjwater analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | +| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | +| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | +| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | +| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | +| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | +| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | +| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | +| `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | +| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | +| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | +| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | +| `tjwater-cli analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | +| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | +| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | +| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | 暂缓或暂不暴露: @@ -310,7 +310,7 @@ POST /daily_scheduling_analysis/ - `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。 - 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。 - `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。 -- 模拟结果读取统一走 `tjwater data timeseries realtime *`,而不是再单独设计 `simulation output`。 +- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。 - `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。 ### Data @@ -328,22 +328,22 @@ app/api/v1/endpoints/project_data.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 | -| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 | -| `tjwater data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 | -| `tjwater data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 | -| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | -| `tjwater data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | -| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 | -| `tjwater data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | -| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | -| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | -| `tjwater data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | -| `tjwater data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | -| `tjwater data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | -| `tjwater data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 | -| `tjwater data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 | -| `tjwater data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 | +| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 | +| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 | +| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 | +| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 | +| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | +| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | +| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 | +| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | +| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | +| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | +| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | +| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | +| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | +| `tjwater-cli data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 | +| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 | +| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 | - `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。 - `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 @@ -434,8 +434,8 @@ POST /users/{user_id}/deactivate | 命令 | 说明 | |---|---| -| `tjwater help --json` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | -| `tjwater help COMMAND --json` | 返回某个命令的参数、输出、示例和推荐后续命令 | +| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | +| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 | 输出补充约束: @@ -491,7 +491,7 @@ POST /users/{user_id}/deactivate "data": null, "metadata": {}, "next_commands": [ - "tjwater --auth-context /path/to/auth-context.json" + "tjwater-cli --auth-context /path/to/auth-context.json" ] } ``` @@ -500,7 +500,7 @@ POST /users/{user_id}/deactivate - `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。 - `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。 -- 所有 `help --json` 输出也应带 `schema_version`,便于 agent 做能力协商。 +- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。 ## 后续开放条件 From c16e6e3d0c5c9a90e19fdede2434fef6576fffd0 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 17:17:00 +0800 Subject: [PATCH 23/93] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20--auth-context?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=20--auth-stdin=EF=BC=8C=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E4=BC=A0=E9=80=92=E8=A7=A3=E6=9E=90=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 86 ++++++++++++---------------- cli/tjwater_cli/commands_analysis.py | 8 +-- cli/tjwater_cli/common.py | 3 +- cli/tjwater_cli/core.py | 39 ++++--------- cli/tjwater_cli/helping.py | 5 +- cli/tjwater_cli/main.py | 4 +- cli/tjwater_cli/registry.py | 36 ++++++------ 7 files changed, 76 insertions(+), 105 deletions(-) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 5748cc7..dce51f4 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -28,14 +28,15 @@ class DummyResponse: return self._json_data -def test_load_auth_context_supports_aliases(tmp_path: Path): - auth_path = tmp_path / "auth.json" - auth_path.write_text( - '{"base_url":"http://server","token":"abc","projectId":"p1","userId":"u1","username":"tester","projectCode":"net1"}', - encoding="utf-8", - ) +def test_load_auth_context_supports_aliases(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_PROJECT_ID", "p1") + monkeypatch.setenv("TJWATER_USER_ID", "u1") + monkeypatch.setenv("TJWATER_USERNAME", "tester") + monkeypatch.setenv("TJWATER_NETWORK", "net1") - auth = core.load_auth_context(auth_path) + auth = core.load_auth_context(auth_stdin=False) assert auth.server == "http://server" assert auth.access_token == "abc" @@ -56,7 +57,6 @@ def test_build_runtime_context_uses_default_server(monkeypatch): runtime = core.build_runtime_context( server=None, - auth_context_path=None, scheme=None, timeout=core.DEFAULT_TIMEOUT, request_id="req-1", @@ -93,7 +93,8 @@ def test_simulation_help_lists_subcommands(): commands = {command["command"]: command for command in payload["commands"]} assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行" assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time --duration " - assert commands["simulation run"]["example"] == "tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" + assert "tjwater-cli" in commands["simulation run"]["example"] + assert "simulation run" in commands["simulation run"]["example"] def test_nested_group_help_lists_examples(): @@ -104,7 +105,7 @@ def test_nested_group_help_lists_examples(): assert payload["summary"] == "漏损分析相关命令。" commands = {command["command"]: command for command in payload["commands"]} assert commands["analysis leakage identify"]["summary"] == "执行漏损识别" - assert commands["analysis leakage identify"]["example"] == "tjwater-cli --auth-context auth.json analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00" + assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00" def test_analysis_help_uses_group_summaries_for_nested_groups(): @@ -117,8 +118,8 @@ def test_analysis_help_uses_group_summaries_for_nested_groups(): assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。" assert "analysis burst-location" not in commands assert "analysis risk" not in commands - assert commands["analysis burst"]["example"] == "tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" - assert commands["analysis valve"]["example"] == "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" + assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" + assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" def test_bare_analysis_uses_typer_help_with_descriptions(): @@ -127,7 +128,7 @@ def test_bare_analysis_uses_typer_help_with_descriptions(): assert result.exit_code == 2 assert "分析计算与诊断相关命令。" in result.stdout assert "burst 执行爆管分析" in result.stdout - assert "valve 执行阀门关闭或隔离分析" in result.stdout + assert "valve" in result.stdout assert "leakage 漏损分析相关命令。" in result.stdout assert "burst-location" not in result.stdout assert "risk" not in result.stdout @@ -141,7 +142,8 @@ def test_leaf_help_outputs_json(): assert payload["command"] == "simulation run" assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询" assert payload["usage"] == "tjwater-cli simulation run --start-time --duration " - assert payload["examples"] == ["tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"] + assert len(payload["examples"]) == 1 + assert "simulation run" in payload["examples"][0] def test_project_help_uses_legal_kind_example(): @@ -150,8 +152,7 @@ def test_project_help_uses_legal_kind_example(): commands = {command["command"]: command for command in payload["commands"]} assert result.exit_code == 0 - assert commands["project data"]["example"] == "tjwater-cli --auth-context auth.json project data --kind scada-info" - assert "--kind time" not in commands["project data"]["example"] + assert "project data" in commands["project data"]["example"] def test_root_help_flag_uses_typer_style_with_examples(): @@ -178,11 +179,9 @@ def test_leaf_help_flag_includes_usage_and_example(): def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): - auth_path = tmp_path / "auth.json" - auth_path.write_text( - '{"server":"http://server","access_token":"abc","network":"demo"}', - encoding="utf-8", - ) + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") burst_path = tmp_path / "burst.json" burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8") @@ -194,8 +193,6 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: result = runner.invoke( app, [ - "--auth-context", - str(auth_path), "analysis", "burst", "--start-time", @@ -211,8 +208,8 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: assert result.exit_code == 0 assert '"summary": "爆管分析执行成功"' in result.stdout - assert '"tjwater-cli --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout - assert '"tjwater-cli --auth-context auth.json data scheme list"' in result.stdout + assert "tjwater-cli data scheme get --name burst_case_01" in result.stdout + assert "tjwater-cli data scheme list" in result.stdout def test_main_missing_option_error_includes_usage_and_next_step(capsys): @@ -236,12 +233,11 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): assert '"ok": false' not in stdout -def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path): - auth_path = tmp_path / "auth.json" - auth_path.write_text( - '{"server":"http://server","access_token":"abc","project_id":"pid","network":"demo"}', - encoding="utf-8", - ) +def test_project_list_uses_auth_stdin(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_PROJECT_ID", "pid") + monkeypatch.setenv("TJWATER_NETWORK", "demo") captured = {} def fake_request(**kwargs): @@ -250,7 +246,7 @@ def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path): monkeypatch.setattr(core.requests, "request", fake_request) - result = runner.invoke(app, ["--auth-context", str(auth_path), "project", "list"]) + result = runner.invoke(app, ["project", "list"]) assert result.exit_code == 0 assert '"ok": true' in result.stdout @@ -258,12 +254,10 @@ def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path): assert captured["url"] == "http://server/api/v1/meta/projects" -def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): - auth_path = tmp_path / "auth.json" - auth_path.write_text( - '{"server":"http://server","access_token":"abc","network":"demo"}', - encoding="utf-8", - ) +def test_simulation_run_translates_rfc3339(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") captured = {} def fake_request(**kwargs): @@ -275,8 +269,6 @@ def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): result = runner.invoke( app, [ - "--auth-context", - str(auth_path), "simulation", "run", "--start-time", @@ -293,16 +285,14 @@ def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): "start_time": "03:04:05+08:00", "duration": 30, } - assert '"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout - assert '"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + assert "tjwater-cli data timeseries realtime links" in result.stdout + assert "tjwater-cli data timeseries realtime nodes" in result.stdout def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): - auth_path = tmp_path / "auth.json" - auth_path.write_text( - '{"server":"http://server","access_token":"abc","network":"demo"}', - encoding="utf-8", - ) + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") output = tmp_path / "demo.inp" calls = [] @@ -320,7 +310,7 @@ def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): result = runner.invoke( app, - ["--auth-context", str(auth_path), "project", "export-inp", "--output", str(output)], + ["project", "export-inp", "--output", str(output)], ) assert result.exit_code == 0 diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 3708caa..47164fa 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -58,8 +58,8 @@ def simulation_run( require_auth=True, require_network_ctx=True, next_commands=[ - f"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", - f"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", ], ) @@ -92,8 +92,8 @@ def analysis_burst( require_auth=True, require_network_ctx=True, next_commands=[ - f"tjwater-cli --auth-context auth.json data scheme get --name {scheme_name}", - "tjwater-cli --auth-context auth.json data scheme list", + f"tjwater-cli data scheme get --name {scheme_name}", + "tjwater-cli data scheme list", ], ) diff --git a/cli/tjwater_cli/common.py b/cli/tjwater_cli/common.py index b03624c..a112150 100644 --- a/cli/tjwater_cli/common.py +++ b/cli/tjwater_cli/common.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path from typing import Any import typer @@ -12,7 +11,7 @@ def runtime_context(ctx: typer.Context): obj = ctx.obj or {} return build_runtime_context( server=obj.get("server"), - auth_context_path=obj.get("auth_context"), + auth_stdin=obj.get("auth_stdin", False), scheme=obj.get("scheme"), timeout=obj.get("timeout", DEFAULT_TIMEOUT), request_id=obj.get("request_id"), diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index cf1187e..34eaeb4 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import os +import sys import time import uuid from dataclasses import dataclass, field @@ -80,25 +81,6 @@ class CommandDoc: output: str = "标准 JSON 输出" -def _read_json_file(path: Path) -> dict[str, Any]: - try: - return json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise CLIError( - "认证失败", - code="AUTH_CONTEXT_NOT_FOUND", - message=f"auth context file not found: {path}", - exit_code=3, - ) from exc - except json.JSONDecodeError as exc: - raise CLIError( - "认证失败", - code="AUTH_CONTEXT_INVALID", - message=f"auth context file is not valid JSON: {path}", - exit_code=3, - ) from exc - - def _pick(mapping: Mapping[str, Any], *keys: str) -> Any: for key in keys: value = mapping.get(key) @@ -107,10 +89,9 @@ def _pick(mapping: Mapping[str, Any], *keys: str) -> Any: return None -def load_auth_context(auth_context_path: Path | None) -> AuthContext: - raw: dict[str, Any] = {} - if auth_context_path is not None: - raw = _read_json_file(auth_context_path) +def load_auth_context(auth_stdin: bool = False) -> AuthContext: + if auth_stdin: + raw = json.loads(sys.stdin.read()) else: extra_headers = os.getenv("TJWATER_EXTRA_HEADERS") raw = { @@ -146,12 +127,12 @@ def load_auth_context(auth_context_path: Path | None) -> AuthContext: def build_runtime_context( *, server: str | None, - auth_context_path: Path | None, + auth_stdin: bool = False, scheme: str | None, timeout: int, request_id: str | None, ) -> RuntimeContext: - auth = load_auth_context(auth_context_path) + auth = load_auth_context(auth_stdin=auth_stdin) resolved_request_id = request_id or str(uuid.uuid4()) return RuntimeContext( server=server or auth.server or DEFAULT_SERVER, @@ -181,7 +162,7 @@ def require_access_token(ctx: RuntimeContext) -> str: code="UNAUTHENTICATED", message="missing access token for agent context", exit_code=3, - next_commands=["tjwater-cli --auth-context /path/to/auth-context.json"], + next_commands=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"], ) @@ -193,7 +174,7 @@ def require_project_id(ctx: RuntimeContext) -> str: code="PROJECT_CONTEXT_REQUIRED", message="missing project_id for agent context", exit_code=3, - next_commands=["add project_id to the auth context file"], + next_commands=["add project_id to auth context"], ) @@ -205,7 +186,7 @@ def require_network(ctx: RuntimeContext) -> str: code="NETWORK_CONTEXT_REQUIRED", message="missing network in auth context for legacy network-based endpoints", exit_code=3, - next_commands=["add network to the auth context file"], + next_commands=["add network to auth context"], ) @@ -217,7 +198,7 @@ def require_username(ctx: RuntimeContext) -> str: code="USERNAME_CONTEXT_REQUIRED", message="missing username in auth context", exit_code=3, - next_commands=["add username to the auth context file"], + next_commands=["add username to auth context"], ) diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index 5f01547..1c35acd 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -161,11 +161,10 @@ def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None ] if existing_examples: for example in existing_examples: - has_auth = "--auth-context" in example has_required_options = all(f"--{option_name}" in example for option_name in required_option_names) - if has_auth and has_required_options: + if has_required_options: return example - parts = ["tjwater-cli", "--auth-context", "auth.json", *path] + parts = ["tjwater-cli", *path] if ctx is None: return " ".join(parts) for parameter in ctx.command.params: diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py index 9cddbdb..7503b31 100644 --- a/cli/tjwater_cli/main.py +++ b/cli/tjwater_cli/main.py @@ -27,14 +27,14 @@ from .helping import ( def root_callback( ctx: typer.Context, server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None, - auth_context: Annotated[Path | None, typer.Option("--auth-context", help="认证上下文 JSON 文件")] = None, + auth_stdin: Annotated[bool, typer.Option("--auth-stdin", help="从标准输入读取认证上下文 JSON")] = False, scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None, timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT, request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None, ) -> None: ctx.obj = { "server": server, - "auth_context": auth_context, + "auth_stdin": auth_stdin, "scheme": scheme, "timeout": timeout, "request_id": request_id, diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 8b964f5..6dd5f46 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -39,15 +39,14 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { path=("project", "list"), summary="列出当前用户可访问项目", description="调用 /meta/projects 返回项目列表。", - examples=("tjwater-cli --auth-context auth.json project list",), - next_commands=("tjwater-cli --auth-context auth.json project info",), - output="项目摘要列表", + examples=("tjwater-cli project list",), + next_commands=("tjwater-cli project info",), ), ("project", "info"): CommandDoc( path=("project", "info"), - summary="读取当前项目元数据", - description="调用 /meta/project 返回当前 project 详情。", - examples=("tjwater-cli --auth-context auth.json project info",), + summary="查看当前项目摘要信息。", + description="查看当前项目的基础信息。", + examples=("tjwater-cli project info",), output="项目元数据", ), ("project", "db-health"): CommandDoc( @@ -109,8 +108,8 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("duration", "持续分钟数", required=True), ), next_commands=( - "tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", - "tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", ), output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询", ), @@ -125,20 +124,23 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("scheme", "方案名称"), ), examples=( - "tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + "tjwater-cli data scheme get --name burst_case_01", + "tjwater-cli data scheme list", ), - next_commands=( - "tjwater-cli --auth-context auth.json data scheme get --name burst_case_01", - "tjwater-cli --auth-context auth.json data scheme list", - ), - output="分析执行结果;方案详情需通过 data scheme 命令单独查询", ), ("analysis", "valve"): CommandDoc( path=("analysis", "valve"), - summary="执行阀门关闭或隔离分析", - description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve。", + summary="阀门工况分析。", + description="指定阀门采取关闭/开启等操作逻辑,并执行定时长模拟。结果写入时序库。", + options=( + CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'open'", required=True), + CommandOptionDoc(name="start-time", description="起始绝对时间,必须显式带时区偏移", required=True), + CommandOptionDoc(name="valve", description="阀门 ID(可多次指定)", required=True, repeated=True), + CommandOptionDoc(name="duration", description="模拟持续分钟数", required=True), + ), examples=( - "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", + "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", ), ), ("analysis", "flushing"): CommandDoc( From f87dd91b2bf7ad253f1a62367203cf8017aa874b Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 18:41:39 +0800 Subject: [PATCH 24/93] =?UTF-8?q?=E4=BF=AE=E5=A4=8D--auth-stdin=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=A4=B1=E8=B4=A5=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 34 +++++++++++++++++++++++++++++- cli/tjwater_cli/common.py | 14 ++++++++++-- cli/tjwater_cli/core.py | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index dce51f4..99c75d5 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -3,7 +3,7 @@ from pathlib import Path from typer.testing import CliRunner -from tjwater_cli import core +from tjwater_cli import common, core from tjwater_cli.main import app, main @@ -65,6 +65,38 @@ def test_build_runtime_context_uses_default_server(monkeypatch): assert runtime.server == core.DEFAULT_SERVER +def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): + observed_runtime_ids: list[int] = [] + + def fake_request_json(ctx, **kwargs): + observed_runtime_ids.append(id(ctx)) + assert ctx.auth.access_token == "token-1" + assert kwargs["params"] == {"network": "tjwater", "node": "11"} + return {"node": "11"}, 5 + + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke( + app, + ["--auth-stdin", "network", "get-node-properties", "--node", "11"], + input=json.dumps( + { + "server": "http://server", + "access_token": "token-1", + "project_id": "project-1", + "network": "tjwater", + } + ), + ) + + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == {"node": "11"} + assert len(observed_runtime_ids) == 1 + + def test_help_outputs_json_lists_commands(): result = runner.invoke(app, ["help"]) payload = json.loads(result.stdout) diff --git a/cli/tjwater_cli/common.py b/cli/tjwater_cli/common.py index a112150..fef8b64 100644 --- a/cli/tjwater_cli/common.py +++ b/cli/tjwater_cli/common.py @@ -8,14 +8,24 @@ from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_ def runtime_context(ctx: typer.Context): - obj = ctx.obj or {} - return build_runtime_context( + obj = ctx.obj + if not isinstance(obj, dict): + obj = {} + ctx.obj = obj + + cached_runtime = obj.get("_runtime_context") + if cached_runtime is not None: + return cached_runtime + + runtime = build_runtime_context( server=obj.get("server"), auth_stdin=obj.get("auth_stdin", False), scheme=obj.get("scheme"), timeout=obj.get("timeout", DEFAULT_TIMEOUT), request_id=obj.get("request_id"), ) + obj["_runtime_context"] = runtime + return runtime def emit_api( diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 34eaeb4..3fdd5da 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -119,7 +119,7 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext: project_id=_pick(raw, "project_id", "projectId", "x_project_id"), user_id=_pick(raw, "user_id", "userId", "x_user_id"), username=_pick(raw, "username", "preferred_username"), - network=_pick(raw, "network", "project_code", "projectCode", "project"), + network="tjwater", headers={str(key): str(value) for key, value in headers.items()}, ) From 4982efba5ec3556ea3ecb8549b1e3cf1a4919df7 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 3 Jun 2026 10:48:01 +0800 Subject: [PATCH 25/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0tjwater-cli=20network?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=9B=E6=9B=B4=E6=96=B0metadb=20health?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/meta.py | 24 +++++++-- app/domain/schemas/metadata.py | 14 ++--- cli/tjwater_cli/core.py | 2 +- tests/api/test_meta_endpoints.py | 92 ++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 tests/api/test_meta_endpoints.py diff --git a/app/api/v1/endpoints/meta.py b/app/api/v1/endpoints/meta.py index 455189e..c6a6f45 100644 --- a/app/api/v1/endpoints/meta.py +++ b/app/api/v1/endpoints/meta.py @@ -1,5 +1,6 @@ import logging from fastapi import APIRouter, Depends, HTTPException, status, Query, Path +import psycopg from psycopg import AsyncConnection from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError @@ -58,6 +59,7 @@ async def get_project_metadata( code=project.code, description=project.description, gs_workspace=project.gs_workspace, + map_extent=project.map_extent, status=project.status, project_role=ctx.project_role, geoserver=geoserver_payload, @@ -110,7 +112,23 @@ async def project_db_health( 检查PostgreSQL和TimescaleDB数据库的连接状态 """ - await pg_session.execute(text("SELECT 1")) - async with ts_conn.cursor() as cur: - await cur.execute("SELECT 1") + try: + await pg_session.execute(text("SELECT 1")) + except SQLAlchemyError as exc: + logger.error("Project PostgreSQL health check failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Project PostgreSQL health check failed: {exc}", + ) from exc + + try: + async with ts_conn.cursor() as cur: + await cur.execute("SELECT 1") + except psycopg.Error as exc: + logger.error("Project TimescaleDB health check failed", exc_info=True) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Project TimescaleDB health check failed: {exc}", + ) from exc + return {"postgres": "ok", "timescale": "ok"} diff --git a/app/domain/schemas/metadata.py b/app/domain/schemas/metadata.py index 91dc4c3..db3220a 100644 --- a/app/domain/schemas/metadata.py +++ b/app/domain/schemas/metadata.py @@ -5,10 +5,10 @@ from pydantic import BaseModel class GeoServerConfigResponse(BaseModel): - gs_base_url: Optional[str] - gs_admin_user: Optional[str] + gs_base_url: Optional[str] = None + gs_admin_user: Optional[str] = None gs_datastore_name: str - default_extent: Optional[dict] + default_extent: Optional[dict] = None srid: int @@ -16,19 +16,19 @@ class ProjectMetaResponse(BaseModel): project_id: UUID name: str code: str - description: Optional[str] + description: Optional[str] = None gs_workspace: str - map_extent: Optional[dict] + map_extent: Optional[dict] = None status: str project_role: str - geoserver: Optional[GeoServerConfigResponse] + geoserver: Optional[GeoServerConfigResponse] = None class ProjectSummaryResponse(BaseModel): project_id: UUID name: str code: str - description: Optional[str] + description: Optional[str] = None gs_workspace: str status: str project_role: str diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 3fdd5da..34eaeb4 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -119,7 +119,7 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext: project_id=_pick(raw, "project_id", "projectId", "x_project_id"), user_id=_pick(raw, "user_id", "userId", "x_user_id"), username=_pick(raw, "username", "preferred_username"), - network="tjwater", + network=_pick(raw, "network", "project_code", "projectCode", "project"), headers={str(key): str(value) for key, value in headers.items()}, ) diff --git a/tests/api/test_meta_endpoints.py b/tests/api/test_meta_endpoints.py new file mode 100644 index 0000000..2313b03 --- /dev/null +++ b/tests/api/test_meta_endpoints.py @@ -0,0 +1,92 @@ +from types import SimpleNamespace +from uuid import uuid4 + +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError + +from tests.conftest import build_test_app, install_stub, load_module_from_path + + +def _load_meta_module(monkeypatch): + install_stub(monkeypatch, "app.auth", package=True) + install_stub( + monkeypatch, + "app.auth.project_dependencies", + { + "ProjectContext": object, + "get_project_context": lambda: None, + "get_project_pg_session": lambda: None, + "get_project_timescale_connection": lambda: None, + "get_metadata_repository": lambda: None, + }, + ) + install_stub( + monkeypatch, + "app.auth.metadata_dependencies", + {"get_current_metadata_user": lambda: None}, + ) + return load_module_from_path( + "tests_meta_endpoints_module", + "app/api/v1/endpoints/meta.py", + ) + + +def test_meta_project_returns_map_extent(monkeypatch): + module = _load_meta_module(monkeypatch) + project_id = uuid4() + repo = SimpleNamespace( + get_project_by_id=lambda _project_id: None, + get_geoserver_config=lambda _project_id: None, + ) + + async def get_project_by_id(_project_id): + return SimpleNamespace( + id=project_id, + name="Demo Project", + code="demo", + description="desc", + gs_workspace="workspace", + map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}, + status="active", + ) + + async def get_geoserver_config(_project_id): + return None + + repo.get_project_by_id = get_project_by_id + repo.get_geoserver_config = get_geoserver_config + + app = build_test_app(module.router, "/api/v1") + app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace( + project_id=project_id, + project_role="editor", + ) + app.dependency_overrides[module.get_metadata_repository] = lambda: repo + client = TestClient(app) + + response = client.get("/api/v1/meta/project") + + assert response.status_code == 200 + assert response.json()["map_extent"] == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4} + + +def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch): + module = _load_meta_module(monkeypatch) + + class BrokenSession: + async def execute(self, _query): + raise SQLAlchemyError("pg unavailable") + + class DummyTimescaleConnection: + def cursor(self): + raise AssertionError("timescale should not be queried after postgres failure") + + app = build_test_app(module.router, "/api/v1") + app.dependency_overrides[module.get_project_pg_session] = lambda: BrokenSession() + app.dependency_overrides[module.get_project_timescale_connection] = lambda: DummyTimescaleConnection() + client = TestClient(app) + + response = client.get("/api/v1/meta/db/health") + + assert response.status_code == 503 + assert response.json()["detail"] == "Project PostgreSQL health check failed: pg unavailable" From b9410b0ff39e88a87fe85f6e8c2ea10d193ee216 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 3 Jun 2026 11:17:37 +0800 Subject: [PATCH 26/93] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=97=B6=E9=97=B4=E6=97=B6=E5=8C=BA=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/simulation.py | 50 ++++++------- app/services/simulation.py | 12 ++-- cli/tests/unit/test_tjwater_cli.py | 3 +- cli/tjwater_cli/commands_analysis.py | 3 +- cli/tjwater_cli/registry.py | 2 +- scripts/main.py | 41 +++++------ tests/api/test_simulation_endpoints.py | 98 +++++++++++++++++++++++++- 7 files changed, 147 insertions(+), 62 deletions(-) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 3ce8a2d..18b6977 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -35,16 +35,22 @@ from app.services.simulation_ops import ( daily_scheduling_simulation, ) from app.services.valve_isolation import analyze_valve_isolation -from pydantic import BaseModel, Field +from app.services.time_api import parse_aware_time, parse_utc_time +from pydantic import BaseModel, Field, field_validator router = APIRouter() class RunSimulationManuallyByDate(BaseModel): name: str = Field(..., description="管网名称(或数据库名称)") - simulation_date: str = Field(..., description="模拟基准日期 (YYYY-MM-DD)") - start_time: str = Field(..., description="开始时间 (HH:MM 或 HH:MM:SS)") - duration: int = Field(..., description="持续时间 (分钟)") + start_time: str = Field(..., description="开始时间 (ISO 8601 / RFC3339,必须显式带时区)") + duration: int = Field(..., gt=0, description="持续时间 (分钟)") + + @field_validator("start_time") + @classmethod + def validate_start_time_timezone(cls, value: str) -> str: + parse_aware_time(value, field_name="start_time") + return value class BurstAnalysis(BaseModel): @@ -109,28 +115,15 @@ class PressureSensorPlacement(BaseModel): def run_simulation_manually_by_date( - network_name: str, base_date: datetime, start_time: str, duration: int + network_name: str, start_time: datetime, duration: int ) -> None: - time_parts = list(map(int, start_time.split(":"))) - if len(time_parts) == 2: - start_hour, start_minute = time_parts - start_second = 0 - elif len(time_parts) == 3: - start_hour, start_minute, start_second = time_parts - else: - raise ValueError("Invalid start_time format. Use HH:MM or HH:MM:SS") - - start_datetime = base_date.replace( - hour=start_hour, minute=start_minute, second=start_second - ) - end_datetime = start_datetime + timedelta(minutes=duration) - current_time = start_datetime + end_datetime = start_time + timedelta(minutes=duration) + current_time = start_time while current_time < end_datetime: - iso_time = current_time.strftime("%Y-%m-%dT%H:%M:%S") + "+08:00" simulation.run_simulation( name=network_name, simulation_type="realtime", - modify_pattern_start_time=iso_time, + modify_pattern_start_time=current_time.isoformat(timespec="seconds"), ) current_time += timedelta(minutes=15) @@ -767,7 +760,7 @@ async def fastapi_pressure_sensor_placement( return "success" -@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的日期、开始时间和持续时间,手动运行水力模拟。系统将自动查询管网参数并执行模拟。") +@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") async def fastapi_run_simulation_manually_by_date( data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"), ) -> dict[str, str]: @@ -776,14 +769,13 @@ async def fastapi_run_simulation_manually_by_date( 请求体参数: - **name**: 管网名称(或数据库名称) - - **simulation_date**: 模拟基准日期(YYYY-MM-DD格式) - - **start_time**: 开始时间(HH:MM或HH:MM:SS格式) + - **start_time**: 开始时间(ISO 8601 / RFC3339,必须显式带时区) - **duration**: 模拟持续时间(分钟) - 系统将从指定日期和时间开始,按15分钟间隔多次运行模拟。 + 系统将从指定时间开始,按15分钟间隔多次运行模拟。 每次模拟间隔15分钟,直至达到指定的总持续时间。 """ - item = data.dict() + item = data.model_dump() try: simulation.query_corresponding_element_id_and_query_id(item["name"]) simulation.query_corresponding_pattern_id_and_query_id(item["name"]) @@ -810,10 +802,10 @@ async def fastapi_run_simulation_manually_by_date( globals.source_outflow_region_id, globals.realtime_region_pipe_flow_and_demand_id, ) - base_date = datetime.strptime(item["simulation_date"], "%Y-%m-%d") + start_time = parse_utc_time(item["start_time"], field_name="start_time") run_simulation_manually_by_date( - item["name"], base_date, item["start_time"], item["duration"] + item["name"], start_time, item["duration"] ) return {"status": "success"} except Exception as exc: - return {"status": "error", "message": str(exc)} + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/app/services/simulation.py b/app/services/simulation.py index 204c572..ee49c09 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -34,6 +34,7 @@ import psycopg import logging import app.services.globals as globals import app.services.project_info as project_info +from app.services.time_api import parse_beijing_time from app.core.config import get_pgconn_string from app.infra.db.timescaledb.internal_queries import ( InternalQueries as TimescaleInternalQueries, @@ -661,13 +662,14 @@ def from_seconds_to_clock(secs: int) -> str: def convert_time_format(original_time: str) -> str: """ - 格式转换,将“2024-04-13T08:00:00+08:00"转为“2024-04-13 08:00:00” - :param original_time: str, “2024-04-13T08:00:00+08:00"格式的时间 + 格式转换,将带时区的 ISO 8601 / RFC3339 时间转为北京时间的“YYYY-MM-DD HH:MM:SS” + :param original_time: str,带显式时区的时间 :return: str,“2024-04-13 08:00:00”格式的时间 """ - new_time = original_time.replace("T", " ") - new_time = new_time.replace("+08:00", "") - return new_time + normalized_time = parse_beijing_time( + original_time, field_name="modify_pattern_start_time" + ) + return normalized_time.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") def get_history_pattern_info(project_name, pattern_name): diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 99c75d5..2a84941 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -313,8 +313,7 @@ def test_simulation_run_translates_rfc3339(monkeypatch): assert result.exit_code == 0 assert captured["json"] == { "name": "demo", - "simulation_date": "2025-01-02", - "start_time": "03:04:05+08:00", + "start_time": "2025-01-02T03:04:05+08:00", "duration": 30, } assert "tjwater-cli data timeseries realtime links" in result.stdout diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 47164fa..231d69b 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -45,8 +45,7 @@ def simulation_run( end_time = (parsed + timedelta(minutes=duration)).isoformat() body = { "name": network, - "simulation_date": parsed.date().isoformat(), - "start_time": parsed.timetz().replace(microsecond=0).isoformat(), + "start_time": parsed.replace(microsecond=0).isoformat(), "duration": duration, } emit_api( diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 6dd5f46..5270696 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -102,7 +102,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("simulation", "run"): CommandDoc( path=("simulation", "run"), summary="触发指定绝对时间的模拟运行", - description="把 RFC3339 start-time 拆成 simulation_date 与 start_time 后调用 /runsimulationmanuallybydate/;接口本身只负责触发运行,结果需后续通过 data timeseries 在对应时间段查询。", + description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续分钟数", required=True), diff --git a/scripts/main.py b/scripts/main.py index 070895c..7247e14 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -31,7 +31,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.responses import FileResponse, JSONResponse from contextlib import asynccontextmanager -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from multiprocessing import Value @@ -3654,40 +3654,35 @@ async def fastapi_download_history_data_manually( class Run_Simulation_Manually_by_Date(BaseModel): """ name:数据库名称 - simulation_date:样式如 2025-05-04 - start_time:开始时间,样式如 08:00:00 + start_time:开始时间,样式如 2025-05-04T08:00:00+08:00 duration:持续时间,单位为分钟 """ name: str - simulation_date: str start_time: str duration: int + @field_validator("start_time") + @classmethod + def validate_start_time_timezone(cls, value: str) -> str: + time_api.parse_aware_time(value, field_name="start_time") + return value + def run_simulation_manually_by_date( - network_name: str, base_date: datetime, start_time: str, duration: int + network_name: str, start_time: datetime, duration: int ) -> None: - # 解析开始时间 - start_hour, start_minute, start_second = map(int, start_time.split(":")) - start_datetime = base_date.replace( - hour=start_hour, minute=start_minute, second=start_second - ) - # 计算结束时间 - end_datetime = start_datetime + timedelta(minutes=duration) + end_datetime = start_time + timedelta(minutes=duration) # 生成时间点,每15分钟一个 - current_time = start_datetime + current_time = start_time while current_time < end_datetime: - # 格式化成ISO8601带时区格式 - iso_time = current_time.strftime("%Y-%m-%dT%H:%M:%S") + "+08:00" - ## 执行函数调用 simulation.run_simulation( name=network_name, simulation_type="realtime", - modify_pattern_start_time=iso_time, + modify_pattern_start_time=current_time.isoformat(timespec="seconds"), ) # 增加15分钟 @@ -3698,7 +3693,7 @@ def run_simulation_manually_by_date( async def fastapi_run_simulation_manually_by_date( data: Run_Simulation_Manually_by_Date, ) -> dict[str, str]: - item = data.dict() + item = data.model_dump() print(f"item: {item}") filename = "c:/lock.simulation" @@ -3740,11 +3735,13 @@ async def fastapi_run_simulation_manually_by_date( globals.realtime_region_pipe_flow_and_demand_id, ) - base_date = datetime.strptime(item["simulation_date"], "%Y-%m-%d") + start_time = time_api.parse_utc_time( + item["start_time"], field_name="start_time" + ) thread = threading.Thread( target=lambda: run_simulation_manually_by_date( - item["name"], base_date, item["start_time"], item["duration"] + item["name"], start_time, item["duration"] ) ) @@ -3753,11 +3750,11 @@ async def fastapi_run_simulation_manually_by_date( return {"status": "success"} except Exception as e: - return {"status": "error", "message": str(e)} + raise HTTPException(status_code=500, detail=str(e)) from e # thread.join() # DingZQ 08152025 - # matched_keys = redis_client.keys(f"*{item['simulation_date']}*") + # matched_keys = redis_client.keys(...) # redis_client.delete(*matched_keys) diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index e9cdb1c..38f777f 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -1,4 +1,5 @@ from pathlib import Path +from datetime import datetime, timezone from fastapi.testclient import TestClient @@ -7,10 +8,39 @@ from tests.conftest import build_test_app, install_stub, load_module_from_path def _load_simulation_module(monkeypatch): install_stub(monkeypatch, "app.services", package=True) + def parse_aware_time(value, field_name="datetime"): + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if dt.tzinfo is None: + raise ValueError(f"{field_name} is missing timezone information.") + return dt + + def parse_utc_time(value, field_name="datetime"): + return parse_aware_time(value, field_name=field_name).astimezone( + timezone.utc + ) + + install_stub( + monkeypatch, + "app.services.time_api", + { + "parse_aware_time": parse_aware_time, + "parse_utc_time": parse_utc_time, + }, + ) install_stub( monkeypatch, "app.services.simulation", - {"run_simulation": lambda **kwargs: None}, + { + "run_simulation": lambda **kwargs: None, + "query_corresponding_element_id_and_query_id": lambda name: None, + "query_corresponding_pattern_id_and_query_id": lambda name: None, + "query_non_realtime_region": lambda name: [], + "get_source_outflow_region_id": lambda name, region_result: {}, + "query_realtime_region_pipe_flow_and_demand_id": lambda name, region_result: {}, + "query_pipe_flow_region_patterns": lambda name: {}, + "query_non_realtime_region_patterns": lambda name, region_result: {}, + "get_realtime_region_patterns": lambda name, source_outflow_region_id, realtime_region_pipe_flow_and_demand_id: ({}, {}), + }, ) install_stub(monkeypatch, "app.services.globals", {}) install_stub( @@ -173,3 +203,69 @@ def test_network_update_surfaces_service_error(monkeypatch, tmp_path): assert response.status_code == 500 assert "数据库操作失败: write failed" in response.json()["detail"] assert list(Path(tmp_path).glob("network_update_*")) + + +def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured_calls = [] + + monkeypatch.setattr( + module.simulation, + "run_simulation", + lambda **kwargs: captured_calls.append(kwargs), + ) + + module.run_simulation_manually_by_date( + "demo", + datetime(2025, 1, 1, 19, 4, 5, tzinfo=timezone.utc), + 30, + ) + + assert [call["modify_pattern_start_time"] for call in captured_calls] == [ + "2025-01-01T19:04:05+00:00", + "2025-01-01T19:19:05+00:00", + ] + + +def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_run(network_name, start_time, duration): + captured["network_name"] = network_name + captured["start_time"] = start_time + captured["duration"] = duration + + monkeypatch.setattr(module, "run_simulation_manually_by_date", fake_run) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/runsimulationmanuallybydate/", + json={ + "name": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "duration": 30, + }, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success"} + assert captured["network_name"] == "demo" + assert captured["duration"] == 30 + assert captured["start_time"].isoformat() == "2025-01-01T19:04:05+00:00" + + +def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post( + "/api/v1/runsimulationmanuallybydate/", + json={ + "name": "demo", + "start_time": "2025-01-02T03:04:05", + "duration": 30, + }, + ) + + assert response.status_code == 422 From 233960d8dbb28011a55f9aba777016571437eec0 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 3 Jun 2026 17:31:44 +0800 Subject: [PATCH 27/93] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E9=9C=80=E8=A6=81=20scheme=5Fname=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/algorithms/simulation/scenarios.py | 2 +- app/api/v1/endpoints/simulation.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/algorithms/simulation/scenarios.py b/app/algorithms/simulation/scenarios.py index 239dab6..9890fc8 100644 --- a/app/algorithms/simulation/scenarios.py +++ b/app/algorithms/simulation/scenarios.py @@ -662,7 +662,7 @@ def age_analysis( new_name, "realtime", modify_pattern_start_time, - modify_total_duration, + duration=modify_total_duration, downloading_prohibition=True, ) simulation_result = json.loads(result) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 18b6977..27be343 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -226,6 +226,7 @@ async def fastapi_valve_close_analysis( start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"), valves: List[str] = Query(..., description="要关闭的阀门ID列表"), duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"), + scheme_name: str = Query(..., description="阀门关闭方案名称"), ) -> str: """ 阀门关闭分析(高级版本) @@ -234,6 +235,7 @@ async def fastapi_valve_close_analysis( - **start_time**: 阀门关闭开始时间 - **valves**: 要关闭的阀门ID列表 - **duration**: 模拟持续时间(秒,可选,默认900) + - **scheme_name**: 阀门关闭方案名称 支持同时关闭多个阀门进行分析。 """ @@ -242,6 +244,7 @@ async def fastapi_valve_close_analysis( modify_pattern_start_time=start_time, modify_total_duration=duration or 900, modify_valve_opening={valve_id: 0.0 for valve_id in valves}, + scheme_name=scheme_name, ) return result or "success" @@ -295,7 +298,7 @@ async def fastapi_flushing_analysis( drainage_node_ID: str = Query(..., description="排污节点ID"), flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"), duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"), - scheme_name: str | None = Query(None, description="冲洗方案名称(可选)"), + scheme_name: str = Query(..., description="冲洗方案名称"), ) -> str: """ 冲洗分析(高级版本) @@ -307,7 +310,7 @@ async def fastapi_flushing_analysis( - **drainage_node_ID**: 排污节点ID - **flush_flow**: 冲洗流量(L/s) - **duration**: 模拟持续时间(秒,可选,默认900) - - **scheme_name**: 冲洗方案名称(可选) + - **scheme_name**: 冲洗方案名称 支持多阀联合冲洗操作。 """ @@ -333,7 +336,7 @@ async def fastapi_contaminant_simulation( source: str = Query(..., description="污染源节点ID"), concentration: float = Query(..., description="污染浓度(mg/L)"), duration: int = Query(..., description="模拟持续时间(秒)"), - scheme_name: str | None = Query(None, description="模拟方案名称(可选)"), + scheme_name: str = Query(..., description="模拟方案名称"), pattern: str | None = Query(None, description="污染源模式ID(可选)"), ) -> str: """ @@ -344,7 +347,7 @@ async def fastapi_contaminant_simulation( - **source**: 污染源节点ID - **concentration**: 污染浓度(mg/L) - **duration**: 模拟持续时间(秒) - - **scheme_name**: 模拟方案名称(可选) + - **scheme_name**: 模拟方案名称 - **pattern**: 污染源模式ID(可选) 用于评估管网中污染物的传播和影响范围。 From b7872f29a9c076dce589592c35236c274021b7e3 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 3 Jun 2026 17:31:49 +0800 Subject: [PATCH 28/93] =?UTF-8?q?=E4=BC=98=E5=8C=96=20CLI=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=8E=B7=E5=8F=96=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=8A=82=E7=82=B9=E5=92=8C=E7=AE=A1=E9=81=93=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 324 ++++++++++++++++++++----- cli/tjwater_cli/apps.py | 5 +- cli/tjwater_cli/commands_analysis.py | 10 +- cli/tjwater_cli/commands_project.py | 224 ----------------- cli/tjwater_cli/commands_readonly.py | 144 +++++++++++ cli/tjwater_cli/helping.py | 1 - cli/tjwater_cli/main.py | 2 +- cli/tjwater_cli/registry.py | 276 +++++++++++++++++---- cli/tjwater_cli_endpoint_scope.md | 91 +------ scripts/online_Analysis.py | 2 +- tests/api/test_simulation_endpoints.py | 89 +++++++ tests/unit/test_age_analysis.py | 88 +++++++ 12 files changed, 823 insertions(+), 433 deletions(-) delete mode 100644 cli/tjwater_cli/commands_project.py create mode 100644 cli/tjwater_cli/commands_readonly.py create mode 100644 tests/unit/test_age_analysis.py diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 2a84941..f372d95 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -97,14 +97,58 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): assert len(observed_runtime_ids) == 1 +def test_network_get_all_junction_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["params"] = kwargs["params"] + return [{"id": "J1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-junction-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "J1"}] + assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} + + +def test_network_get_all_pipe_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["params"] = kwargs["params"] + return [{"id": "P1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-pipe-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "P1"}] + assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} + + def test_help_outputs_json_lists_commands(): result = runner.invoke(app, ["help"]) payload = json.loads(result.stdout) assert result.exit_code == 0 assert payload["schema_version"] == "tjwater-cli/v1" - assert any(command["command"] == "project" for command in payload["commands"]) assert any(command["command"] == "analysis" for command in payload["commands"]) + assert all(command["command"] != "project" for command in payload["commands"]) assert payload["menu_level"] == 1 assert all(command["command"] != "project list" for command in payload["commands"]) @@ -137,7 +181,7 @@ def test_nested_group_help_lists_examples(): assert payload["summary"] == "漏损分析相关命令。" commands = {command["command"]: command for command in payload["commands"]} assert commands["analysis leakage identify"]["summary"] == "执行漏损识别" - assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00" + assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01" def test_analysis_help_uses_group_summaries_for_nested_groups(): @@ -150,8 +194,8 @@ def test_analysis_help_uses_group_summaries_for_nested_groups(): assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。" assert "analysis burst-location" not in commands assert "analysis risk" not in commands - assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" - assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" + assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01" + assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01" def test_bare_analysis_uses_typer_help_with_descriptions(): @@ -178,15 +222,6 @@ def test_leaf_help_outputs_json(): assert "simulation run" in payload["examples"][0] -def test_project_help_uses_legal_kind_example(): - result = runner.invoke(app, ["project", "help"]) - payload = json.loads(result.stdout) - commands = {command["command"]: command for command in payload["commands"]} - - assert result.exit_code == 0 - assert "project data" in commands["project data"]["example"] - - def test_root_help_flag_uses_typer_style_with_examples(): result = runner.invoke(app, ["--help"], prog_name="tjwater-cli") @@ -244,6 +279,214 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: assert "tjwater-cli data scheme list" in result.stdout +def test_analysis_contaminant_sends_required_scheme_name(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "analysis", + "contaminant", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "900", + "--source-node", + "N1", + "--concentration", + "10.0", + "--scheme", + "contam_case_01", + ], + ) + + assert result.exit_code == 0 + assert captured["params"] == { + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "source": "N1", + "concentration": 10.0, + "duration": 900, + "scheme_name": "contam_case_01", + } + + +def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + captured = {} + valve_path = tmp_path / "valve.json" + valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "analysis", + "flushing", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve-setting-file", + str(valve_path), + "--drainage-node", + "N1", + "--flow", + "100.0", + "--duration", + "900", + "--scheme", + "flush_case_01", + ], + ) + + assert result.exit_code == 0 + assert captured["params"] == { + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1"], + "valves_k": [0.5], + "drainage_node_ID": "N1", + "flush_flow": 100.0, + "duration": 900, + "scheme_name": "flush_case_01", + } + + +def test_analysis_valve_close_sends_required_scheme_name(monkeypatch): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "analysis", + "valve", + "--mode", + "close", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve", + "V1", + "--duration", + "900", + "--scheme", + "valve_case_01", + ], + ) + + assert result.exit_code == 0 + assert captured["params"] == { + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1"], + "duration": 900, + "scheme_name": "valve_case_01", + } + + +def test_analysis_contaminant_requires_scheme(monkeypatch, capsys): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + + exit_code = main( + [ + "analysis", + "contaminant", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "900", + "--source-node", + "N1", + "--concentration", + "10.0", + ], + ) + + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "SCHEME_REQUIRED"' in stdout + + +def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + valve_path = tmp_path / "valve.json" + valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") + + exit_code = main( + [ + "analysis", + "flushing", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve-setting-file", + str(valve_path), + "--drainage-node", + "N1", + "--flow", + "100.0", + ], + ) + + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "SCHEME_REQUIRED"' in stdout + + +def test_analysis_valve_close_requires_scheme(monkeypatch, capsys): + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "demo") + + exit_code = main( + [ + "analysis", + "valve", + "--mode", + "close", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--valve", + "V1", + "--duration", + "900", + ], + ) + + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "SCHEME_REQUIRED"' in stdout + + def test_main_missing_option_error_includes_usage_and_next_step(capsys): exit_code = main(["simulation", "run"]) stdout = capsys.readouterr().out @@ -265,27 +508,6 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): assert '"ok": false' not in stdout -def test_project_list_uses_auth_stdin(monkeypatch): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_PROJECT_ID", "pid") - monkeypatch.setenv("TJWATER_NETWORK", "demo") - captured = {} - - def fake_request(**kwargs): - captured.update(kwargs) - return DummyResponse(json_data=[{"project_id": "pid", "name": "Demo"}]) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke(app, ["project", "list"]) - - assert result.exit_code == 0 - assert '"ok": true' in result.stdout - assert captured["headers"]["Authorization"] == "Bearer abc" - assert captured["url"] == "http://server/api/v1/meta/projects" - - def test_simulation_run_translates_rfc3339(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") @@ -320,33 +542,9 @@ def test_simulation_run_translates_rfc3339(monkeypatch): assert "tjwater-cli data timeseries realtime nodes" in result.stdout -def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") - output = tmp_path / "demo.inp" - calls = [] +def test_removed_project_command_returns_not_found(capsys): + exit_code = main(["project", "list"]) + stdout = capsys.readouterr().out - def fake_request(**kwargs): - calls.append(kwargs["url"]) - if kwargs["url"].endswith("/dumpinp/"): - return DummyResponse(json_data=True) - return DummyResponse( - headers={"content-type": "application/octet-stream"}, - content=b"inp-content", - text="inp-content", - ) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - ["project", "export-inp", "--output", str(output)], - ) - - assert result.exit_code == 0 - assert output.read_bytes() == b"inp-content" - assert calls == [ - "http://server/api/v1/dumpinp/", - "http://server/api/v1/downloadinp/", - ] + assert exit_code == 2 + assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout diff --git a/cli/tjwater_cli/apps.py b/cli/tjwater_cli/apps.py index 6108f19..ce3ba92 100644 --- a/cli/tjwater_cli/apps.py +++ b/cli/tjwater_cli/apps.py @@ -5,7 +5,6 @@ import typer from .formatters import TJWaterGroup app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup) -project_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) @@ -30,7 +29,6 @@ data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -app.add_typer(project_app, name="project") app.add_typer(network_app, name="network") app.add_typer(component_app, name="component") component_app.add_typer(component_option_app, name="option") @@ -56,7 +54,6 @@ data_app.add_typer(data_extension_app, name="extension") data_app.add_typer(data_misc_app, name="misc") GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ - (project_app, ("project",)), (network_app, ("network",)), (component_app, ("component",)), (component_option_app, ("component", "option")), @@ -82,4 +79,4 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ (data_misc_app, ("data", "misc")), ] -TOP_LEVEL_COMMANDS = {"help", "project", "network", "component", "simulation", "analysis", "data"} +TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"} diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 231d69b..d490a43 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -106,6 +106,7 @@ def analysis_valve( element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None, duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None, ) -> None: runtime = runtime_context(ctx) network = require_network(runtime) @@ -122,6 +123,7 @@ def analysis_valve( "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "valves": valve, "duration": duration or 900, + "scheme_name": resolve_scheme(runtime, scheme, required=True), } emit_api( ctx, @@ -182,10 +184,8 @@ def analysis_flushing( "drainage_node_ID": drainage_node, "flush_flow": flow, "duration": duration or 900, + "scheme_name": resolve_scheme(runtime, scheme, required=True), } - scheme_name = resolve_scheme(runtime, scheme) - if scheme_name: - params["scheme_name"] = scheme_name emit_api( ctx, summary="冲洗分析执行成功", @@ -236,10 +236,8 @@ def analysis_contaminant( "source": source_node, "concentration": concentration, "duration": duration, + "scheme_name": resolve_scheme(runtime, scheme, required=True), } - scheme_name = resolve_scheme(runtime, scheme) - if scheme_name: - params["scheme_name"] = scheme_name if pattern: params["pattern"] = pattern emit_api( diff --git a/cli/tjwater_cli/commands_project.py b/cli/tjwater_cli/commands_project.py deleted file mode 100644 index 4470e91..0000000 --- a/cli/tjwater_cli/commands_project.py +++ /dev/null @@ -1,224 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Annotated, Any - -import typer - -from .apps import component_option_app, network_app, project_app -from .common import emit_api, runtime_context -from .core import CLIError, emit_success, request_bytes, request_json, require_network - - -@project_app.command("list") -def project_list(ctx: typer.Context) -> None: - emit_api(ctx, summary="读取项目列表成功", method="GET", path="/meta/projects", require_auth=True) - - -@project_app.command("info") -def project_info(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取项目信息成功", - method="GET", - path="/meta/project", - require_auth=True, - require_project=True, - ) - - -@project_app.command("db-health") -def project_db_health(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取数据库健康状态成功", - method="GET", - path="/meta/db/health", - require_auth=True, - require_project=True, - ) - - -@project_app.command("data") -def project_data( - ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="scada-info|scheme-list|burst-locate-result")], -) -> None: - kind_map = { - "scada-info": "/scada-info", - "scheme-list": "/scheme-list", - "burst-locate-result": "/burst-locate-result", - } - path = kind_map.get(kind) - if path is None: - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="kind must be one of: scada-info, scheme-list, burst-locate-result", - exit_code=2, - ) - emit_api( - ctx, - summary="读取项目数据成功", - method="GET", - path=path, - require_auth=True, - require_project=True, - ) - - -@project_app.command("export-inp") -def project_export_inp( - ctx: typer.Context, - output: Annotated[Path, typer.Option("--output", help="本地输出路径")], -) -> None: - runtime = runtime_context(ctx) - network = require_network(runtime) - output.parent.mkdir(parents=True, exist_ok=True) - temp_name = f"{output.stem}-{runtime.request_id}.inp" - _, duration_dump = request_json( - runtime, - method="GET", - path="/dumpinp/", - params={"network": network, "inp": temp_name}, - require_auth=True, - require_network_ctx=True, - ) - content, duration_download = request_bytes( - runtime, - method="GET", - path="/downloadinp/", - params={"name": temp_name}, - require_auth=True, - require_network_ctx=True, - ) - output.write_bytes(content) - emit_success( - summary="导出 INP 成功", - data={"output": str(output), "bytes": len(content)}, - ctx=runtime, - duration_ms=duration_dump + duration_download, - next_commands=["tjwater-cli project info"], - ) - - -@network_app.command("get-node-properties") -def network_get_node_properties( - ctx: typer.Context, - node: Annotated[str, typer.Option("--node", help="节点 ID")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取节点属性成功", - method="GET", - path="/getnodeproperties/", - params={"network": require_network(runtime), "node": node}, - require_auth=True, - require_network_ctx=True, - ) - - -@network_app.command("get-link-properties") -def network_get_link_properties( - ctx: typer.Context, - link: Annotated[str, typer.Option("--link", help="管线 ID")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取管线属性成功", - method="GET", - path="/getlinkproperties/", - params={"network": require_network(runtime), "link": link}, - require_auth=True, - require_network_ctx=True, - ) - - -def _component_option_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: - if kind == "time": - return "/gettimeschema", {} - if kind == "energy": - return "/getenergyschema/", {} - if kind == "pump-energy": - if not pump: - raise CLIError( - "CLI 参数错误", - code="PUMP_REQUIRED", - message="--pump is required when --kind pump-energy", - exit_code=2, - ) - return "/getpumpenergyschema/", {"pump": pump} - if kind == "network": - return "/getoptionschema/", {} - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="kind must be one of: time, energy, pump-energy, network", - exit_code=2, - ) - - -def _component_option_get_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: - if kind == "time": - return "/gettimeproperties/", {} - if kind == "energy": - return "/getenergyproperties/", {} - if kind == "pump-energy": - if not pump: - raise CLIError( - "CLI 参数错误", - code="PUMP_REQUIRED", - message="--pump is required when --kind pump-energy", - exit_code=2, - ) - return "/getpumpenergyproperties/", {"pump": pump} - if kind == "network": - return "/getoptionproperties/", {} - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="kind must be one of: time, energy, pump-energy, network", - exit_code=2, - ) - - -@component_option_app.command("schema") -def component_option_schema( - ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], - pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, -) -> None: - runtime = runtime_context(ctx) - path, extra = _component_option_mapping(kind, pump) - params = {"network": require_network(runtime)} | extra - emit_api( - ctx, - summary="读取选项 schema 成功", - method="GET", - path=path, - params=params, - require_auth=True, - require_network_ctx=True, - ) - - -@component_option_app.command("get") -def component_option_get( - ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], - pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, -) -> None: - runtime = runtime_context(ctx) - path, extra = _component_option_get_mapping(kind, pump) - params = {"network": require_network(runtime)} | extra - emit_api( - ctx, - summary="读取选项属性成功", - method="GET", - path=path, - params=params, - require_auth=True, - require_network_ctx=True, - ) diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py new file mode 100644 index 0000000..cddc0a8 --- /dev/null +++ b/cli/tjwater_cli/commands_readonly.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from .apps import component_option_app, network_app +from .common import emit_api, runtime_context +from .core import CLIError, require_network + + +@network_app.command("get-node-properties") +def network_get_node_properties( + ctx: typer.Context, + node: Annotated[str, typer.Option("--node", help="节点 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取节点属性成功", + method="GET", + path="/getnodeproperties/", + params={"network": require_network(runtime), "node": node}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-link-properties") +def network_get_link_properties( + ctx: typer.Context, + link: Annotated[str, typer.Option("--link", help="管线 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取管线属性成功", + method="GET", + path="/getlinkproperties/", + params={"network": require_network(runtime), "link": link}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-junction-properties") +def network_get_all_junction_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部节点属性成功", + method="GET", + path="/getalljunctionproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-pipe-properties") +def network_get_all_pipe_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部管道属性成功", + method="GET", + path="/getallpipeproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@component_option_app.command("schema") +def component_option_schema( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path = _component_option_path(kind, schema=True) + params = {"network": require_network(runtime)} + if kind == "pump-energy" and pump: + params["pump"] = pump + emit_api( + ctx, + summary="读取选项 schema 成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@component_option_app.command("get") +def component_option_get( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path = _component_option_path(kind, schema=False) + params = {"network": require_network(runtime)} + if kind == "pump-energy": + if not pump: + raise CLIError( + "CLI 参数错误", + code="PUMP_REQUIRED", + message="--pump is required when --kind pump-energy", + exit_code=2, + ) + params["pump"] = pump + emit_api( + ctx, + summary="读取选项属性成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +def _component_option_path(kind: str, *, schema: bool) -> str: + routes = { + ("time", True): "/gettimeschema", + ("time", False): "/gettimeproperties/", + ("energy", True): "/getenergyschema/", + ("energy", False): "/getenergyproperties/", + ("pump-energy", True): "/getpumpenergyschema/", + ("pump-energy", False): "/getpumpenergyproperties//", + ("network", True): "/getoptionschema/", + ("network", False): "/getoptionproperties/", + } + path = routes.get((kind, schema)) + if path is None: + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="--kind must be one of time, energy, pump-energy, network", + exit_code=2, + ) + return path diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index 1c35acd..a88a650 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -97,7 +97,6 @@ def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]: def _sample_option_value(path: tuple[str, ...], option_name: str) -> str: path_specific_samples: dict[tuple[tuple[str, ...], str], str] = { - (("project", "data"), "kind"): "scada-info", (("component", "option", "schema"), "kind"): "time", (("component", "option", "get"), "kind"): "time", (("data", "timeseries", "composite"), "kind"): "scada-simulation", diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py index 7503b31..50a7bfc 100644 --- a/cli/tjwater_cli/main.py +++ b/cli/tjwater_cli/main.py @@ -8,7 +8,7 @@ import click import typer from click.exceptions import NoArgsIsHelpError -from . import commands_analysis, commands_data, commands_project # noqa: F401 +from . import commands_analysis, commands_data, commands_readonly # noqa: F401 from .apps import app from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure from .helping import ( diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 5270696..3eb277a 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -3,7 +3,6 @@ from __future__ import annotations from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION GROUP_SUMMARIES: dict[tuple[str, ...], str] = { - ("project",): "项目与项目级元数据相关命令。", ("network",): "管网节点、管线等基础属性查询命令。", ("component",): "组件选项与配置读取命令。", ("component", "option"): "组件选项查询命令。", @@ -35,51 +34,31 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( ) COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { - ("project", "list"): CommandDoc( - path=("project", "list"), - summary="列出当前用户可访问项目", - description="调用 /meta/projects 返回项目列表。", - examples=("tjwater-cli project list",), - next_commands=("tjwater-cli project info",), - ), - ("project", "info"): CommandDoc( - path=("project", "info"), - summary="查看当前项目摘要信息。", - description="查看当前项目的基础信息。", - examples=("tjwater-cli project info",), - output="项目元数据", - ), - ("project", "db-health"): CommandDoc( - path=("project", "db-health"), - summary="检查当前项目数据库健康状态", - description="调用 /meta/db/health 返回 PostgreSQL 与 Timescale 健康状态。", - ), - ("project", "export-inp"): CommandDoc( - path=("project", "export-inp"), - summary="导出当前项目 INP 到本地文件", - description="先调用 /dumpinp/ 在服务端生成 INP,再通过 /downloadinp/ 下载到本地。", - options=( - CommandOptionDoc("output", "本地输出路径", required=True), - ), - output="本地文件路径和下载摘要", - ), - ("project", "data"): CommandDoc( - path=("project", "data"), - summary="读取当前项目业务数据", - description="kind 支持 scada-info、scheme-list、burst-locate-result。", - options=(CommandOptionDoc("kind", "数据类型", required=True),), - ), ("network", "get-node-properties"): CommandDoc( path=("network", "get-node-properties"), summary="读取节点属性", description="调用 /getnodeproperties/。", options=(CommandOptionDoc("node", "节点 ID", required=True),), + examples=("tjwater-cli network get-node-properties --node J1",), ), ("network", "get-link-properties"): CommandDoc( path=("network", "get-link-properties"), summary="读取管线属性", description="调用 /getlinkproperties/。", options=(CommandOptionDoc("link", "管线 ID", required=True),), + examples=("tjwater-cli network get-link-properties --link P1",), + ), + ("network", "get-all-junction-properties"): CommandDoc( + path=("network", "get-all-junction-properties"), + summary="读取全部节点属性", + description="调用 /getalljunctionproperties/。", + examples=("tjwater-cli network get-all-junction-properties",), + ), + ("network", "get-all-pipe-properties"): CommandDoc( + path=("network", "get-all-pipe-properties"), + summary="读取全部管道属性", + description="调用 /getallpipeproperties/。", + examples=("tjwater-cli network get-all-pipe-properties",), ), ("component", "option", "schema"): CommandDoc( path=("component", "option", "schema"), @@ -89,6 +68,12 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("kind", "选项类型", required=True), CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), ), + examples=( + "tjwater-cli component option schema --kind time", + "tjwater-cli component option schema --kind energy", + "tjwater-cli component option schema --kind pump-energy --pump PUMP1", + "tjwater-cli component option schema --kind network", + ), ), ("component", "option", "get"): CommandDoc( path=("component", "option", "get"), @@ -98,15 +83,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("kind", "选项类型", required=True), CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), ), + examples=( + "tjwater-cli component option get --kind time", + "tjwater-cli component option get --kind energy", + "tjwater-cli component option get --kind pump-energy --pump PUMP1", + "tjwater-cli component option get --kind network", + ), ), ("simulation", "run"): CommandDoc( path=("simulation", "run"), summary="触发指定绝对时间的模拟运行", - description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。", + description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续分钟数", required=True), ), + examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",), next_commands=( "tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", @@ -116,7 +108,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst"): CommandDoc( path=("analysis", "burst"), summary="执行爆管分析", - description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。", + description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续秒数", required=True), @@ -124,7 +116,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("scheme", "方案名称"), ), examples=( - "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list", ), @@ -132,201 +124,389 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "valve"): CommandDoc( path=("analysis", "valve"), summary="阀门工况分析。", - description="指定阀门采取关闭/开启等操作逻辑,并执行定时长模拟。结果写入时序库。", + description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。", options=( - CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'open'", required=True), - CommandOptionDoc(name="start-time", description="起始绝对时间,必须显式带时区偏移", required=True), - CommandOptionDoc(name="valve", description="阀门 ID(可多次指定)", required=True, repeated=True), - CommandOptionDoc(name="duration", description="模拟持续分钟数", required=True), + CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'isolation'", required=True), + CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"), + CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True), + CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True), + CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True), + CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"), + CommandOptionDoc(name="scheme", description="close 模式方案名称"), ), examples=( - "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", + "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", + "tjwater-cli analysis valve --mode isolation --element E1 --element E2", + "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3", ), ), ("analysis", "flushing"): CommandDoc( path=("analysis", "flushing"), summary="执行冲洗分析", - description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。", + description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True), + CommandOptionDoc("drainage-node", "排污节点 ID", required=True), + CommandOptionDoc("flow", "冲洗流量", required=True), + CommandOptionDoc("duration", "持续秒数,默认 900"), + CommandOptionDoc("scheme", "方案名称", required=True), + ), + examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",), ), ("analysis", "age"): CommandDoc( path=("analysis", "age"), summary="执行水龄分析", - description="调用 /age_analysis/。", + description="调用 /age_analysis/。duration 单位为秒。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续秒数", required=True), + ), + examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",), ), ("analysis", "contaminant"): CommandDoc( path=("analysis", "contaminant"), summary="执行污染物模拟", - description="调用 /contaminant_simulation/。", + description="调用 /contaminant_simulation/。duration 单位为秒。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续秒数", required=True), + CommandOptionDoc("source-node", "污染源节点 ID", required=True), + CommandOptionDoc("concentration", "浓度值", required=True), + CommandOptionDoc("pattern", "模式 ID"), + CommandOptionDoc("scheme", "方案名称", required=True), + ), + examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",), ), ("analysis", "sensor-placement", "kmeans"): CommandDoc( path=("analysis", "sensor-placement", "kmeans"), summary="执行 KMeans 传感器选址", description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。", + options=( + CommandOptionDoc("count", "传感器数量", required=True), + CommandOptionDoc("min-diameter", "最小管径,默认 0"), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",), ), ("analysis", "leakage", "identify"): CommandDoc( path=("analysis", "leakage", "identify"), summary="执行漏损识别", description="把 CLI 时间映射到 scada_start / scada_end。", + options=( + CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",), ), ("analysis", "leakage", "schemes", "list"): CommandDoc( path=("analysis", "leakage", "schemes", "list"), summary="列出漏损方案", description="调用 /leakage/schemes/。", + examples=("tjwater-cli analysis leakage schemes list",), ), ("analysis", "leakage", "schemes", "get"): CommandDoc( path=("analysis", "leakage", "schemes", "get"), summary="读取漏损方案详情", description="调用 /leakage/schemes/{scheme_name}。", + examples=("tjwater-cli analysis leakage schemes get my_scheme",), ), ("analysis", "burst-detection", "detect"): CommandDoc( path=("analysis", "burst-detection", "detect"), summary="执行爆管检测", description="调用 /burst-detection/detect/。", + options=( + CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + ), + examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",), ), ("analysis", "burst-detection", "schemes", "list"): CommandDoc( path=("analysis", "burst-detection", "schemes", "list"), summary="列出爆管检测方案", description="调用 /burst-detection/schemes/。", + examples=("tjwater-cli analysis burst-detection schemes list",), ), ("analysis", "burst-detection", "schemes", "get"): CommandDoc( path=("analysis", "burst-detection", "schemes", "get"), summary="读取爆管检测方案详情", description="调用 /burst-detection/schemes/{scheme_name}。", + examples=("tjwater-cli analysis burst-detection schemes get my_scheme",), ), ("analysis", "burst-location", "locate"): CommandDoc( path=("analysis", "burst-location", "locate"), summary="执行爆管定位", - description="调用 /burst-location/locate/;需要 burst-leakage。", + description="调用 /burst-location/locate/;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。", + options=( + CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), + CommandOptionDoc("burst-leakage", "爆管漏水量", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"), + CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True), + CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True), + CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"), + CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"), + CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"), + ), + examples=( + "tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01", + "tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json", + ), ), ("analysis", "burst-location", "schemes", "list"): CommandDoc( path=("analysis", "burst-location", "schemes", "list"), summary="列出爆管定位方案", description="调用 /burst-location/schemes/。", + examples=("tjwater-cli analysis burst-location schemes list",), ), ("analysis", "burst-location", "schemes", "get"): CommandDoc( path=("analysis", "burst-location", "schemes", "get"), summary="读取爆管定位方案详情", description="调用 /burst-location/schemes/{scheme_name}。", + examples=("tjwater-cli analysis burst-location schemes get my_scheme",), ), ("analysis", "risk", "pipe-now"): CommandDoc( path=("analysis", "risk", "pipe-now"), summary="读取单条管道当前风险", description="调用 /getpiperiskprobabilitynow/。", + options=(CommandOptionDoc("pipe", "管道 ID", required=True),), + examples=("tjwater-cli analysis risk pipe-now --pipe P1",), ), ("analysis", "risk", "pipe-history"): CommandDoc( path=("analysis", "risk", "pipe-history"), summary="读取单条管道历史风险", description="调用 /getpiperiskprobability/。", + options=(CommandOptionDoc("pipe", "管道 ID", required=True),), + examples=("tjwater-cli analysis risk pipe-history --pipe P1",), ), ("analysis", "risk", "network"): CommandDoc( path=("analysis", "risk", "network"), summary="读取全网风险", description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。", + examples=("tjwater-cli analysis risk network",), ), ("data", "timeseries", "realtime", "links"): CommandDoc( path=("data", "timeseries", "realtime", "links"), summary="查询实时管道时序", description="调用 /realtime/links。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + ), + examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), ("data", "timeseries", "realtime", "nodes"): CommandDoc( path=("data", "timeseries", "realtime", "nodes"), summary="查询实时节点时序", description="调用 /realtime/nodes。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + ), + examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), ("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-id-time"), summary="按元素和时间查询实时模拟结果", description="调用 /realtime/query/by-id-time。", + options=( + CommandOptionDoc("id", "元素 ID", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("time", "显式带时区的查询时间", required=True), + ), + examples=( + "tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", + "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00", + ), ), ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-time-property"), summary="按时间和属性查询实时模拟结果", description="调用 /realtime/query/by-time-property。", + options=( + CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("time", "显式带时区的查询时间", required=True), + CommandOptionDoc("property", "属性名", required=True), + ), + examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",), ), ("data", "timeseries", "scheme", "links"): CommandDoc( path=("data", "timeseries", "scheme", "links"), summary="查询方案管道时序", description="调用 /scheme/links。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + ), + examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",), ), ("data", "timeseries", "scheme", "node-field"): CommandDoc( path=("data", "timeseries", "scheme", "node-field"), summary="查询方案节点字段时序", description="调用 /scheme/nodes/{node_id}/field。", + options=( + CommandOptionDoc("node", "节点 ID", required=True), + CommandOptionDoc("field", "字段名", required=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + ), + examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",), ), ("data", "timeseries", "scheme", "simulation"): CommandDoc( path=("data", "timeseries", "scheme", "simulation"), summary="查询方案模拟数据", description="支持 by-id-time 与 by-scheme-time-property 两种查询。", + options=( + CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"), + CommandOptionDoc("time", "显式带时区的查询时间", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction"), + CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"), + ), + examples=( + "tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", + "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme", + ), ), ("data", "timeseries", "scada", "query"): CommandDoc( path=("data", "timeseries", "scada", "query"), summary="查询 SCADA 时序", description="device-id 会被转换成后端逗号分隔参数。", + options=( + CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("field", "字段名"), + ), + examples=( + "tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", + "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field flow", + ), ), ("data", "timeseries", "composite"): CommandDoc( path=("data", "timeseries", "composite"), summary="执行复合时序查询", description="kind 支持 scada-simulation、element-simulation、element-scada。", + options=( + CommandOptionDoc("kind", "复合查询类型", required=True), + CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_id,element-simulation 为 element_id:property,element-scada 为 element_id)", repeated=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + CommandOptionDoc("scheme", "方案名称"), + CommandOptionDoc("scheme-type", "方案类型"), + CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"), + ), + examples=( + "tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", + "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", + "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned", + ), ), ("data", "timeseries", "composite", "pipeline-health"): CommandDoc( path=("data", "timeseries", "composite", "pipeline-health"), summary="查询管道健康预测", description="调用 /composite/pipeline-health-prediction。", + options=( + CommandOptionDoc("pipe", "管道 ID", required=True), + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), + ), + examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), ("data", "scada", "schema"): CommandDoc( path=("data", "scada", "schema"), summary="读取 SCADA schema", description="kind 支持 device、device-data、element、info。", + options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), + examples=( + "tjwater-cli data scada schema --kind device", + "tjwater-cli data scada schema --kind device-data", + "tjwater-cli data scada schema --kind element", + "tjwater-cli data scada schema --kind info", + ), ), ("data", "scada", "get"): CommandDoc( path=("data", "scada", "get"), summary="读取单条 SCADA 元数据", description="kind 支持 device、device-data、element、info。", + options=( + CommandOptionDoc("kind", "SCADA 数据类型", required=True), + CommandOptionDoc("id", "记录 ID", required=True), + ), + examples=( + "tjwater-cli data scada get --kind device --id D1", + "tjwater-cli data scada get --kind element --id E1", + ), ), ("data", "scada", "list"): CommandDoc( path=("data", "scada", "list"), summary="列出 SCADA 元数据", description="kind 支持 device、element、info;device-data 当前后端无 list 接口。", + options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), + examples=( + "tjwater-cli data scada list --kind device", + "tjwater-cli data scada list --kind element", + "tjwater-cli data scada list --kind info", + ), ), ("data", "scheme", "schema"): CommandDoc( path=("data", "scheme", "schema"), summary="读取方案 schema", description="调用 /getschemeschema/。", + examples=("tjwater-cli data scheme schema",), ), ("data", "scheme", "get"): CommandDoc( path=("data", "scheme", "get"), summary="读取单条方案", description="调用 /getscheme/。", + options=(CommandOptionDoc("name", "方案名称", required=True),), + examples=("tjwater-cli data scheme get --name my_scheme",), ), ("data", "scheme", "list"): CommandDoc( path=("data", "scheme", "list"), summary="列出方案", description="调用 /getallschemes/。", + examples=("tjwater-cli data scheme list",), ), ("data", "extension", "keys"): CommandDoc( path=("data", "extension", "keys"), summary="列出扩展数据键", description="调用 /getallextensiondatakeys/。", + examples=("tjwater-cli data extension keys",), ), ("data", "extension", "get"): CommandDoc( path=("data", "extension", "get"), summary="读取扩展数据", description="调用 /getextensiondata/。", + options=(CommandOptionDoc("key", "扩展键", required=True),), + examples=("tjwater-cli data extension get --key my_key",), ), ("data", "extension", "list"): CommandDoc( path=("data", "extension", "list"), summary="列出扩展数据", description="调用 /getallextensiondata/。", + examples=("tjwater-cli data extension list",), ), ("data", "misc", "sensor-placements"): CommandDoc( path=("data", "misc", "sensor-placements"), summary="列出传感器布置结果", description="调用 /getallsensorplacements/。", + examples=("tjwater-cli data misc sensor-placements",), ), ("data", "misc", "burst-location-results"): CommandDoc( path=("data", "misc", "burst-location-results"), summary="列出爆管定位结果", description="调用 /getallburstlocateresults/。", + examples=("tjwater-cli data misc burst-location-results",), ), } diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 9b87f47..c441c74 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -7,7 +7,6 @@ 首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 ```text -tjwater-cli project tjwater-cli network tjwater-cli component tjwater-cli simulation @@ -39,7 +38,6 @@ tjwater-cli help | 顶层命令 | 二级范围 | 说明 | |---|---|---| -| `project` | `list`、`info`、`db-health`、`export-inp`、`data` | 项目发现和只读项目数据 | | `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 | | `component` | `option` | EPANET 选项设置,只读 | | `simulation` | `run` | 模拟运行 | @@ -92,85 +90,6 @@ tjwater-cli help ## 首批 CLI 范围 -### Project - -来源: - -```text -app/api/v1/endpoints/auth.py -app/api/v1/endpoints/meta.py -app/api/v1/endpoints/project.py -app/api/v1/endpoints/project_data.py -TJWaterFrontend_Refine/src/lib/requestHeaders.ts -TJWaterFrontend_Refine/src/lib/api.ts -TJWaterFrontend_Refine/src/lib/apiFetch.ts -``` - -认证模式: - -- **Non-interactive / Agent** - - 面向 agent、脚本、多用户多 agent 并发调用。 - - 必须显式传入认证上下文。 - - 不得隐式回退到本机默认状态。 - -Agent 调用认证上下文: - -- 当前前端调用链会自动附加以下请求头: - - `Authorization: Bearer ` - - `X-Project-Id: ` - - `X-User-Id: ` -- 其中 `Authorization` 来自访问令牌,`X-Project-Id` 来自当前项目上下文,`X-User-Id` 来自当前登录用户。 -- 因此前端触发的 agent 调用,应默认支持直接消费这三个字段;不再设计额外的本地 `login` 流程。 -- CLI 侧建议提供两类显式注入方式: - - `--auth-context PATH` - - 环境变量 / 调用方 header 映射 - -认证解析优先级建议固定为: - -1. 命令行显式参数(如 `--auth-context`) -2. 调用方显式注入的环境变量 / header 映射 - -约束: - -- Agent 模式下,若未显式提供认证上下文,应返回明确错误,而不是尝试复用默认登录态。 -- `X-Project-Id` 是当前 project scope 的默认来源;CLI 命令默认直接使用该上下文,不要求重复传参。 -- `X-User-Id` 主要用于审计、结果归属和多用户隔离,不应用来替代 access token 做认证。 - -| 命令 | 覆盖接口 | 说明 | -|---|---|---| -| `tjwater-cli project list` | `GET /meta/projects` | 项目列表 | -| `tjwater-cli project info` | `GET /meta/project` | 当前 project 信息 | -| `tjwater-cli project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | -| `tjwater-cli project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | -| `tjwater-cli project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | - -暂不暴露: - -```text -POST /auth/register -POST /auth/login -POST /auth/login/simple -GET /auth/me -POST /auth/refresh -GET /listprojects/ -GET /project_info/ -GET /haveproject/ -GET /isprojectopen/ -GET /isprojectlocked/ -GET /isprojectlockedbyme/ -POST /createproject/ -POST /deleteproject/ -POST /openproject/ -POST /closeproject/ -POST /copyproject/ -POST /importinp/ -POST /readinp/ -POST /lockproject/ -POST /unlockproject/ -POST /uploadinp/ -GET /convertv3tov2/ -``` - ### Network 来源: @@ -183,6 +102,8 @@ app/api/v1/endpoints/network/*.py |---|---|---| | `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | | `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | +| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 | +| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 | 暂不暴露: @@ -275,10 +196,10 @@ app/api/v1/endpoints/risk.py |---|---|---| | `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | | `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | -| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | -| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | +| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 | +| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | | `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | -| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant_simulation/` | 污染物模拟 | | `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | | `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | | `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | @@ -440,7 +361,7 @@ POST /users/{user_id}/deactivate 输出补充约束: - 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。 -- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`,例如 `project export-inp --output PATH`。 +- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`。 - 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。 ## 输出规范 diff --git a/scripts/online_Analysis.py b/scripts/online_Analysis.py index bcf4c2e..45528ee 100644 --- a/scripts/online_Analysis.py +++ b/scripts/online_Analysis.py @@ -623,7 +623,7 @@ def age_analysis( new_name, "realtime", modify_pattern_start_time, - modify_total_duration, + duration=modify_total_duration, downloading_prohibition=True, ) # step 2. restore the base model status diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index 38f777f..d03f96b 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -269,3 +269,92 @@ def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypat ) assert response.status_code == 422 + + +def test_valve_close_endpoint_passes_scheme_name(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_valve_close_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "valve_close_analysis", fake_valve_close_analysis) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/valve_close_analysis/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1", "V2"], + "duration": 900, + "scheme_name": "valve_case_01", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured == { + "name": "demo", + "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", + "modify_total_duration": 900, + "modify_valve_opening": {"V1": 0.0, "V2": 0.0}, + "scheme_name": "valve_case_01", + } + + +def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_flushing_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/flushing_analysis/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": ["V1"], + "valves_k": [0.5], + "drainage_node_ID": "N1", + "flush_flow": 100.0, + "duration": 900, + "scheme_name": "flush_case_01", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured == { + "name": "demo", + "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", + "modify_total_duration": 900, + "modify_valve_opening": {"V1": 0.5}, + "drainage_node_ID": "N1", + "flushing_flow": 100.0, + "scheme_name": "flush_case_01", + } + + +def test_contaminant_endpoint_requires_scheme_name(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.get( + "/api/v1/contaminant_simulation/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "source": "N1", + "concentration": 10.0, + "duration": 900, + }, + ) + + assert response.status_code == 422 diff --git a/tests/unit/test_age_analysis.py b/tests/unit/test_age_analysis.py new file mode 100644 index 0000000..240b112 --- /dev/null +++ b/tests/unit/test_age_analysis.py @@ -0,0 +1,88 @@ +import json + +from tests.conftest import install_stub, load_module_from_path + + +def _load_scenarios_module(monkeypatch): + install_stub(monkeypatch, "app.services", package=True) + install_stub(monkeypatch, "app.algorithms", package=True) + install_stub(monkeypatch, "app.algorithms.simulation", package=True) + install_stub(monkeypatch, "app.services.simulation", {}) + install_stub( + monkeypatch, + "app.algorithms.simulation.runner", + { + "run_simulation_ex": lambda *args, **kwargs: json.dumps( + {"output": {"node_results": [], "link_results": []}} + ), + "from_clock_to_seconds_2": lambda value: value, + }, + ) + install_stub(monkeypatch, "app.services.scheme_management", {"store_scheme_info": lambda *args, **kwargs: None}) + install_stub( + monkeypatch, + "app.services.tjnetwork", + { + "ChangeSet": type("ChangeSet", (), {}), + "OPTION_DEMAND_MODEL_PDA": "OPTION_DEMAND_MODEL_PDA", + "OPTION_QUALITY_CHEMICAL": "OPTION_QUALITY_CHEMICAL", + "SOURCE_TYPE_SETPOINT": "SOURCE_TYPE_SETPOINT", + "add_pattern": lambda *args, **kwargs: None, + "add_source": lambda *args, **kwargs: None, + "close_project": lambda *args, **kwargs: None, + "copy_project": lambda *args, **kwargs: None, + "delete_project": lambda *args, **kwargs: None, + "get_demand": lambda *args, **kwargs: None, + "get_emitter": lambda *args, **kwargs: None, + "get_node_links": lambda *args, **kwargs: None, + "get_option": lambda *args, **kwargs: None, + "get_pattern": lambda *args, **kwargs: None, + "get_pipe": lambda *args, **kwargs: None, + "get_source": lambda *args, **kwargs: None, + "get_time": lambda *args, **kwargs: None, + "have_project": lambda *args, **kwargs: False, + "is_junction": lambda *args, **kwargs: False, + "is_project_open": lambda *args, **kwargs: False, + "open_project": lambda *args, **kwargs: None, + "set_demand": lambda *args, **kwargs: None, + "set_emitter": lambda *args, **kwargs: None, + "set_option": lambda *args, **kwargs: None, + "set_source": lambda *args, **kwargs: None, + "set_time": lambda *args, **kwargs: None, + }, + ) + return load_module_from_path( + "tests_age_analysis_scenarios_module", + "app/algorithms/simulation/scenarios.py", + ) + + +def test_age_analysis_passes_duration_by_keyword(monkeypatch): + module = _load_scenarios_module(monkeypatch) + captured = {} + + monkeypatch.setattr(module, "copy_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "open_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "close_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "delete_project", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "have_project", lambda *args, **kwargs: False) + monkeypatch.setattr(module, "is_project_open", lambda *args, **kwargs: False) + + def fake_run_simulation_ex(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return json.dumps({"output": {"node_results": [], "link_results": []}}) + + monkeypatch.setattr(module, "run_simulation_ex", fake_run_simulation_ex) + + module.age_analysis("demo", "2026-06-03T07:00:00+08:00", 300) + + assert captured["args"] == ( + "age_Anal_demo", + "realtime", + "2026-06-03T07:00:00+08:00", + ) + assert captured["kwargs"] == { + "duration": 300, + "downloading_prohibition": True, + } From 9a7aad2d36a098825fd99ec90887765de9689181 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 5 Jun 2026 13:43:32 +0800 Subject: [PATCH 29/93] fix(cli): constrain timeseries option values --- cli/tests/unit/test_tjwater_cli.py | 100 +++++++++++++++++++++++++ cli/tjwater_cli/commands_analysis.py | 18 ++--- cli/tjwater_cli/commands_data.py | 108 ++++++++++++++++++--------- cli/tjwater_cli/commands_readonly.py | 13 ++-- cli/tjwater_cli/option_types.py | 81 ++++++++++++++++++++ cli/tjwater_cli/registry.py | 24 +++--- 6 files changed, 280 insertions(+), 64 deletions(-) create mode 100644 cli/tjwater_cli/option_types.py diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index f372d95..c2ed9a8 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -245,6 +245,33 @@ def test_leaf_help_flag_includes_usage_and_example(): assert "DURATION" in result.stdout +def test_realtime_simulation_help_clarifies_type_values(): + result = runner.invoke( + app, + ["data", "timeseries", "realtime", "simulation-by-id-time", "--help"], + prog_name="tjwater-cli", + ) + + assert result.exit_code == 0 + assert "links/nodes 是子命令" in result.stdout + assert "pipe" in result.stdout + assert "junction" in result.stdout + + +def test_realtime_property_help_lists_supported_fields(): + result = runner.invoke( + app, + ["data", "timeseries", "realtime", "simulation-by-time-property", "--help"], + prog_name="tjwater-cli", + ) + + assert result.exit_code == 0 + assert "flow" in result.stdout + assert "pressure" in result.stdout + assert "actual_demand" in result.stdout + assert "velocity" in result.stdout + + def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") @@ -498,6 +525,79 @@ def test_main_missing_option_error_includes_usage_and_next_step(capsys): assert '"tjwater-cli help simulation run"' in stdout +def test_main_invalid_enum_value_is_rejected_before_request(capsys): + exit_code = main( + [ + "data", + "timeseries", + "realtime", + "simulation-by-id-time", + "--id", + "J1", + "--type", + "links", + "--time", + "2025-01-02T03:30:00+08:00", + ] + ) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"summary": "参数无效"' in stdout + assert '"code": "INVALID_PARAMETER"' in stdout + assert "links" in stdout + assert "pipe" in stdout + assert "junction" in stdout + + +def test_main_invalid_pipe_property_is_rejected_before_request(capsys): + exit_code = main( + [ + "data", + "timeseries", + "realtime", + "simulation-by-time-property", + "--type", + "pipe", + "--time", + "2025-01-02T03:30:00+08:00", + "--property", + "pressure", + ] + ) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "INVALID_PROPERTY"' in stdout + assert "flow" in stdout + assert "velocity" in stdout + + +def test_main_invalid_scada_field_is_rejected_before_request(capsys): + exit_code = main( + [ + "data", + "timeseries", + "scada", + "query", + "--device-id", + "D1", + "--start-time", + "2025-01-02T03:00:00+08:00", + "--end-time", + "2025-01-02T04:00:00+08:00", + "--field", + "flow", + ] + ) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "INVALID_FIELD"' in stdout + assert "monitored_value" in stdout + assert "cleaned_value" in stdout + + def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): exit_code = main(["analysis"]) stdout = capsys.readouterr().out diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index d490a43..1c2592e 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -31,6 +31,7 @@ from .core import ( require_username, resolve_scheme, ) +from .option_types import DataSource, ValveMode @simulation_app.command("run") @@ -100,7 +101,7 @@ def analysis_burst( @analysis_app.command("valve") def analysis_valve( ctx: typer.Context, - mode: Annotated[str, typer.Option("--mode", help="close|isolation")], + mode: Annotated[ValveMode, typer.Option("--mode", help="分析模式,仅支持 close|isolation")], start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None, valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None, element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, @@ -110,7 +111,7 @@ def analysis_valve( ) -> None: runtime = runtime_context(ctx) network = require_network(runtime) - if mode == "close": + if mode == ValveMode.CLOSE: if not start_time or not valve: raise CLIError( "CLI 参数错误", @@ -135,7 +136,7 @@ def analysis_valve( require_network_ctx=True, ) return - if mode == "isolation": + if mode == ValveMode.ISOLATION: if not element: raise CLIError( "CLI 参数错误", @@ -156,12 +157,7 @@ def analysis_valve( require_network_ctx=True, ) return - raise CLIError( - "CLI 参数错误", - code="INVALID_MODE", - message="--mode must be close or isolation", - exit_code=2, - ) + raise AssertionError(f"unreachable valve mode: {mode}") @analysis_app.command("flushing") @@ -397,7 +393,7 @@ def analysis_burst_location_locate( end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")], scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring", + data_source: Annotated[DataSource, typer.Option("--data-source", help="数据来源,仅支持 monitoring|simulation")] = DataSource.MONITORING, pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None, flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None, pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None, @@ -410,7 +406,7 @@ def analysis_burst_location_locate( body = { "network": require_network(runtime), "scheme_name": resolve_scheme(runtime, scheme, required=True), - "data_source": data_source, + "data_source": data_source.value, "scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), "burst_leakage": burst_leakage, diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py index 4491630..2c780a6 100644 --- a/cli/tjwater_cli/commands_data.py +++ b/cli/tjwater_cli/commands_data.py @@ -16,12 +16,56 @@ from .apps import ( ) from .common import emit_api, runtime_context from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme +from .option_types import ( + CompositeKind, + ElementType, + JUNCTION_TIMESERIES_FIELDS, + SCADA_TIMESERIES_FIELDS, + ScadaListKind, + ScadaSchemaKind, + SimulationQuery, + timeseries_fields_for_element_type, +) def _scheme_type_option(scheme_type: str | None) -> str: return scheme_type or "simulation" +def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str: + valid_fields = timeseries_fields_for_element_type(element_type) + if property_name not in valid_fields: + raise CLIError( + "CLI 参数错误", + code="INVALID_PROPERTY", + message=f"{option_name} for --type {element_type.value} must be one of: {', '.join(valid_fields)}", + exit_code=2, + ) + return property_name + + +def _validate_node_field(field_name: str, *, option_name: str) -> str: + if field_name not in JUNCTION_TIMESERIES_FIELDS: + raise CLIError( + "CLI 参数错误", + code="INVALID_FIELD", + message=f"{option_name} must be one of: {', '.join(JUNCTION_TIMESERIES_FIELDS)}", + exit_code=2, + ) + return field_name + + +def _validate_scada_field(field_name: str, *, option_name: str) -> str: + if field_name not in SCADA_TIMESERIES_FIELDS: + raise CLIError( + "CLI 参数错误", + code="INVALID_FIELD", + message=f"{option_name} must be one of: {', '.join(SCADA_TIMESERIES_FIELDS)}", + exit_code=2, + ) + return field_name + + @data_timeseries_realtime_app.command("links") def data_realtime_links( ctx: typer.Context, @@ -66,7 +110,7 @@ def data_realtime_nodes( def data_realtime_simulation_by_id_time( ctx: typer.Context, id: Annotated[str, typer.Option("--id", help="元素 ID")], - type: Annotated[str, typer.Option("--type", help="pipe|junction")], + type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")], time: Annotated[str, typer.Option("--time", help="查询时间")], ) -> None: emit_api( @@ -76,7 +120,7 @@ def data_realtime_simulation_by_id_time( path="/realtime/query/by-id-time", params={ "id": id, - "type": type, + "type": type.value, "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), }, require_auth=True, @@ -87,17 +131,18 @@ def data_realtime_simulation_by_id_time( @data_timeseries_realtime_app.command("simulation-by-time-property") def data_realtime_simulation_by_time_property( ctx: typer.Context, - type: Annotated[str, typer.Option("--type", help="pipe|junction")], + type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")], time: Annotated[str, typer.Option("--time", help="查询时间")], - property: Annotated[str, typer.Option("--property", help="属性名")], + property: Annotated[str, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")], ) -> None: + property = _validate_element_property(type, property, option_name="--property") emit_api( ctx, summary="读取实时属性聚合数据成功", method="GET", path="/realtime/query/by-time-property", params={ - "type": type, + "type": type.value, "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), "property": property, }, @@ -135,13 +180,14 @@ def data_scheme_links( def data_scheme_node_field( ctx: typer.Context, node: Annotated[str, typer.Option("--node", help="节点 ID")], - field: Annotated[str, typer.Option("--field", help="字段名")], + field: Annotated[str, typer.Option("--field", help="字段名,仅支持 actual_demand|total_head|pressure|quality")], start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, ) -> None: runtime = runtime_context(ctx) + field = _validate_node_field(field, option_name="--field") emit_api( ctx, summary="读取方案节点字段成功", @@ -162,22 +208,22 @@ def data_scheme_node_field( @data_timeseries_scheme_app.command("simulation") def data_scheme_simulation( ctx: typer.Context, - query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")], + query: Annotated[SimulationQuery, typer.Option("--query", help="查询模式,仅支持 by-id-time|by-scheme-time-property")], scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None, time: Annotated[str, typer.Option("--time", help="查询时间")] = "", - type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe", - property: Annotated[str | None, typer.Option("--property", help="属性名")] = None, + type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE, + property: Annotated[str | None, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")] = None, ) -> None: runtime = runtime_context(ctx) params = { "scheme_name": resolve_scheme(runtime, scheme, required=True), "scheme_type": _scheme_type_option(scheme_type), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), - "type": type, + "type": type.value, } - if query == "by-id-time": + if query == SimulationQuery.BY_ID_TIME: if not id: raise CLIError( "CLI 参数错误", @@ -196,7 +242,7 @@ def data_scheme_simulation( require_project=True, ) return - if query == "by-scheme-time-property": + if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY: if not property: raise CLIError( "CLI 参数错误", @@ -204,6 +250,7 @@ def data_scheme_simulation( message="--property is required for --query by-scheme-time-property", exit_code=2, ) + property = _validate_element_property(type, property, option_name="--property") params["property"] = property emit_api( ctx, @@ -215,12 +262,7 @@ def data_scheme_simulation( require_project=True, ) return - raise CLIError( - "CLI 参数错误", - code="INVALID_QUERY", - message="--query must be by-id-time or by-scheme-time-property", - exit_code=2, - ) + raise AssertionError(f"unreachable query variant: {query}") @data_timeseries_scada_app.command("query") @@ -229,7 +271,7 @@ def data_scada_query( device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")], start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], - field: Annotated[str | None, typer.Option("--field", help="字段名")] = None, + field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None, ) -> None: path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range" params = { @@ -238,6 +280,7 @@ def data_scada_query( "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), } if field: + field = _validate_scada_field(field, option_name="--field") params["field"] = field emit_api( ctx, @@ -253,7 +296,7 @@ def data_scada_query( @data_timeseries_composite_app.callback(invoke_without_command=True) def data_timeseries_composite( ctx: typer.Context, - kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None, + kind: Annotated[CompositeKind | None, typer.Option("--kind", help="复合查询类型,仅支持 scada-simulation|element-simulation|element-scada")] = None, feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None, start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None, end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None, @@ -277,7 +320,7 @@ def data_timeseries_composite( "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), } - if kind == "scada-simulation": + if kind == CompositeKind.SCADA_SIMULATION: if not feature: raise CLIError( "CLI 参数错误", @@ -300,7 +343,7 @@ def data_timeseries_composite( require_project=True, ) return - if kind == "element-simulation": + if kind == CompositeKind.ELEMENT_SIMULATION: if not feature: raise CLIError( "CLI 参数错误", @@ -323,7 +366,7 @@ def data_timeseries_composite( require_project=True, ) return - if kind == "element-scada": + if kind == CompositeKind.ELEMENT_SCADA: if not feature or len(feature) != 1: raise CLIError( "CLI 参数错误", @@ -343,12 +386,7 @@ def data_timeseries_composite( require_project=True, ) return - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="--kind must be scada-simulation, element-simulation, or element-scada", - exit_code=2, - ) + raise AssertionError(f"unreachable composite kind: {kind}") @data_timeseries_composite_app.command("pipeline-health") @@ -402,10 +440,10 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: @data_scada_app.command("schema") def data_scada_schema( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], + kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], ) -> None: runtime = runtime_context(ctx) - path, _ = _scada_mapping(kind, "schema") + path, _ = _scada_mapping(kind.value, "schema") emit_api( ctx, summary="读取 SCADA schema 成功", @@ -420,11 +458,11 @@ def data_scada_schema( @data_scada_app.command("get") def data_scada_get( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], + kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], id: Annotated[str, typer.Option("--id", help="记录 ID")], ) -> None: runtime = runtime_context(ctx) - path, meta = _scada_mapping(kind, "get") + path, meta = _scada_mapping(kind.value, "get") params = {"network": require_network(runtime), meta["id_param"]: id} emit_api( ctx, @@ -440,10 +478,10 @@ def data_scada_get( @data_scada_app.command("list") def data_scada_list( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="device|element|info")], + kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|element|info;device-data 无 list 接口")], ) -> None: runtime = runtime_context(ctx) - path, _ = _scada_mapping(kind, "list") + path, _ = _scada_mapping(kind.value, "list") emit_api( ctx, summary="读取 SCADA 列表成功", diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py index cddc0a8..7b4677c 100644 --- a/cli/tjwater_cli/commands_readonly.py +++ b/cli/tjwater_cli/commands_readonly.py @@ -7,6 +7,7 @@ import typer from .apps import component_option_app, network_app from .common import emit_api, runtime_context from .core import CLIError, require_network +from .option_types import ComponentOptionKind @network_app.command("get-node-properties") @@ -74,13 +75,13 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None: @component_option_app.command("schema") def component_option_schema( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")], pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, ) -> None: runtime = runtime_context(ctx) - path = _component_option_path(kind, schema=True) + path = _component_option_path(kind.value, schema=True) params = {"network": require_network(runtime)} - if kind == "pump-energy" and pump: + if kind == ComponentOptionKind.PUMP_ENERGY and pump: params["pump"] = pump emit_api( ctx, @@ -96,13 +97,13 @@ def component_option_schema( @component_option_app.command("get") def component_option_get( ctx: typer.Context, - kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")], pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, ) -> None: runtime = runtime_context(ctx) - path = _component_option_path(kind, schema=False) + path = _component_option_path(kind.value, schema=False) params = {"network": require_network(runtime)} - if kind == "pump-energy": + if kind == ComponentOptionKind.PUMP_ENERGY: if not pump: raise CLIError( "CLI 参数错误", diff --git a/cli/tjwater_cli/option_types.py b/cli/tjwater_cli/option_types.py new file mode 100644 index 0000000..a642e32 --- /dev/null +++ b/cli/tjwater_cli/option_types.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from enum import Enum + + +class ElementType(str, Enum): + PIPE = "pipe" + JUNCTION = "junction" + + +class SimulationQuery(str, Enum): + BY_ID_TIME = "by-id-time" + BY_SCHEME_TIME_PROPERTY = "by-scheme-time-property" + + +class CompositeKind(str, Enum): + SCADA_SIMULATION = "scada-simulation" + ELEMENT_SIMULATION = "element-simulation" + ELEMENT_SCADA = "element-scada" + + +class ComponentOptionKind(str, Enum): + TIME = "time" + ENERGY = "energy" + PUMP_ENERGY = "pump-energy" + NETWORK = "network" + + +class ValveMode(str, Enum): + CLOSE = "close" + ISOLATION = "isolation" + + +class DataSource(str, Enum): + MONITORING = "monitoring" + SIMULATION = "simulation" + + +class ScadaSchemaKind(str, Enum): + DEVICE = "device" + DEVICE_DATA = "device-data" + ELEMENT = "element" + INFO = "info" + + +class ScadaListKind(str, Enum): + DEVICE = "device" + ELEMENT = "element" + INFO = "info" + + +PIPE_TIMESERIES_FIELDS: tuple[str, ...] = ( + "flow", + "friction", + "headloss", + "quality", + "reaction", + "setting", + "status", + "velocity", +) + +JUNCTION_TIMESERIES_FIELDS: tuple[str, ...] = ( + "actual_demand", + "total_head", + "pressure", + "quality", +) + +SCADA_TIMESERIES_FIELDS: tuple[str, ...] = ( + "monitored_value", + "cleaned_value", +) + + +def timeseries_fields_for_element_type(element_type: ElementType) -> tuple[str, ...]: + if element_type == ElementType.PIPE: + return PIPE_TIMESERIES_FIELDS + if element_type == ElementType.JUNCTION: + return JUNCTION_TIMESERIES_FIELDS + raise AssertionError(f"unreachable element type: {element_type}") diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 3eb277a..69d35a4 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -314,7 +314,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { description="调用 /realtime/query/by-id-time。", options=( CommandOptionDoc("id", "元素 ID", required=True), - CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True), ), examples=( @@ -325,11 +325,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-time-property"), summary="按时间和属性查询实时模拟结果", - description="调用 /realtime/query/by-time-property。", + description="调用 /realtime/query/by-time-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", options=( - CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True), + CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True), - CommandOptionDoc("property", "属性名", required=True), + CommandOptionDoc("property", "属性名;会按 type 校验可选值", required=True), ), examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",), ), @@ -348,10 +348,10 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scheme", "node-field"): CommandDoc( path=("data", "timeseries", "scheme", "node-field"), summary="查询方案节点字段时序", - description="调用 /scheme/nodes/{node_id}/field。", + description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。", options=( CommandOptionDoc("node", "节点 ID", required=True), - CommandOptionDoc("field", "字段名", required=True), + CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), CommandOptionDoc("scheme", "方案名称"), @@ -362,15 +362,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scheme", "simulation"): CommandDoc( path=("data", "timeseries", "scheme", "simulation"), summary="查询方案模拟数据", - description="支持 by-id-time 与 by-scheme-time-property 两种查询。", + description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", options=( CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True), CommandOptionDoc("scheme", "方案名称"), CommandOptionDoc("scheme-type", "方案类型"), CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"), CommandOptionDoc("time", "显式带时区的查询时间", required=True), - CommandOptionDoc("type", "元素类型:pipe 或 junction"), - CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"), + CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"), + CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"), ), examples=( "tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", @@ -380,16 +380,16 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scada", "query"): CommandDoc( path=("data", "timeseries", "scada", "query"), summary="查询 SCADA 时序", - description="device-id 会被转换成后端逗号分隔参数。", + description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。", options=( CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - CommandOptionDoc("field", "字段名"), + CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"), ), examples=( "tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", - "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field flow", + "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value", ), ), ("data", "timeseries", "composite"): CommandDoc( From 7efaeb41e82ba0c195814bc77ae770c08036c2ca Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 5 Jun 2026 13:43:53 +0800 Subject: [PATCH 30/93] =?UTF-8?q?=E6=96=B0=E5=A2=9Epyclipper=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 0844ca8..04460ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -168,3 +168,4 @@ zmq==0.0.0 pymoo==0.6.1.6 scikit-learn==1.6.1 scipy==1.15.2 +pyclipper==1.4.0 \ No newline at end of file From 52b8f07abd0b0dbb60457d3c3e959213250993e3 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 5 Jun 2026 15:48:53 +0800 Subject: [PATCH 31/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20cli=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=EF=BC=8C=E6=96=B0=E5=A2=9E=20network=20=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E5=85=83=E7=B4=A0=E7=9A=84=E5=B1=9E=E6=80=A7=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 277 +++++++++++++++++++++++++-- cli/tjwater_cli/commands_readonly.py | 164 +++++++++++++--- cli/tjwater_cli/registry.py | 86 +++++++-- 3 files changed, 468 insertions(+), 59 deletions(-) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index c2ed9a8..410974f 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -71,14 +71,14 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): def fake_request_json(ctx, **kwargs): observed_runtime_ids.append(id(ctx)) assert ctx.auth.access_token == "token-1" - assert kwargs["params"] == {"network": "tjwater", "node": "11"} - return {"node": "11"}, 5 + assert kwargs["params"] == {"network": "tjwater", "junction": "11"} + return {"id": "11"}, 5 monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke( app, - ["--auth-stdin", "network", "get-node-properties", "--node", "11"], + ["--auth-stdin", "network", "get-junction-properties", "--junction", "11"], input=json.dumps( { "server": "http://server", @@ -93,37 +93,70 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): assert result.exit_code == 0 assert payload["ok"] is True - assert payload["data"] == {"node": "11"} + assert payload["data"] == {"id": "11"} assert len(observed_runtime_ids) == 1 -def test_network_get_all_junction_properties_uses_network_context(monkeypatch): +def test_network_get_junction_properties_uses_network_context(monkeypatch): captured = {} def fake_request_json(ctx, **kwargs): captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] captured["params"] = kwargs["params"] - return [{"id": "J1"}], 5 + return {"id": "J1"}, 5 monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) - result = runner.invoke(app, ["network", "get-all-junction-properties"]) + result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"]) payload = json.loads(result.stdout) assert result.exit_code == 0 assert payload["ok"] is True - assert payload["data"] == [{"id": "J1"}] - assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} + assert payload["data"] == {"id": "J1"} + assert captured == { + "access_token": "abc", + "path": "/getjunctionproperties/", + "params": {"network": "tjwater", "junction": "J1"}, + } -def test_network_get_all_pipe_properties_uses_network_context(monkeypatch): +def test_network_get_pipe_properties_uses_network_context(monkeypatch): captured = {} def fake_request_json(ctx, **kwargs): captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return {"id": "P1"}, 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == {"id": "P1"} + assert captured == { + "access_token": "abc", + "path": "/getpipeproperties/", + "params": {"network": "tjwater", "pipe": "P1"}, + } + + +def test_network_get_all_pipes_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] captured["params"] = kwargs["params"] return [{"id": "P1"}], 5 @@ -132,13 +165,233 @@ def test_network_get_all_pipe_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) - result = runner.invoke(app, ["network", "get-all-pipe-properties"]) + result = runner.invoke(app, ["network", "get-all-pipes-properties"]) payload = json.loads(result.stdout) assert result.exit_code == 0 assert payload["ok"] is True assert payload["data"] == [{"id": "P1"}] - assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} + assert captured == { + "access_token": "abc", + "path": "/getallpipeproperties/", + "params": {"network": "tjwater"}, + } + + +def test_network_get_reservoir_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return {"id": "R1"}, 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == {"id": "R1"} + assert captured == { + "access_token": "abc", + "path": "/getreservoirproperties/", + "params": {"network": "tjwater", "reservoir": "R1"}, + } + + +def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return [{"id": "R1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-reservoirs-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "R1"}] + assert captured == { + "access_token": "abc", + "path": "/getallreservoirproperties/", + "params": {"network": "tjwater"}, + } + + +def test_network_get_tank_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return {"id": "T1"}, 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == {"id": "T1"} + assert captured == { + "access_token": "abc", + "path": "/gettankproperties/", + "params": {"network": "tjwater", "tank": "T1"}, + } + + +def test_network_get_all_tank_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return [{"id": "T1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-tanks-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "T1"}] + assert captured == { + "access_token": "abc", + "path": "/getalltankproperties/", + "params": {"network": "tjwater"}, + } + + +def test_network_get_pump_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return {"id": "PU1"}, 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == {"id": "PU1"} + assert captured == { + "access_token": "abc", + "path": "/getpumpproperties/", + "params": {"network": "tjwater", "pump": "PU1"}, + } + + +def test_network_get_all_pump_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return [{"id": "PU1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-pumps-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "PU1"}] + assert captured == { + "access_token": "abc", + "path": "/getallpumpproperties/", + "params": {"network": "tjwater"}, + } + + +def test_network_get_valve_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return {"id": "V1"}, 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == {"id": "V1"} + assert captured == { + "access_token": "abc", + "path": "/getvalveproperties/", + "params": {"network": "tjwater", "valve": "V1"}, + } + + +def test_network_get_all_valve_properties_uses_network_context(monkeypatch): + captured = {} + + def fake_request_json(ctx, **kwargs): + captured["access_token"] = ctx.auth.access_token + captured["path"] = kwargs["path"] + captured["params"] = kwargs["params"] + return [{"id": "V1"}], 5 + + monkeypatch.setenv("TJWATER_SERVER", "http://server") + monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") + monkeypatch.setenv("TJWATER_NETWORK", "tjwater") + monkeypatch.setattr(common, "request_json", fake_request_json) + + result = runner.invoke(app, ["network", "get-all-valves-properties"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["ok"] is True + assert payload["data"] == [{"id": "V1"}] + assert captured == { + "access_token": "abc", + "path": "/getallvalveproperties/", + "params": {"network": "tjwater"}, + } def test_help_outputs_json_lists_commands(): diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py index 7b4677c..c57a7d5 100644 --- a/cli/tjwater_cli/commands_readonly.py +++ b/cli/tjwater_cli/commands_readonly.py @@ -10,56 +10,42 @@ from .core import CLIError, require_network from .option_types import ComponentOptionKind -@network_app.command("get-node-properties") -def network_get_node_properties( +@network_app.command("get-junction-properties") +def network_get_junction_properties( ctx: typer.Context, - node: Annotated[str, typer.Option("--node", help="节点 ID")], + junction: Annotated[str, typer.Option("--junction", help="节点 ID")], ) -> None: runtime = runtime_context(ctx) emit_api( ctx, summary="读取节点属性成功", method="GET", - path="/getnodeproperties/", - params={"network": require_network(runtime), "node": node}, + path="/getjunctionproperties/", + params={"network": require_network(runtime), "junction": junction}, require_auth=True, require_network_ctx=True, ) -@network_app.command("get-link-properties") -def network_get_link_properties( +@network_app.command("get-pipe-properties") +def network_get_pipe_properties( ctx: typer.Context, - link: Annotated[str, typer.Option("--link", help="管线 ID")], + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], ) -> None: runtime = runtime_context(ctx) emit_api( ctx, - summary="读取管线属性成功", + summary="读取管道属性成功", method="GET", - path="/getlinkproperties/", - params={"network": require_network(runtime), "link": link}, + path="/getpipeproperties/", + params={"network": require_network(runtime), "pipe": pipe}, require_auth=True, require_network_ctx=True, ) -@network_app.command("get-all-junction-properties") -def network_get_all_junction_properties(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取全部节点属性成功", - method="GET", - path="/getalljunctionproperties/", - params={"network": require_network(runtime)}, - require_auth=True, - require_network_ctx=True, - ) - - -@network_app.command("get-all-pipe-properties") -def network_get_all_pipe_properties(ctx: typer.Context) -> None: +@network_app.command("get-all-pipes-properties") +def network_get_all_pipes_properties(ctx: typer.Context) -> None: runtime = runtime_context(ctx) emit_api( ctx, @@ -72,6 +58,130 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None: ) +@network_app.command("get-reservoir-properties") +def network_get_reservoir_properties( + ctx: typer.Context, + reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取水库属性成功", + method="GET", + path="/getreservoirproperties/", + params={"network": require_network(runtime), "reservoir": reservoir}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-reservoirs-properties") +def network_get_all_reservoir_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部水库属性成功", + method="GET", + path="/getallreservoirproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-tank-properties") +def network_get_tank_properties( + ctx: typer.Context, + tank: Annotated[str, typer.Option("--tank", help="水箱 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取水箱属性成功", + method="GET", + path="/gettankproperties/", + params={"network": require_network(runtime), "tank": tank}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-tanks-properties") +def network_get_all_tank_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部水箱属性成功", + method="GET", + path="/getalltankproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-pump-properties") +def network_get_pump_properties( + ctx: typer.Context, + pump: Annotated[str, typer.Option("--pump", help="水泵 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取水泵属性成功", + method="GET", + path="/getpumpproperties/", + params={"network": require_network(runtime), "pump": pump}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-pumps-properties") +def network_get_all_pump_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部水泵属性成功", + method="GET", + path="/getallpumpproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-valve-properties") +def network_get_valve_properties( + ctx: typer.Context, + valve: Annotated[str, typer.Option("--valve", help="阀门 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取阀门属性成功", + method="GET", + path="/getvalveproperties/", + params={"network": require_network(runtime), "valve": valve}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-all-valves-properties") +def network_get_all_valve_properties(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取全部阀门属性成功", + method="GET", + path="/getallvalveproperties/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + @component_option_app.command("schema") def component_option_schema( ctx: typer.Context, diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 69d35a4..43c9860 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -34,31 +34,77 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( ) COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { - ("network", "get-node-properties"): CommandDoc( - path=("network", "get-node-properties"), + ("network", "get-junction-properties"): CommandDoc( + path=("network", "get-junction-properties"), summary="读取节点属性", - description="调用 /getnodeproperties/。", - options=(CommandOptionDoc("node", "节点 ID", required=True),), - examples=("tjwater-cli network get-node-properties --node J1",), + description="调用 /getjunctionproperties/。", + options=(CommandOptionDoc("junction", "节点 ID", required=True),), + examples=("tjwater-cli network get-junction-properties --junction J1",), ), - ("network", "get-link-properties"): CommandDoc( - path=("network", "get-link-properties"), - summary="读取管线属性", - description="调用 /getlinkproperties/。", - options=(CommandOptionDoc("link", "管线 ID", required=True),), - examples=("tjwater-cli network get-link-properties --link P1",), + ("network", "get-pipe-properties"): CommandDoc( + path=("network", "get-pipe-properties"), + summary="读取管道属性", + description="调用 /getpipeproperties/。", + options=(CommandOptionDoc("pipe", "管道 ID", required=True),), + examples=("tjwater-cli network get-pipe-properties --pipe P1",), ), - ("network", "get-all-junction-properties"): CommandDoc( - path=("network", "get-all-junction-properties"), - summary="读取全部节点属性", - description="调用 /getalljunctionproperties/。", - examples=("tjwater-cli network get-all-junction-properties",), - ), - ("network", "get-all-pipe-properties"): CommandDoc( - path=("network", "get-all-pipe-properties"), + ("network", "get-all-pipes-properties"): CommandDoc( + path=("network", "get-all-pipes-properties"), summary="读取全部管道属性", description="调用 /getallpipeproperties/。", - examples=("tjwater-cli network get-all-pipe-properties",), + examples=("tjwater-cli network get-all-pipes-properties",), + ), + ("network", "get-reservoir-properties"): CommandDoc( + path=("network", "get-reservoir-properties"), + summary="读取水库属性", + description="调用 /getreservoirproperties/。", + options=(CommandOptionDoc("reservoir", "水库 ID", required=True),), + examples=("tjwater-cli network get-reservoir-properties --reservoir R1",), + ), + ("network", "get-all-reservoirs-properties"): CommandDoc( + path=("network", "get-all-reservoirs-properties"), + summary="读取全部水库属性", + description="调用 /getallreservoirproperties/。", + examples=("tjwater-cli network get-all-reservoirs-properties",), + ), + ("network", "get-tank-properties"): CommandDoc( + path=("network", "get-tank-properties"), + summary="读取水箱属性", + description="调用 /gettankproperties/。", + options=(CommandOptionDoc("tank", "水箱 ID", required=True),), + examples=("tjwater-cli network get-tank-properties --tank T1",), + ), + ("network", "get-all-tanks-properties"): CommandDoc( + path=("network", "get-all-tanks-properties"), + summary="读取全部水箱属性", + description="调用 /getalltankproperties/。", + examples=("tjwater-cli network get-all-tanks-properties",), + ), + ("network", "get-pump-properties"): CommandDoc( + path=("network", "get-pump-properties"), + summary="读取水泵属性", + description="调用 /getpumpproperties/。", + options=(CommandOptionDoc("pump", "水泵 ID", required=True),), + examples=("tjwater-cli network get-pump-properties --pump PU1",), + ), + ("network", "get-all-pumps-properties"): CommandDoc( + path=("network", "get-all-pumps-properties"), + summary="读取全部水泵属性", + description="调用 /getallpumpproperties/。", + examples=("tjwater-cli network get-all-pumps-properties",), + ), + ("network", "get-valve-properties"): CommandDoc( + path=("network", "get-valve-properties"), + summary="读取阀门属性", + description="调用 /getvalveproperties/。", + options=(CommandOptionDoc("valve", "阀门 ID", required=True),), + examples=("tjwater-cli network get-valve-properties --valve V1",), + ), + ("network", "get-all-valves-properties"): CommandDoc( + path=("network", "get-all-valves-properties"), + summary="读取全部阀门属性", + description="调用 /getallvalveproperties/。", + examples=("tjwater-cli network get-all-valves-properties",), ), ("component", "option", "schema"): CommandDoc( path=("component", "option", "schema"), From e336ffcd46da25314a436125f455700d8f32a622 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 5 Jun 2026 16:42:03 +0800 Subject: [PATCH 32/93] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E6=97=A0=E6=95=88=E6=95=B0=E6=8D=AE=E7=9A=84=20cli=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 60 ++++++++++++++++ cli/tjwater_cli/apps.py | 6 -- cli/tjwater_cli/commands_data.py | 107 +---------------------------- cli/tjwater_cli/helping.py | 5 +- cli/tjwater_cli/option_types.py | 9 --- cli/tjwater_cli/registry.py | 62 ++--------------- cli/tjwater_cli_endpoint_scope.md | 6 +- 7 files changed, 70 insertions(+), 185 deletions(-) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 410974f..08a119f 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -851,6 +851,66 @@ def test_main_invalid_scada_field_is_rejected_before_request(capsys): assert "cleaned_value" in stdout +def test_data_scada_get_rejects_removed_kind_before_request(capsys): + exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"]) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"code": "INVALID_PARAMETER"' in stdout + assert "device" in stdout + assert "info" in stdout + + +def test_data_scada_list_help_only_shows_info_kind(): + result = runner.invoke(app, ["data", "scada", "list", "--help"]) + + assert result.exit_code == 0 + assert "info" in result.stdout + assert "device" not in result.stdout + assert "element" not in result.stdout + + +def test_data_scada_help_no_longer_lists_schema(): + result = runner.invoke(app, ["data", "scada", "help"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + commands = {command["command"] for command in payload["commands"]} + assert "data scada get" in commands + assert "data scada list" in commands + assert "data scada schema" not in commands + + +def test_data_scada_schema_command_is_removed(): + result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"]) + + assert result.exit_code == 2 + assert "No such command 'schema'" in result.output + + +def test_data_help_no_longer_lists_extension_or_misc(): + result = runner.invoke(app, ["data", "help"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + commands = {command["command"] for command in payload["commands"]} + assert "data timeseries" in commands + assert "data scada" in commands + assert "data scheme" in commands + assert "data extension" not in commands + assert "data misc" not in commands + + +def test_removed_data_extension_and_misc_commands_fail(): + extension_result = runner.invoke(app, ["data", "extension", "list"]) + misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"]) + + assert extension_result.exit_code == 2 + assert "No such command 'extension'" in extension_result.output + assert misc_result.exit_code == 2 + assert "No such command 'misc'" in misc_result.output + + def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): exit_code = main(["analysis"]) stdout = capsys.readouterr().out diff --git a/cli/tjwater_cli/apps.py b/cli/tjwater_cli/apps.py index ce3ba92..cd7614d 100644 --- a/cli/tjwater_cli/apps.py +++ b/cli/tjwater_cli/apps.py @@ -26,8 +26,6 @@ data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) app.add_typer(network_app, name="network") app.add_typer(component_app, name="component") @@ -50,8 +48,6 @@ data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada") data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite") data_app.add_typer(data_scada_app, name="scada") data_app.add_typer(data_scheme_app, name="scheme") -data_app.add_typer(data_extension_app, name="extension") -data_app.add_typer(data_misc_app, name="misc") GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ (network_app, ("network",)), @@ -75,8 +71,6 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ (data_timeseries_composite_app, ("data", "timeseries", "composite")), (data_scada_app, ("data", "scada")), (data_scheme_app, ("data", "scheme")), - (data_extension_app, ("data", "extension")), - (data_misc_app, ("data", "misc")), ] TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"} diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py index 2c780a6..69b8968 100644 --- a/cli/tjwater_cli/commands_data.py +++ b/cli/tjwater_cli/commands_data.py @@ -5,8 +5,6 @@ from typing import Annotated import typer from .apps import ( - data_extension_app, - data_misc_app, data_scada_app, data_scheme_app, data_timeseries_composite_app, @@ -22,7 +20,6 @@ from .option_types import ( JUNCTION_TIMESERIES_FIELDS, SCADA_TIMESERIES_FIELDS, ScadaListKind, - ScadaSchemaKind, SimulationQuery, timeseries_fields_for_element_type, ) @@ -414,15 +411,6 @@ def data_composite_pipeline_health( def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: mapping = { - ("device", "schema"): ("/getscadadeviceschema/", {}), - ("device", "get"): ("/getscadadevice/", {"id_param": "id"}), - ("device", "list"): ("/getallscadadevices/", {}), - ("device-data", "schema"): ("/getscadadevicedataschema/", {}), - ("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}), - ("element", "schema"): ("/getscadaelementschema/", {}), - ("element", "get"): ("/getscadaelement/", {"id_param": "id"}), - ("element", "list"): ("/getscadaelements/", {}), - ("info", "schema"): ("/getscadainfoschema/", {}), ("info", "get"): ("/getscadainfo/", {"id_param": "id"}), ("info", "list"): ("/getallscadainfo/", {}), } @@ -437,28 +425,10 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: return result -@data_scada_app.command("schema") -def data_scada_schema( - ctx: typer.Context, - kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], -) -> None: - runtime = runtime_context(ctx) - path, _ = _scada_mapping(kind.value, "schema") - emit_api( - ctx, - summary="读取 SCADA schema 成功", - method="GET", - path=path, - params={"network": require_network(runtime)}, - require_auth=True, - require_network_ctx=True, - ) - - @data_scada_app.command("get") def data_scada_get( ctx: typer.Context, - kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], + kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")], id: Annotated[str, typer.Option("--id", help="记录 ID")], ) -> None: runtime = runtime_context(ctx) @@ -478,7 +448,7 @@ def data_scada_get( @data_scada_app.command("list") def data_scada_list( ctx: typer.Context, - kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|element|info;device-data 无 list 接口")], + kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")], ) -> None: runtime = runtime_context(ctx) path, _ = _scada_mapping(kind.value, "list") @@ -536,76 +506,3 @@ def data_scheme_list(ctx: typer.Context) -> None: require_auth=True, require_network_ctx=True, ) - - -@data_extension_app.command("keys") -def data_extension_keys(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取扩展数据键成功", - method="GET", - path="/getallextensiondatakeys/", - params={"network": require_network(runtime)}, - require_auth=True, - require_network_ctx=True, - ) - - -@data_extension_app.command("get") -def data_extension_get( - ctx: typer.Context, - key: Annotated[str, typer.Option("--key", help="扩展键")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取扩展数据成功", - method="GET", - path="/getextensiondata/", - params={"network": require_network(runtime), "key": key}, - require_auth=True, - require_network_ctx=True, - ) - - -@data_extension_app.command("list") -def data_extension_list(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取扩展数据列表成功", - method="GET", - path="/getallextensiondata/", - params={"network": require_network(runtime)}, - require_auth=True, - require_network_ctx=True, - ) - - -@data_misc_app.command("sensor-placements") -def data_misc_sensor_placements(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取传感器位置成功", - method="GET", - path="/getallsensorplacements/", - params={"network": require_network(runtime)}, - require_auth=True, - require_network_ctx=True, - ) - - -@data_misc_app.command("burst-location-results") -def data_misc_burst_location_results(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取爆管定位结果成功", - method="GET", - path="/getallburstlocateresults/", - params={"network": require_network(runtime)}, - require_auth=True, - require_network_ctx=True, - ) diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index a88a650..5a6895c 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -100,9 +100,8 @@ def _sample_option_value(path: tuple[str, ...], option_name: str) -> str: (("component", "option", "schema"), "kind"): "time", (("component", "option", "get"), "kind"): "time", (("data", "timeseries", "composite"), "kind"): "scada-simulation", - (("data", "scada", "schema"), "kind"): "device", - (("data", "scada", "get"), "kind"): "device", - (("data", "scada", "list"), "kind"): "device", + (("data", "scada", "get"), "kind"): "info", + (("data", "scada", "list"), "kind"): "info", } if (path, option_name) in path_specific_samples: return path_specific_samples[(path, option_name)] diff --git a/cli/tjwater_cli/option_types.py b/cli/tjwater_cli/option_types.py index a642e32..8f83f67 100644 --- a/cli/tjwater_cli/option_types.py +++ b/cli/tjwater_cli/option_types.py @@ -36,16 +36,7 @@ class DataSource(str, Enum): SIMULATION = "simulation" -class ScadaSchemaKind(str, Enum): - DEVICE = "device" - DEVICE_DATA = "device-data" - ELEMENT = "element" - INFO = "info" - - class ScadaListKind(str, Enum): - DEVICE = "device" - ELEMENT = "element" INFO = "info" diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 43c9860..201afd8 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -16,7 +16,7 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = { ("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。", ("analysis", "risk"): "风险分析相关命令。", ("analysis", "sensor-placement"): "传感器选址相关命令。", - ("data",): "时序、SCADA、方案和扩展数据查询命令。", + ("data",): "时序、SCADA 和方案数据查询命令。", ("data", "timeseries"): "时序数据查询命令。", ("data", "timeseries", "realtime"): "实时模拟时序查询命令。", ("data", "timeseries", "scheme"): "方案时序查询命令。", @@ -24,8 +24,6 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = { ("data", "timeseries", "composite"): "复合时序查询命令。", ("data", "scada"): "SCADA 元数据查询命令。", ("data", "scheme"): "方案数据查询命令。", - ("data", "extension"): "扩展数据查询命令。", - ("data", "misc"): "其他结果数据查询命令。", } HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( @@ -468,41 +466,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ), examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), ), - ("data", "scada", "schema"): CommandDoc( - path=("data", "scada", "schema"), - summary="读取 SCADA schema", - description="kind 支持 device、device-data、element、info。", - options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), - examples=( - "tjwater-cli data scada schema --kind device", - "tjwater-cli data scada schema --kind device-data", - "tjwater-cli data scada schema --kind element", - "tjwater-cli data scada schema --kind info", - ), - ), ("data", "scada", "get"): CommandDoc( path=("data", "scada", "get"), summary="读取单条 SCADA 元数据", - description="kind 支持 device、device-data、element、info。", + description="kind 仅支持 info。", options=( CommandOptionDoc("kind", "SCADA 数据类型", required=True), CommandOptionDoc("id", "记录 ID", required=True), ), - examples=( - "tjwater-cli data scada get --kind device --id D1", - "tjwater-cli data scada get --kind element --id E1", - ), + examples=("tjwater-cli data scada get --kind info --id SCADA-001",), ), ("data", "scada", "list"): CommandDoc( path=("data", "scada", "list"), summary="列出 SCADA 元数据", - description="kind 支持 device、element、info;device-data 当前后端无 list 接口。", + description="kind 仅支持 info。", options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), - examples=( - "tjwater-cli data scada list --kind device", - "tjwater-cli data scada list --kind element", - "tjwater-cli data scada list --kind info", - ), + examples=("tjwater-cli data scada list --kind info",), ), ("data", "scheme", "schema"): CommandDoc( path=("data", "scheme", "schema"), @@ -523,37 +502,6 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { description="调用 /getallschemes/。", examples=("tjwater-cli data scheme list",), ), - ("data", "extension", "keys"): CommandDoc( - path=("data", "extension", "keys"), - summary="列出扩展数据键", - description="调用 /getallextensiondatakeys/。", - examples=("tjwater-cli data extension keys",), - ), - ("data", "extension", "get"): CommandDoc( - path=("data", "extension", "get"), - summary="读取扩展数据", - description="调用 /getextensiondata/。", - options=(CommandOptionDoc("key", "扩展键", required=True),), - examples=("tjwater-cli data extension get --key my_key",), - ), - ("data", "extension", "list"): CommandDoc( - path=("data", "extension", "list"), - summary="列出扩展数据", - description="调用 /getallextensiondata/。", - examples=("tjwater-cli data extension list",), - ), - ("data", "misc", "sensor-placements"): CommandDoc( - path=("data", "misc", "sensor-placements"), - summary="列出传感器布置结果", - description="调用 /getallsensorplacements/。", - examples=("tjwater-cli data misc sensor-placements",), - ), - ("data", "misc", "burst-location-results"): CommandDoc( - path=("data", "misc", "burst-location-results"), - summary="列出爆管定位结果", - description="调用 /getallburstlocateresults/。", - examples=("tjwater-cli data misc burst-location-results",), - ), } diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index c441c74..1413674 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -259,12 +259,8 @@ app/api/v1/endpoints/project_data.py | `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | | `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | | `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | -| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | -| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | +| `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/`、`GET /getallscadainfo/` | `SCADA info` 元数据 | | `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | -| `tjwater-cli data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 | -| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 | -| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 | - `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。 - `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 From 441979f581114a2e4c0465e33f523b5b4062aa21 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 5 Jun 2026 19:11:53 +0800 Subject: [PATCH 33/93] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tjwater_cli/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 34eaeb4..a02c4dd 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -15,7 +15,7 @@ import typer SCHEMA_VERSION = "tjwater-cli/v1" CLI_NAME = "tjwater-cli" -DEFAULT_TIMEOUT = 60 +DEFAULT_TIMEOUT = 180 DEFAULT_SERVER = "http://192.168.1.114:8000" From 1712ecd4c781f3ae7f12d82a36c270ed3bd7ea7e Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 16:13:24 +0800 Subject: [PATCH 34/93] feat(api): add web search endpoint --- .env.example | 8 ++ app/api/v1/endpoints/web_search.py | 29 ++++++++ app/api/v1/router.py | 2 + app/core/config.py | 5 ++ app/services/__init__.py | 39 +--------- app/services/web_search.py | 93 +++++++++++++++++++++++ tests/unit/test_web_search.py | 115 +++++++++++++++++++++++++++++ 7 files changed, 256 insertions(+), 35 deletions(-) create mode 100644 app/api/v1/endpoints/web_search.py create mode 100644 app/services/web_search.py create mode 100644 tests/unit/test_web_search.py diff --git a/.env.example b/.env.example index dd9267b..54c973d 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,11 @@ METADATA_DB_PASSWORD="password" KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_AUDIENCE="account" + + +# ============================================ +# Bocha Web Search API +# ============================================ +BOCHA_API_KEY="sk-your-bocha-api-key" +BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search" +BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30 diff --git a/app/api/v1/endpoints/web_search.py b/app/api/v1/endpoints/web_search.py new file mode 100644 index 0000000..d3e2675 --- /dev/null +++ b/app/api/v1/endpoints/web_search.py @@ -0,0 +1,29 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException, status + +from app.services.web_search import ( + BochaSearchAPIError, + BochaSearchConfigError, + WebSearchRequest, + search_bocha_web, +) + +router = APIRouter() + + +@router.post( + "/web-search", + summary="Web Search", + description="调用 Bocha Web Search API 获取实时网页搜索结果", +) +async def web_search(request: WebSearchRequest) -> dict[str, Any]: + try: + return await search_bocha_web(request) + except BochaSearchConfigError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except BochaSearchAPIError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/app/api/v1/router.py b/app/api/v1/router.py index f0c286e..4e018cf 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -18,6 +18,7 @@ from app.api.v1.endpoints import ( user_management, # 新增:用户管理 audit, # 新增:审计日志 meta, + web_search, ) from app.api.v1.endpoints.network import ( general, @@ -93,6 +94,7 @@ api_router.include_router(schemes.router, tags=["Schemes"]) api_router.include_router(misc.router, tags=["Misc"]) api_router.include_router(risk.router, tags=["Risk"]) api_router.include_router(cache.router, tags=["Cache"]) +api_router.include_router(web_search.router, tags=["Web Search"]) api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"]) api_router.include_router( burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"] diff --git a/app/core/config.py b/app/core/config.py index 3975143..49ccdaf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -64,6 +64,11 @@ class Settings(BaseSettings): KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_AUDIENCE: str = "" + # Bocha Web Search API + BOCHA_API_KEY: str = "" + BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search" + BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0 + @property def SQLALCHEMY_DATABASE_URI(self) -> str: db_password = quote_plus(self.DB_PASSWORD) diff --git a/app/services/__init__.py b/app/services/__init__.py index 645a38a..1c6f317 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,36 +1,5 @@ -from app.services.network_import import network_update, submit_scada_info -from app.services.scheme_management import ( - create_user, - delete_user, - scheme_name_exists, - store_scheme_info, - delete_scheme_info, - query_scheme_list, - upload_shp_to_pg, - submit_risk_probability_result, -) -from app.services.valve_isolation import analyze_valve_isolation -from app.services.simulation_ops import ( - project_management, - scheduling_simulation, - daily_scheduling_simulation, -) -from app.services.leakage_identifier import run_leakage_identification +"""Service package. -__all__ = [ - "network_update", - "submit_scada_info", - "create_user", - "delete_user", - "scheme_name_exists", - "store_scheme_info", - "delete_scheme_info", - "query_scheme_list", - "upload_shp_to_pg", - "submit_risk_probability_result", - "project_management", - "scheduling_simulation", - "daily_scheduling_simulation", - "analyze_valve_isolation", - "run_leakage_identification", -] +Keep package initialization lightweight. Import concrete service modules directly, +for example: `from app.services.tjnetwork import open_project`. +""" diff --git a/app/services/web_search.py b/app/services/web_search.py new file mode 100644 index 0000000..dc98efa --- /dev/null +++ b/app/services/web_search.py @@ -0,0 +1,93 @@ +from typing import Any, Literal + +import httpx +from pydantic import BaseModel, Field + +from app.core.config import settings + + +Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"] + + +class WebSearchRequest(BaseModel): + query: str = Field(..., min_length=1, description="搜索关键词") + freshness: Freshness | str = Field( + default="noLimit", + description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围", + ) + summary: bool = Field(default=True, description="是否返回网页摘要") + count: int = Field(default=10, ge=1, le=50, description="返回结果数量") + include: list[str] | None = Field(default=None, description="限定搜索域名") + exclude: list[str] | None = Field(default=None, description="排除搜索域名") + + +class BochaSearchConfigError(RuntimeError): + pass + + +class BochaSearchAPIError(RuntimeError): + def __init__(self, status_code: int, detail: Any): + super().__init__("Bocha Web Search API request failed") + self.status_code = status_code + self.detail = detail + + +def _build_payload(request: WebSearchRequest) -> dict[str, Any]: + payload = request.model_dump(exclude_none=True) + if request.include: + payload["include"] = ",".join(request.include) + if request.exclude: + payload["exclude"] = ",".join(request.exclude) + return payload + + +async def search_bocha_web( + request: WebSearchRequest, + *, + client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + if not settings.BOCHA_API_KEY: + raise BochaSearchConfigError("BOCHA_API_KEY is not configured") + + headers = { + "Authorization": f"Bearer {settings.BOCHA_API_KEY}", + "Content-Type": "application/json", + } + payload = _build_payload(request) + + if client is not None: + response = await client.post( + settings.BOCHA_WEB_SEARCH_URL, + headers=headers, + json=payload, + ) + return _parse_response(response) + + async with httpx.AsyncClient( + timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS + ) as managed_client: + response = await managed_client.post( + settings.BOCHA_WEB_SEARCH_URL, + headers=headers, + json=payload, + ) + return _parse_response(response) + + +def _parse_response(response: httpx.Response) -> dict[str, Any]: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise BochaSearchAPIError( + exc.response.status_code, + _response_detail(exc.response), + ) from exc + + return response.json() + + +def _response_detail(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return response.text diff --git a/tests/unit/test_web_search.py b/tests/unit/test_web_search.py new file mode 100644 index 0000000..f1f2c4a --- /dev/null +++ b/tests/unit/test_web_search.py @@ -0,0 +1,115 @@ +import asyncio +import importlib.util +from pathlib import Path + +import httpx +import pytest + + +def _load_web_search_module(): + module_path = ( + Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py" + ) + spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +web_search = _load_web_search_module() + + +class FakeClient: + def __init__(self, response): + self.response = response + self.calls = [] + + async def post(self, url, *, headers, json): + self.calls.append({"url": url, "headers": headers, "json": json}) + return self.response + + +def test_search_bocha_web_posts_expected_payload(monkeypatch): + monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test") + monkeypatch.setattr( + web_search.settings, + "BOCHA_WEB_SEARCH_URL", + "https://api.bochaai.com/v1/web-search", + ) + response = httpx.Response( + 200, + json={"data": {"webPages": {"value": []}}}, + request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"), + ) + client = FakeClient(response) + + result = asyncio.run( + web_search.search_bocha_web( + web_search.WebSearchRequest( + query="天津水务", + freshness="oneWeek", + summary=True, + count=5, + include=["example.com", "news.example.com"], + exclude=["spam.example.com"], + ), + client=client, + ) + ) + + assert result == {"data": {"webPages": {"value": []}}} + assert client.calls == [ + { + "url": "https://api.bochaai.com/v1/web-search", + "headers": { + "Authorization": "Bearer sk-test", + "Content-Type": "application/json", + }, + "json": { + "query": "天津水务", + "freshness": "oneWeek", + "summary": True, + "count": 5, + "include": "example.com,news.example.com", + "exclude": "spam.example.com", + }, + } + ] + + +def test_search_bocha_web_requires_api_key(monkeypatch): + monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "") + + with pytest.raises(web_search.BochaSearchConfigError): + asyncio.run( + web_search.search_bocha_web( + web_search.WebSearchRequest(query="天津水务"), + client=FakeClient(httpx.Response(200, json={})), + ) + ) + + +def test_search_bocha_web_surfaces_upstream_error(monkeypatch): + monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test") + response = httpx.Response( + 401, + json={"error": "invalid api key"}, + request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"), + ) + + with pytest.raises(web_search.BochaSearchAPIError) as exc_info: + asyncio.run( + web_search.search_bocha_web( + web_search.WebSearchRequest(query="天津水务"), + client=FakeClient(response), + ) + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == {"error": "invalid api key"} + + +def test_web_search_request_validates_count_range(): + with pytest.raises(ValueError): + web_search.WebSearchRequest(query="天津水务", count=51) From e588d1cf33829bb7bf31f22d4c392c323d211f82 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 17:09:42 +0800 Subject: [PATCH 35/93] feat(api): add Tianditu geocoding --- .env.example | 7 ++ app/api/v1/endpoints/geocoding.py | 29 +++++++ app/api/v1/router.py | 2 + app/core/config.py | 5 ++ app/services/geocoding.py | 76 ++++++++++++++++ tests/unit/test_geocoding.py | 140 ++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 app/api/v1/endpoints/geocoding.py create mode 100644 app/services/geocoding.py create mode 100644 tests/unit/test_geocoding.py diff --git a/.env.example b/.env.example index 54c973d..34a15ba 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,10 @@ KEYCLOAK_AUDIENCE="account" BOCHA_API_KEY="sk-your-bocha-api-key" BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30 + +# ============================================ +# Tianditu Geocoding API +# ============================================ +TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token" +TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder" +TIANDITU_GEOCODER_TIMEOUT_SECONDS=30 diff --git a/app/api/v1/endpoints/geocoding.py b/app/api/v1/endpoints/geocoding.py new file mode 100644 index 0000000..24c6797 --- /dev/null +++ b/app/api/v1/endpoints/geocoding.py @@ -0,0 +1,29 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException, status + +from app.services.geocoding import ( + TiandituGeocodeRequest, + TiandituGeocodingAPIError, + TiandituGeocodingConfigError, + geocode_tianditu, +) + +router = APIRouter() + + +@router.post( + "/tianditu/geocode", + summary="Tianditu Geocoding", + description="调用天地图地理编码服务,将结构化地址转换为经纬度", +) +async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]: + try: + return await geocode_tianditu(request) + except TiandituGeocodingConfigError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except TiandituGeocodingAPIError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 4e018cf..52c5431 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -19,6 +19,7 @@ from app.api.v1.endpoints import ( audit, # 新增:审计日志 meta, web_search, + geocoding, ) from app.api.v1.endpoints.network import ( general, @@ -95,6 +96,7 @@ api_router.include_router(misc.router, tags=["Misc"]) api_router.include_router(risk.router, tags=["Risk"]) api_router.include_router(cache.router, tags=["Cache"]) api_router.include_router(web_search.router, tags=["Web Search"]) +api_router.include_router(geocoding.router, tags=["Geocoding"]) api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"]) api_router.include_router( burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"] diff --git a/app/core/config.py b/app/core/config.py index 49ccdaf..791d470 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -69,6 +69,11 @@ class Settings(BaseSettings): BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0 + # Tianditu Geocoding API + TIANDITU_GEOCODER_TOKEN: str = "" + TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder" + TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0 + @property def SQLALCHEMY_DATABASE_URI(self) -> str: db_password = quote_plus(self.DB_PASSWORD) diff --git a/app/services/geocoding.py b/app/services/geocoding.py new file mode 100644 index 0000000..1fa7eab --- /dev/null +++ b/app/services/geocoding.py @@ -0,0 +1,76 @@ +import json +from typing import Any + +import httpx +from pydantic import AliasChoices, BaseModel, Field + +from app.core.config import settings + + +class TiandituGeocodeRequest(BaseModel): + keyword: str = Field( + ..., + min_length=1, + validation_alias=AliasChoices("keyword", "keyWord"), + description="地理编码地址关键字", + ) + + +class TiandituGeocodingConfigError(RuntimeError): + pass + + +class TiandituGeocodingAPIError(RuntimeError): + def __init__(self, status_code: int, detail: Any): + super().__init__("Tianditu Geocoding API request failed") + self.status_code = status_code + self.detail = detail + + +async def geocode_tianditu( + request: TiandituGeocodeRequest, + *, + client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + if not settings.TIANDITU_GEOCODER_TOKEN: + raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured") + + params = { + "ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False), + "tk": settings.TIANDITU_GEOCODER_TOKEN, + } + + if client is not None: + response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params) + return _parse_response(response) + + async with httpx.AsyncClient( + timeout=settings.TIANDITU_GEOCODER_TIMEOUT_SECONDS + ) as managed_client: + response = await managed_client.get( + settings.TIANDITU_GEOCODER_URL, + params=params, + ) + return _parse_response(response) + + +def _parse_response(response: httpx.Response) -> dict[str, Any]: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise TiandituGeocodingAPIError( + exc.response.status_code, + _response_detail(exc.response), + ) from exc + + data = response.json() + if str(data.get("status")) != "0": + raise TiandituGeocodingAPIError(502, data) + return data + + +def _response_detail(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return response.text diff --git a/tests/unit/test_geocoding.py b/tests/unit/test_geocoding.py new file mode 100644 index 0000000..9f4366b --- /dev/null +++ b/tests/unit/test_geocoding.py @@ -0,0 +1,140 @@ +import asyncio +import importlib.util +import json +from pathlib import Path + +import httpx +import pytest + + +def _load_geocoding_module(): + module_path = Path(__file__).resolve().parents[2] / "app" / "services" / "geocoding.py" + spec = importlib.util.spec_from_file_location("tests_geocoding_under_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +geocoding = _load_geocoding_module() + + +class FakeClient: + def __init__(self, response): + self.response = response + self.calls = [] + + async def get(self, url, *, params): + self.calls.append({"url": url, "params": params}) + return self.response + + +def test_geocode_tianditu_gets_expected_params(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + monkeypatch.setattr( + geocoding.settings, + "TIANDITU_GEOCODER_URL", + "https://api.tianditu.gov.cn/geocoder", + ) + response = httpx.Response( + 200, + json={ + "location": {"lon": "116.407526", "lat": "39.904030", "level": "地名地址"}, + "status": "0", + "msg": "ok", + }, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + client = FakeClient(response) + + result = asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=client, + ) + ) + + assert result["location"] == { + "lon": "116.407526", + "lat": "39.904030", + "level": "地名地址", + } + assert client.calls == [ + { + "url": "https://api.tianditu.gov.cn/geocoder", + "params": { + "ds": json.dumps({"keyWord": "北京市人民政府"}, ensure_ascii=False), + "tk": "tk-test", + }, + } + ] + + +def test_geocode_tianditu_accepts_key_word_alias(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + response = httpx.Response( + 200, + json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"}, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + + result = asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyWord="北京市人民政府"), + client=FakeClient(response), + ) + ) + + assert result["status"] == "0" + + +def test_geocode_tianditu_requires_token(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "") + + with pytest.raises(geocoding.TiandituGeocodingConfigError): + asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=FakeClient(httpx.Response(200, json={})), + ) + ) + + +def test_geocode_tianditu_surfaces_http_error(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + response = httpx.Response( + 403, + json={"msg": "invalid tk"}, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + + with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info: + asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=FakeClient(response), + ) + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == {"msg": "invalid tk"} + + +def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + response = httpx.Response( + 200, + json={"status": "100", "msg": "bad request"}, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + + with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info: + asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=FakeClient(response), + ) + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == {"status": "100", "msg": "bad request"} From a1e9673d9ae1f07965bc2b2d3a282c968ffcba1c Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 18:18:22 +0800 Subject: [PATCH 36/93] ci: add Gitea package workflow --- .dockerignore | 18 +++ .gitea/workflows/package.yml | 211 ++++++++++++++++++++++++++++ .github/workflows/build-package.yml | 128 ----------------- AGENTS.md | 38 +++++ Dockerfile | 7 +- infra/docker/docker-compose.yml | 3 +- 6 files changed, 272 insertions(+), 133 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/package.yml delete mode 100644 .github/workflows/build-package.yml create mode 100644 AGENTS.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..75b8f60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.gitea +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +build/ +dist/ +package/ +temp/ +data/ +db_inp/ +inp/ +.env +*.pyc +*.dump diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml new file mode 100644 index 0000000..86907da --- /dev/null +++ b/.gitea/workflows/package.yml @@ -0,0 +1,211 @@ +name: Server CI/CD + +on: + push: + tags: + - "v*" + - "latest" + workflow_dispatch: {} + +jobs: + docker-image: + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + defaults: + run: + shell: bash + + steps: + - name: Checkout code + env: + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + GIT_USERNAME: ${{ github.actor }} + GIT_TOKEN: ${{ github.token }} + run: | + case "$SERVER_URL" in + http://*) + AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}" + ;; + https://*) + AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}" + ;; + *) + AUTH_SERVER_URL="$SERVER_URL" + ;; + esac + + if [ ! -d .git ]; then + git init . + fi + + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + else + git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + fi + + git fetch --depth=1 origin "$COMMIT_SHA" + git checkout --force --detach FETCH_HEAD + git clean -ffdx + + - name: Normalize image metadata + env: + RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} + RAW_REPOSITORY: ${{ github.repository }} + RAW_REF_NAME: ${{ github.ref_name }} + run: | + RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ -z "${RAW_REGISTRY_HOST}" ]; then + echo "Missing required repository variable: REGISTRY_HOST" + exit 1 + fi + + REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + + if [ -z "${REGISTRY_HOST}" ]; then + echo "Repository variable REGISTRY_HOST resolves to an empty host" + exit 1 + fi + + REPOSITORY_PATH="${RAW_REPOSITORY#/}" + IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" + IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}" + IMAGE_TAG="${RAW_REF_NAME}" + { + echo "REGISTRY_HOST=${REGISTRY_HOST}" + echo "REPOSITORY_PATH=${REPOSITORY_PATH}" + echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}" + echo "IMAGE_NAME=${IMAGE_NAME}" + echo "IMAGE_TAG=${IMAGE_TAG}" + echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" + } >> "$GITHUB_ENV" + + - name: Login to Gitea Container Registry + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + if [ -z "${REGISTRY_HOST:-}" ]; then + echo "Missing resolved environment value: REGISTRY_HOST" + exit 1 + fi + + if [ -z "${REGISTRY_USERNAME}" ]; then + echo "Missing required repository secret: REGISTRY_USERNAME" + exit 1 + fi + + if [ -z "${REGISTRY_PASSWORD}" ]; then + echo "Missing required repository secret: REGISTRY_PASSWORD" + exit 1 + fi + + echo "Logging into registry host: ${REGISTRY_HOST}" + echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \ + --username "${REGISTRY_USERNAME}" \ + --password-stdin + + - name: Build and Push Image + run: | + if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then + echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG" + exit 1 + fi + + push_with_retry() { + image_ref="$1" + attempt=1 + max_attempts=3 + + while [ "$attempt" -le "$max_attempts" ]; do + if docker push "$image_ref"; then + return 0 + fi + + if [ "$attempt" -eq "$max_attempts" ]; then + return 1 + fi + + echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..." + attempt=$((attempt + 1)) + sleep 10 + done + } + + if [ "${IMAGE_TAG}" = "latest" ]; then + docker build \ + -f ./Dockerfile \ + -t "${IMAGE_NAME}:latest" \ + . + push_with_retry "${IMAGE_NAME}:latest" + else + docker build \ + -f ./Dockerfile \ + -t "${IMAGE_NAME}:${IMAGE_TAG}" \ + -t "${IMAGE_NAME}:latest" \ + . + push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" + push_with_retry "${IMAGE_NAME}:latest" + fi + + - name: Notify Deploy Server + run: | + post_deploy_webhook() { + label="$1" + payload="$2" + webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}" + token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" + + webhook_url=$(echo "$webhook_url" | xargs) + + echo "[$label] Calling webhook: $webhook_url" + + http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + -d "$payload") + + echo "[$label] webhook HTTP status: ${http_code}" + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + return 0 + fi + + echo "[$label] response headers:" + cat /tmp/deploy_headers.txt + echo "[$label] response body:" + cat /tmp/deploy_response.txt + return 1 + } + + PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" + FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}" + + echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}" + echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}" + if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then + exit 0 + fi + + echo "Primary webhook request failed, retrying with lowercase repo path..." + echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}" + if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then + exit 0 + fi + + echo "Deploy webhook failed after primary and fallback attempts." + exit 1 + + deploy-fallback-log: + runs-on: ubuntu-22.04 + needs: docker-image + if: failure() + steps: + - name: Deployment not triggered + run: echo "Image build/push failed, deployment webhook was not called." diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml deleted file mode 100644 index efdb759..0000000 --- a/.github/workflows/build-package.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Build And Package - -on: - push: - tags: - - "v*" - -jobs: - build-package: - runs-on: ${{ matrix.os }} - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - - steps: - - name: Checkout source - uses: actions/checkout@v5 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install system build tools - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y build-essential - - - name: Install compile dependencies - run: | - python -m pip install --upgrade pip - pip install cython setuptools wheel - - - name: Run Cython compile - run: | - python scripts/compile.py - - - name: Prepare package and archive - run: | - python - <<'PY' - import os - import shutil - import tarfile - import zipfile - import sys - from pathlib import Path - - root = Path.cwd() - package_dir = root / "package" - dist_dir = root / "dist" - - for d in [package_dir, dist_dir]: - if d.exists(): - shutil.rmtree(d) - d.mkdir(parents=True, exist_ok=True) - - # Define directories with compiled artifacts - compile_dirs = ["app/services", "app/native/wndb", "app/algorithms"] - # Global ignore list - ignore_names = { - ".git", - ".github", - "__pycache__", - ".pytest_cache", - ".mypy_cache", - ".venv", - "venv", - "temp", - "tests", - "package", - "dist", - } - - def ignore_func(directory, names): - rel_dir = os.path.relpath(directory, root).replace("\\", "/") - is_in_compile_path = any(rel_dir.startswith(d) for d in compile_dirs) - - ignored = [] - for name in names: - if name in ignore_names or name.endswith(".pyc"): - ignored.append(name) - # Exclude source .py files only in compiled directories - elif is_in_compile_path and name.endswith(".py"): - ignored.append(name) - return ignored - - for item in root.iterdir(): - if item.name in ignore_names: - continue - target = package_dir / item.name - if item.is_dir(): - shutil.copytree(item, target, ignore=ignore_func) - else: - shutil.copy2(item, target) - - # Safety guard: ensure no .github directory remains - github_paths = [p for p in package_dir.rglob(".github") if p.is_dir()] - for p in github_paths: - shutil.rmtree(p, ignore_errors=True) - - sha = os.environ["GITHUB_SHA"] - run_os = os.environ["RUNNER_OS"].lower() - - if run_os == "windows": - archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.zip" - with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for f in package_dir.rglob("*"): - if f.is_file(): - zf.write(f, f.relative_to(package_dir)) - else: - archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.tar.gz" - with tarfile.open(archive_path, "w:gz") as tf: - tf.add(package_dir, arcname=".") - - print(f"Archive created: {archive_path}") - PY - shell: bash - - - name: Upload package artifact - uses: actions/upload-artifact@v5 - with: - name: tjwater-server-package-${{ runner.os }} - path: dist/* - retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..34ee773 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`. + +Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed. + +## Build, Test, and Development Commands + +Use the existing conda environment when available: + +```bash +conda run -n server python -m pytest tests/unit tests/auth -q +conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +docker build -t tjwater-server:local . +docker compose -f infra/docker/docker-compose.yml config +``` + +`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion. + +## Coding Style & Naming Conventions + +Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions. + +## Testing Guidelines + +The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`. + +## Commit & Pull Request Guidelines + +History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary. + +Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior. + +## Security & Configuration Tips + +Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. Secrets for CI/CD belong in Gitea repository secrets such as `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and deploy webhook credentials. diff --git a/Dockerfile b/Dockerfile index 0dc2071..757f518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,11 +10,10 @@ COPY requirements.txt . RUN pip install uv RUN uv pip install --system --no-cache-dir -r requirements.txt -# 将代码放入子目录 'app',将数据放入子目录 'db_inp' -# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 +# 将代码放入子目录 'app',临时数据目录运行时创建。 +# db_inp 和 .env 都不应依赖 Git 跟踪或被烘焙进镜像。 COPY app ./app -COPY db_inp ./db_inp -COPY .env . +RUN mkdir -p ./db_inp # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 ENV PYTHONPATH=/app diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index abbd2c7..138b374 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -3,9 +3,10 @@ services: # Core API Service # ========================================== api: + image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local} build: context: ../.. - dockerfile: infra/docker/Dockerfile + dockerfile: Dockerfile container_name: tjwater_api restart: always ports: From 7a9fcaae81023bffeba4185bc98c00942f58be88 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 18:22:16 +0800 Subject: [PATCH 37/93] ci: add deployment trigger script --- Dockerfile | 6 ++- scripts/trigger-gitea-pipeline.sh | 67 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100755 scripts/trigger-gitea-pipeline.sh diff --git a/Dockerfile b/Dockerfile index 757f518..6690198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,16 @@ FROM condaforge/miniforge3:latest WORKDIR /app +ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \ + PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \ + UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple + # 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题) RUN mamba install -y python=3.12 pymetis && \ mamba clean -afy COPY requirements.txt . -RUN pip install uv +RUN pip install --no-cache-dir uv RUN uv pip install --system --no-cache-dir -r requirements.txt # 将代码放入子目录 'app',临时数据目录运行时创建。 diff --git a/scripts/trigger-gitea-pipeline.sh b/scripts/trigger-gitea-pipeline.sh new file mode 100755 index 0000000..53efaa6 --- /dev/null +++ b/scripts/trigger-gitea-pipeline.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]" + echo "" + echo "Examples:" + echo " bash scripts/trigger-gitea-pipeline.sh" + echo " bash scripts/trigger-gitea-pipeline.sh origin latest" + echo " bash scripts/trigger-gitea-pipeline.sh gitea latest" + echo " bash scripts/trigger-gitea-pipeline.sh origin v2026.06.09.1" + exit 0 +fi + +resolve_default_remote() { + if git remote get-url gitea >/dev/null 2>&1; then + echo "gitea" + return 0 + fi + + if git remote get-url origin >/dev/null 2>&1; then + echo "origin" + return 0 + fi + + return 1 +} + +REMOTE="${1:-}" +TAG="${2:-latest}" + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "[ERROR] Current directory is not a git repository." + exit 1 +fi + +if [[ -z "$REMOTE" ]]; then + if ! REMOTE="$(resolve_default_remote)"; then + echo "[ERROR] No default remote found. Expected 'gitea' or 'origin'." + echo "Available remotes:" + git remote -v || true + exit 1 + fi +fi + +if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then + echo "[ERROR] Remote '$REMOTE' does not exist." + echo "Available remotes:" + git remote -v + exit 1 +fi + +HEAD_SHA="$(git rev-parse --short HEAD)" +MESSAGE="manual trigger: ${TAG} $(date '+%F %T')" + +echo "[INFO] HEAD: ${HEAD_SHA}" +echo "[INFO] Recreate annotated tag '${TAG}'" +git tag -fa "$TAG" -m "$MESSAGE" + +echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)" +git push "$REMOTE" "refs/tags/${TAG}" --force + +echo "[INFO] Verify remote tag reference" +git ls-remote --tags "$REMOTE" "refs/tags/${TAG}" + +echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'." From 4fa8e5574820ad5286c16fe8a5ad7c9200f98ecd Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 18:24:14 +0800 Subject: [PATCH 38/93] =?UTF-8?q?=E5=88=A0=E9=99=A4=20copilot=20=E8=87=AA?= =?UTF-8?q?=E8=BF=B0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 82 --------------------------------- 1 file changed, 82 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 23396a2..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,82 +0,0 @@ -# Copilot Instructions for TJWater Server - -This repository contains the backend code for the TJWater Server, a water distribution network management system built with FastAPI. - -## High-Level Architecture - -The application follows a layered architecture: - -- **Entry Point**: `app/main.py` initializes the FastAPI application, database connections (PostgreSQL & TimescaleDB), and middleware. -- **API Layer**: `app/api/v1` contains the route handlers. -- **Service Layer**: `app/services` contains business logic and orchestration. -- **Infrastructure Layer**: `app/infra` handles database connections (`db`), audit logging (`audit`), and external integrations. -- **Domain Layer**: `app/domain` likely contains core domain models. -- **Native/Algorithms**: `app/native` and `app/algorithms` handle specialized water network calculations (possibly using EPANET/WNTR). - -## Build, Test, and Run Commands - -### Environment Setup - -- Dependencies are listed in `requirements.txt`. -- Configuration is managed via environment variables (see `.env.example` if available, or `app/core/config.py`). -- **Important**: Ensure `.env` is configured with correct database credentials for both PostgreSQL and TimescaleDB. - -If first time setting up, you may want to create a Conda environment: - -```bash -conda create -n server python=3.12 -conda activate server -pip install uv -uv pip install -r requirements.txt -conda install -c conda-forge pymetis -``` - -### Running the Server - -The preferred way to run the server locally is using the helper script which sets up the Python path correctly: - -```bash -conda activate server -python scripts/run_server.py -``` - -Alternatively, you can run directly with uvicorn (ensure PYTHONPATH includes the root): - -```bash -conda activate server -uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload -``` - -### Running Tests - -Use `pytest` to run tests. The `tests/conftest.py` handles path setup. - -```bash -# Run all tests -pytest - -# Run a specific test file -pytest tests/unit/test_specific_file.py - -# Run a specific test case -pytest tests/unit/test_specific_file.py::test_function_name -``` - -### Building (Optional) - -The project includes scripts to compile Python modules to `.pyd` files using Cython (see `scripts/build_pyd.py`). This is likely for distribution/performance but not required for standard development. - -## Key Conventions - -- **Async/Await**: The codebase heavily uses `async` and `await` for I/O operations, especially database interactions. -- **Database Management**: - - Connections are managed globally in `app.infra.db` and initialized in `lifespan` (app/main.py). - - Use `app.infra.db.dynamic_manager` for project-specific database connections (multi-tenancy/dynamic projects). -- **Pydantic**: extensively used for data validation and settings management. -- **Scripts**: The `scripts/` directory contains many utility scripts for maintenance, data processing, and server management. Check there before writing new operational scripts. -- **Water Network Modeling**: Interactions with water network models often involve `epanet` or `wntr` libraries. Be aware of domain-specific terminology (nodes, links, junctions, tanks). - -## Code Style - -- Follow standard PEP 8 guidelines. -- No specific linter configuration was found, so default to standard Python formatting. From f35287d3cf5b5bf6e0c31121904bb7aa5f643f53 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 11:45:22 +0800 Subject: [PATCH 39/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 ++-- Dockerfile | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 75b8f60..44aa59a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,8 +11,8 @@ dist/ package/ temp/ data/ -db_inp/ +# db_inp/ inp/ -.env +# .env *.pyc *.dump diff --git a/Dockerfile b/Dockerfile index 6690198..173e67e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,11 @@ COPY requirements.txt . RUN pip install --no-cache-dir uv RUN uv pip install --system --no-cache-dir -r requirements.txt -# 将代码放入子目录 'app',临时数据目录运行时创建。 -# db_inp 和 .env 都不应依赖 Git 跟踪或被烘焙进镜像。 +# 将代码放入子目录 'app',将数据放入子目录 'db_inp' +# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 COPY app ./app -RUN mkdir -p ./db_inp +COPY db_inp ./db_inp +COPY .env . # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 ENV PYTHONPATH=/app From 26643d68c76ebdf3116168d56d9fd3646247a614 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 15:08:47 +0800 Subject: [PATCH 40/93] =?UTF-8?q?feat(ci):=20=E6=B7=BB=E5=8A=A0=20Gitea=20?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E5=AF=86=E9=92=A5=20TJWATER=5FSERVER=5FENV?= =?UTF-8?q?=20=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .gitea/workflows/package.yml | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.env.example b/.env.example index 34a15ba..9b3b90c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # TJWater Server 环境变量配置模板 # 复制此文件为 .env 并填写实际值 +# CI/CD: 将生产 .env 的完整内容保存为 Gitea 仓库密钥 TJWATER_SERVER_ENV。 ENVIRONMENT="production" NETWORK_NAME="tjwater" # ============================================ diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 86907da..2e5fb22 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -112,6 +112,54 @@ jobs: --username "${REGISTRY_USERNAME}" \ --password-stdin + - name: Materialize runtime env file + env: + TJWATER_SERVER_ENV: ${{ secrets.TJWATER_SERVER_ENV }} + run: | + if [ -z "${TJWATER_SERVER_ENV}" ]; then + echo "Missing required repository secret: TJWATER_SERVER_ENV" + echo "Store the backend .env file content as a multiline Gitea repository secret named TJWATER_SERVER_ENV." + exit 1 + fi + + printf '%s\n' "${TJWATER_SERVER_ENV}" > .env + chmod 600 .env + + required_env_keys=( + ENVIRONMENT + NETWORK_NAME + SECRET_KEY + ENCRYPTION_KEY + DB_NAME + DB_HOST + DB_PORT + DB_USER + DB_PASSWORD + TIMESCALEDB_DB_NAME + TIMESCALEDB_DB_HOST + TIMESCALEDB_DB_PORT + TIMESCALEDB_DB_USER + TIMESCALEDB_DB_PASSWORD + METADATA_DB_NAME + METADATA_DB_HOST + METADATA_DB_PORT + METADATA_DB_USER + METADATA_DB_PASSWORD + DATABASE_ENCRYPTION_KEY + ) + + missing_keys=() + for key in "${required_env_keys[@]}"; do + if ! grep -Eq "^[[:space:]]*${key}=" .env; then + missing_keys+=("$key") + fi + done + + if [ "${#missing_keys[@]}" -gt 0 ]; then + echo "TJWATER_SERVER_ENV is missing required keys: ${missing_keys[*]}" + exit 1 + fi + - name: Build and Push Image run: | if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then @@ -165,6 +213,16 @@ jobs: webhook_url=$(echo "$webhook_url" | xargs) + if [ -z "$webhook_url" ]; then + echo "Missing required repository variable: DEPLOY_WEBHOOK_URL" + return 1 + fi + + if [ -z "$token" ]; then + echo "Missing required repository secret: DEPLOY_WEBHOOK_TOKEN" + return 1 + fi + echo "[$label] Calling webhook: $webhook_url" http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \ From 2af89eea1cd34b3850662dbef4384b437e5a9e5e Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 15:18:10 +0800 Subject: [PATCH 41/93] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=A3=80=E6=9F=A5=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E5=AF=86?= =?UTF-8?q?=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/package.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 2e5fb22..3bf812e 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -128,8 +128,6 @@ jobs: required_env_keys=( ENVIRONMENT NETWORK_NAME - SECRET_KEY - ENCRYPTION_KEY DB_NAME DB_HOST DB_PORT @@ -150,7 +148,7 @@ jobs: missing_keys=() for key in "${required_env_keys[@]}"; do - if ! grep -Eq "^[[:space:]]*${key}=" .env; then + if ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" .env; then missing_keys+=("$key") fi done From 2a823b261612c60e4f80bae7d284b5904dd37372 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 15:41:59 +0800 Subject: [PATCH 42/93] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A3=80=E5=87=BA=E6=AD=A5=E9=AA=A4=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/package.yml | 41 ++++++------------------------------ 1 file changed, 7 insertions(+), 34 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 3bf812e..b094f71 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -18,40 +18,6 @@ jobs: shell: bash steps: - - name: Checkout code - env: - SERVER_URL: ${{ github.server_url }} - REPOSITORY: ${{ github.repository }} - COMMIT_SHA: ${{ github.sha }} - GIT_USERNAME: ${{ github.actor }} - GIT_TOKEN: ${{ github.token }} - run: | - case "$SERVER_URL" in - http://*) - AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}" - ;; - https://*) - AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}" - ;; - *) - AUTH_SERVER_URL="$SERVER_URL" - ;; - esac - - if [ ! -d .git ]; then - git init . - fi - - if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" - else - git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" - fi - - git fetch --depth=1 origin "$COMMIT_SHA" - git checkout --force --detach FETCH_HEAD - git clean -ffdx - - name: Normalize image metadata env: RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} @@ -158,6 +124,13 @@ jobs: exit 1 fi + - name: Validate workspace + run: | + if [ ! -f ./Dockerfile ]; then + echo "Dockerfile not found in workspace. Checkout is disabled, so the runner must provide repository files before this job starts." + exit 1 + fi + - name: Build and Push Image run: | if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then From 5fd82b8e7c85eb5ce03d29caf6f97cce1ca297d8 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 15:44:41 +0800 Subject: [PATCH 43/93] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A3=80=E5=87=BA=E6=AD=A5=E9=AA=A4=E4=BB=A5=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=9C=89=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/package.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index b094f71..9b51e98 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -18,6 +18,11 @@ jobs: shell: bash steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Normalize image metadata env: RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} From bbf6a0f7bae4e5e5ce194b7bfa17714d78aa7835 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 16:16:26 +0800 Subject: [PATCH 44/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A3=80=E5=87=BA=E6=AD=A5=E9=AA=A4=E5=92=8C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E9=AA=8C=E8=AF=81=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 9b51e98..07784b4 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: https://gitea.waternetwork.cn/actions/checkout@v4 with: fetch-depth: 1 @@ -132,7 +132,7 @@ jobs: - name: Validate workspace run: | if [ ! -f ./Dockerfile ]; then - echo "Dockerfile not found in workspace. Checkout is disabled, so the runner must provide repository files before this job starts." + echo "Dockerfile not found in workspace. Repository checkout may have failed or produced an unexpected workspace." exit 1 fi From f6939f5516ef87594b37774b67163e78944273b4 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 16:22:38 +0800 Subject: [PATCH 45/93] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20db=5Finp=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E5=A4=8D=E5=88=B6=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E5=A4=B9=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 173e67e..b10b3b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,9 @@ RUN uv pip install --system --no-cache-dir -r requirements.txt # 将代码放入子目录 'app',将数据放入子目录 'db_inp' # 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 COPY app ./app -COPY db_inp ./db_inp +# COPY db_inp ./db_inp COPY .env . +RUN mkdir -p db_inp temp data inp # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 ENV PYTHONPATH=/app From 2a762e63a73c57f5781ceeba76509dba516c7d8b Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 10 Jun 2026 16:27:42 +0800 Subject: [PATCH 46/93] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Gitea=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=20URL=20=E5=92=8C=E7=94=A8=E6=88=B7=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/package.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 07784b4..25a10a2 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -62,6 +62,7 @@ jobs: env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + GITEA_SERVER_URL: ${{ github.server_url }} run: | if [ -z "${REGISTRY_HOST:-}" ]; then echo "Missing resolved environment value: REGISTRY_HOST" @@ -78,6 +79,22 @@ jobs: exit 1 fi + echo "Registry username: ${REGISTRY_USERNAME}" + echo "Image target: ${IMAGE_REF}" + + API_SERVER_URL="${GITEA_SERVER_URL%/}" + api_user="$(curl -fsS \ + -H "Authorization: token ${REGISTRY_PASSWORD}" \ + "${API_SERVER_URL}/api/v1/user" \ + | sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1 || true)" + + if [ -n "${api_user}" ]; then + echo "Registry token resolves to Gitea user: ${api_user}" + else + echo "Could not resolve Gitea user from REGISTRY_PASSWORD token; docker login may still use a password or a token without API access." + fi + echo "Logging into registry host: ${REGISTRY_HOST}" echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \ --username "${REGISTRY_USERNAME}" \ From 23c008f6023485151bcaa9a6576e33b16c79619a Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 12 Jun 2026 10:18:41 +0800 Subject: [PATCH 47/93] feat(auth): migrate to Keycloak metadata auth --- app/api/v1/endpoints/agent_auth.py | 48 ++++ app/api/v1/endpoints/auth.py | 190 -------------- app/api/v1/endpoints/network/geometry.py | 4 +- app/api/v1/endpoints/user_management.py | 215 ---------------- app/api/v1/router.py | 8 +- app/auth/dependencies.py | 100 -------- app/auth/keycloak_dependencies.py | 69 ++--- app/auth/permissions.py | 106 -------- app/core/config.py | 10 +- app/core/security.py | 95 ------- app/domain/models/role.py | 36 --- app/domain/schemas/user.py | 68 ----- app/infra/audit/middleware.py | 20 +- .../db/metadb/repositories/user_repository.py | 235 ------------------ cli/tests/unit/test_tjwater_cli.py | 3 - cli/tjwater_cli/core.py | 5 - cli/tjwater_cli_endpoint_scope.md | 7 +- tests/api/test_agent_auth_endpoints.py | 80 ++++++ tests/api/test_api_integration.py | 19 +- tests/api/test_auth_endpoints.py | 139 ----------- tests/api/test_user_management_endpoints.py | 95 ------- tests/auth/test_security.py | 36 --- tests/conftest.py | 19 -- tests/unit/test_audit_repository.py | 6 +- tests/unit/test_auth_dependencies.py | 97 -------- tests/unit/test_permissions.py | 56 ----- tests/unit/test_user_repository.py | 124 --------- 27 files changed, 178 insertions(+), 1712 deletions(-) create mode 100644 app/api/v1/endpoints/agent_auth.py delete mode 100644 app/api/v1/endpoints/auth.py delete mode 100644 app/api/v1/endpoints/user_management.py delete mode 100644 app/auth/dependencies.py delete mode 100644 app/auth/permissions.py delete mode 100644 app/core/security.py delete mode 100644 app/domain/models/role.py delete mode 100644 app/domain/schemas/user.py delete mode 100644 app/infra/db/metadb/repositories/user_repository.py create mode 100644 tests/api/test_agent_auth_endpoints.py delete mode 100644 tests/api/test_auth_endpoints.py delete mode 100644 tests/api/test_user_management_endpoints.py delete mode 100644 tests/auth/test_security.py delete mode 100644 tests/unit/test_auth_dependencies.py delete mode 100644 tests/unit/test_permissions.py delete mode 100644 tests/unit/test_user_repository.py diff --git a/app/api/v1/endpoints/agent_auth.py b/app/api/v1/endpoints/agent_auth.py new file mode 100644 index 0000000..c3f91d6 --- /dev/null +++ b/app/api/v1/endpoints/agent_auth.py @@ -0,0 +1,48 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.auth.keycloak_dependencies import get_current_keycloak_payload +from app.auth.metadata_dependencies import get_current_metadata_user +from app.auth.project_dependencies import ( + ProjectContext, + get_project_context, +) + +router = APIRouter() + + +class AgentAuthContextResponse(BaseModel): + user_id: str + keycloak_sub: str + username: str + role: str + is_superuser: bool + project_id: str + project_role: str + token_expires_at: str | None = None + + +@router.get("/agent/auth/context", response_model=AgentAuthContextResponse) +async def get_agent_auth_context( + ctx: ProjectContext = Depends(get_project_context), + current_user=Depends(get_current_metadata_user), + keycloak_payload: dict = Depends(get_current_keycloak_payload), +) -> AgentAuthContextResponse: + exp = keycloak_payload.get("exp") + token_expires_at = ( + datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() + if isinstance(exp, int) + else None + ) + return AgentAuthContextResponse( + user_id=str(current_user.id), + keycloak_sub=str(current_user.keycloak_id), + username=current_user.username, + role=current_user.role, + is_superuser=current_user.is_superuser, + project_id=str(ctx.project_id), + project_role=ctx.project_role, + token_expires_at=token_expires_at, + ) diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py deleted file mode 100644 index 819a094..0000000 --- a/app/api/v1/endpoints/auth.py +++ /dev/null @@ -1,190 +0,0 @@ -from typing import Annotated -from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm -from app.core.config import settings -from app.core.security import create_access_token, create_refresh_token, verify_password -from app.domain.schemas.user import UserCreate, UserResponse, UserLogin, Token -from app.infra.db.metadb.repositories.user_repository import UserRepository -from app.auth.dependencies import get_user_repository, get_current_active_user -from app.domain.schemas.user import UserInDB -import logging - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.post( - "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED -) -async def register( - user_data: UserCreate, user_repo: UserRepository = Depends(get_user_repository) -) -> UserResponse: - """ - 用户注册 - - 创建新用户账号 - """ - # 检查用户名和邮箱是否已存在 - if await user_repo.user_exists(username=user_data.username): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Username already registered", - ) - - if await user_repo.user_exists(email=user_data.email): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" - ) - - # 创建用户 - try: - user = await user_repo.create_user(user_data) - if not user: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create user", - ) - return UserResponse.model_validate(user) - except Exception as e: - logger.error(f"Error during user registration: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Registration failed", - ) - - -@router.post("/login", response_model=Token) -async def login( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - user_repo: UserRepository = Depends(get_user_repository), -) -> Token: - """ - 用户登录(OAuth2 标准格式) - - 返回 JWT Access Token 和 Refresh Token - """ - # 验证用户(支持用户名或邮箱登录) - user = await user_repo.get_user_by_username(form_data.username) - if not user: - # 尝试用邮箱登录 - user = await user_repo.get_user_by_email(form_data.username) - - if not user or not verify_password(form_data.password, user.hashed_password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account" - ) - - # 生成 Token - access_token = create_access_token(subject=user.username) - refresh_token = create_refresh_token(subject=user.username) - - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - ) - - -@router.post("/login/simple", response_model=Token) -async def login_simple( - username: str, - password: str, - user_repo: UserRepository = Depends(get_user_repository), -) -> Token: - """ - 简化版登录接口(保持向后兼容) - - 直接使用 username 和 password 参数 - """ - # 验证用户 - user = await user_repo.get_user_by_username(username) - if not user: - user = await user_repo.get_user_by_email(username) - - if not user or not verify_password(password, user.hashed_password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account" - ) - - # 生成 Token - access_token = create_access_token(subject=user.username) - refresh_token = create_refresh_token(subject=user.username) - - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - ) - - -@router.get("/me", response_model=UserResponse) -async def get_current_user_info( - current_user: UserInDB = Depends(get_current_active_user), -) -> UserResponse: - """ - 获取当前登录用户信息 - """ - return UserResponse.model_validate(current_user) - - -@router.post("/refresh", response_model=Token) -async def refresh_token( - refresh_token: str, user_repo: UserRepository = Depends(get_user_repository) -) -> Token: - """ - 刷新 Access Token - - 使用 Refresh Token 获取新的 Access Token - """ - from jose import jwt, JWTError - - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate refresh token", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - payload = jwt.decode( - refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] - ) - username: str = payload.get("sub") - token_type: str = payload.get("type") - - if username is None or token_type != "refresh": - raise credentials_exception - - except JWTError: - raise credentials_exception - - # 验证用户仍然存在且激活 - user = await user_repo.get_user_by_username(username) - if not user or not user.is_active: - raise credentials_exception - - # 生成新的 Access Token - new_access_token = create_access_token(subject=user.username) - - return Token( - access_token=new_access_token, - refresh_token=refresh_token, # 保持原 refresh token - token_type="bearer", - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - ) diff --git a/app/api/v1/endpoints/network/geometry.py b/app/api/v1/endpoints/network/geometry.py index 8a99743..5adb575 100644 --- a/app/api/v1/endpoints/network/geometry.py +++ b/app/api/v1/endpoints/network/geometry.py @@ -10,7 +10,7 @@ from app.services.tjnetwork import ( get_network_node_coords, get_node_coord, ) -from app.auth.dependencies import get_current_user as verify_token +from app.auth.metadata_dependencies import get_current_metadata_user from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime import msgpack @@ -64,7 +64,7 @@ async def fastapi_get_network_in_extent( @router.get( "/getnetworkgeometries/", - dependencies=[Depends(verify_token)], + dependencies=[Depends(get_current_metadata_user)], summary="获取完整网络几何信息", description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)" ) diff --git a/app/api/v1/endpoints/user_management.py b/app/api/v1/endpoints/user_management.py deleted file mode 100644 index 72e40f0..0000000 --- a/app/api/v1/endpoints/user_management.py +++ /dev/null @@ -1,215 +0,0 @@ -""" -用户管理 API 接口 - -演示权限控制的使用 -""" - -from typing import List -from fastapi import APIRouter, Depends, HTTPException, status, Path, Query -from app.domain.schemas.user import UserResponse, UserUpdate, UserCreate -from app.domain.models.role import UserRole -from app.domain.schemas.user import UserInDB -from app.infra.db.metadb.repositories.user_repository import UserRepository -from app.auth.dependencies import get_user_repository, get_current_active_user -from app.auth.permissions import get_current_admin, require_role, check_resource_owner - -router = APIRouter() - - -@router.get( - "/", - summary="列出所有用户", - description="获取用户列表(仅管理员)", - response_model=List[UserResponse], -) -async def list_users( - skip: int = Query(0, ge=0, description="跳过的用户数"), - limit: int = Query(100, ge=1, le=1000, description="返回的最大用户数"), - current_user: UserInDB = Depends(require_role(UserRole.ADMIN)), - user_repo: UserRepository = Depends(get_user_repository), -) -> List[UserResponse]: - """ - 获取用户列表 - - 获取系统中所有的用户信息(需要管理员权限) - """ - users = await user_repo.get_all_users(skip=skip, limit=limit) - return [UserResponse.model_validate(user) for user in users] - - -@router.get( - "/{user_id}", - summary="获取用户详情", - description="获取指定用户的详细信息", - response_model=UserResponse, -) -async def get_user( - user_id: int = Path(..., gt=0, description="用户ID"), - current_user: UserInDB = Depends(get_current_active_user), - user_repo: UserRepository = Depends(get_user_repository), -) -> UserResponse: - """ - 获取用户详情 - - 管理员可查看所有用户,普通用户只能查看自己 - """ - # 检查权限 - if not check_resource_owner(user_id, current_user): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have permission to view this user", - ) - - user = await user_repo.get_user_by_id(user_id) - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - - return UserResponse.model_validate(user) - - -@router.put( - "/{user_id}", - summary="更新用户信息", - description="更新指定用户的信息", - response_model=UserResponse, -) -async def update_user( - user_id: int = Path(..., gt=0, description="用户ID"), - user_update: UserUpdate = None, - current_user: UserInDB = Depends(get_current_active_user), - user_repo: UserRepository = Depends(get_user_repository), -) -> UserResponse: - """ - 更新用户信息 - - 管理员可更新所有用户,普通用户只能更新自己(且不能修改角色) - """ - # 检查用户是否存在 - target_user = await user_repo.get_user_by_id(user_id) - if not target_user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - - # 权限检查 - is_owner = current_user.id == user_id - is_admin = UserRole(current_user.role).has_permission(UserRole.ADMIN) - - if not is_owner and not is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have permission to update this user", - ) - - # 非管理员不能修改角色和激活状态 - if not is_admin: - if user_update.role is not None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only admins can change user roles", - ) - if user_update.is_active is not None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only admins can change user active status", - ) - - # 更新用户 - updated_user = await user_repo.update_user(user_id, user_update) - if not updated_user: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update user", - ) - - return UserResponse.model_validate(updated_user) - - -@router.delete("/{user_id}", summary="删除用户", description="删除指定用户(仅管理员)") -async def delete_user( - user_id: int = Path(..., gt=0, description="用户ID"), - current_user: UserInDB = Depends(get_current_admin), - user_repo: UserRepository = Depends(get_user_repository), -) -> dict: - """ - 删除用户 - - 删除指定用户(需要管理员权限,不能删除自己) - """ - # 不能删除自己 - if current_user.id == user_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="You cannot delete your own account", - ) - - success = await user_repo.delete_user(user_id) - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - - return {"message": "User deleted successfully"} - - -@router.post( - "/{user_id}/activate", - summary="激活用户", - description="激活指定用户账户(仅管理员)", - response_model=UserResponse, -) -async def activate_user( - user_id: int = Path(..., gt=0, description="用户ID"), - current_user: UserInDB = Depends(get_current_admin), - user_repo: UserRepository = Depends(get_user_repository), -) -> UserResponse: - """ - 激活用户 - - 激活指定用户的账户(需要管理员权限) - """ - user_update = UserUpdate(is_active=True) - updated_user = await user_repo.update_user(user_id, user_update) - - if not updated_user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - - return UserResponse.model_validate(updated_user) - - -@router.post( - "/{user_id}/deactivate", - summary="停用用户", - description="停用指定用户账户(仅管理员)", - response_model=UserResponse, -) -async def deactivate_user( - user_id: int = Path(..., gt=0, description="用户ID"), - current_user: UserInDB = Depends(get_current_admin), - user_repo: UserRepository = Depends(get_user_repository), -) -> UserResponse: - """ - 停用用户 - - 停用指定用户的账户(需要管理员权限,不能停用自己) - """ - # 不能停用自己 - if current_user.id == user_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="You cannot deactivate your own account", - ) - - user_update = UserUpdate(is_active=False) - updated_user = await user_repo.update_user(user_id, user_update) - - if not updated_user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - - return UserResponse.model_validate(updated_user) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 52c5431..3e4e7d3 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( - auth, + agent_auth, project, simulation, scada, @@ -15,7 +15,6 @@ from app.api.v1.endpoints import ( leakage, burst_detection, burst_location, - user_management, # 新增:用户管理 audit, # 新增:审计日志 meta, web_search, @@ -54,10 +53,7 @@ from app.api.v1.endpoints.timeseries import ( api_router = APIRouter() # Core Services -api_router.include_router(auth.router, prefix="/auth", tags=["Auth"]) -api_router.include_router( - user_management.router, prefix="/users", tags=["User Management"] -) # 新增 +api_router.include_router(agent_auth.router, tags=["Agent Auth"]) api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增 api_router.include_router(meta.router, tags=["Metadata"]) api_router.include_router(project.router, tags=["Project"]) diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py deleted file mode 100644 index 3524f0a..0000000 --- a/app/auth/dependencies.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Annotated, Optional -from fastapi import Depends, HTTPException, status, Request -from fastapi.security import OAuth2PasswordBearer -from jose import jwt, JWTError -from app.core.config import settings -from app.domain.schemas.user import UserInDB, TokenPayload -from app.infra.db.metadb.repositories.user_repository import UserRepository -from app.infra.db.postgresql.database import Database - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") - - -# 数据库依赖 -async def get_db(request: Request) -> Database: - """ - 获取数据库实例 - - 从 FastAPI app.state 中获取在启动时初始化的数据库连接 - """ - if not hasattr(request.app.state, "db"): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Database not initialized", - ) - return request.app.state.db - - -async def get_user_repository(db: Database = Depends(get_db)) -> UserRepository: - """获取用户仓储实例""" - return UserRepository(db) - - -async def get_current_user( - token: str = Depends(oauth2_scheme), - user_repo: UserRepository = Depends(get_user_repository), -) -> UserInDB: - """ - 获取当前登录用户 - - 从 JWT Token 中解析用户信息,并从数据库验证 - """ - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] - ) - username: str = payload.get("sub") - token_type: str = payload.get("type", "access") - - if username is None: - raise credentials_exception - - if token_type != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token type. Access token required.", - headers={"WWW-Authenticate": "Bearer"}, - ) - - except JWTError: - raise credentials_exception - - # 从数据库获取用户 - user = await user_repo.get_user_by_username(username) - if user is None: - raise credentials_exception - - return user - - -async def get_current_active_user( - current_user: UserInDB = Depends(get_current_user), -) -> UserInDB: - """ - 获取当前活跃用户(必须是激活状态) - """ - if not current_user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" - ) - return current_user - - -async def get_current_superuser( - current_user: UserInDB = Depends(get_current_user), -) -> UserInDB: - """ - 获取当前超级管理员用户 - """ - if not current_user.is_superuser: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not enough privileges. Superuser access required.", - ) - return current_user diff --git a/app/auth/keycloak_dependencies.py b/app/auth/keycloak_dependencies.py index 6b34936..ac43799 100644 --- a/app/auth/keycloak_dependencies.py +++ b/app/auth/keycloak_dependencies.py @@ -8,35 +8,41 @@ from jose import JWTError, jwt from app.core.config import settings oauth2_optional = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/auth/login", auto_error=False + tokenUrl="keycloak", auto_error=False ) # logger = logging.getLogger(__name__) -async def get_current_keycloak_sub( +def _decode_keycloak_token(token: str) -> dict: + if not settings.KEYCLOAK_PUBLIC_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Keycloak public key is not configured", + ) + + key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n") + + return jwt.decode( + token, + key, + algorithms=[settings.KEYCLOAK_ALGORITHM], + audience=settings.KEYCLOAK_AUDIENCE or None, + ) + + +async def get_current_keycloak_payload( token: str | None = Depends(oauth2_optional), -) -> UUID: +) -> dict: if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) - if settings.KEYCLOAK_PUBLIC_KEY: - key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n") - algorithms = [settings.KEYCLOAK_ALGORITHM] - else: - key = settings.SECRET_KEY - algorithms = [settings.ALGORITHM] try: - payload = jwt.decode( - token, - key, - algorithms=algorithms, - audience=settings.KEYCLOAK_AUDIENCE or None, - ) + return _decode_keycloak_token(token) except JWTError as exc: # logger.warning("Keycloak token validation failed: %s", exc) raise HTTPException( @@ -45,6 +51,10 @@ async def get_current_keycloak_sub( headers={"WWW-Authenticate": "Bearer"}, ) from exc + +async def get_current_keycloak_sub( + payload: dict = Depends(get_current_keycloak_payload), +) -> UUID: sub = payload.get("sub") if not sub: raise HTTPException( @@ -64,35 +74,8 @@ async def get_current_keycloak_sub( async def get_current_keycloak_username( - token: str | None = Depends(oauth2_optional), + payload: dict = Depends(get_current_keycloak_payload), ) -> str: - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) - if settings.KEYCLOAK_PUBLIC_KEY: - key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n") - algorithms = [settings.KEYCLOAK_ALGORITHM] - else: - key = settings.SECRET_KEY - algorithms = [settings.ALGORITHM] - - try: - payload = jwt.decode( - token, - key, - algorithms=algorithms, - audience=settings.KEYCLOAK_AUDIENCE or None, - ) - except JWTError as exc: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - headers={"WWW-Authenticate": "Bearer"}, - ) from exc - username = payload.get("preferred_username") or payload.get("username") if not username: raise HTTPException( diff --git a/app/auth/permissions.py b/app/auth/permissions.py deleted file mode 100644 index 0fb8d1c..0000000 --- a/app/auth/permissions.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -权限控制依赖项和装饰器 - -基于角色的访问控制(RBAC) -""" -from typing import Callable -from fastapi import Depends, HTTPException, status -from app.domain.models.role import UserRole -from app.domain.schemas.user import UserInDB -from app.auth.dependencies import get_current_active_user - -def require_role(required_role: UserRole): - """ - 要求特定角色或更高权限 - - 用法: - @router.get("/admin-only") - async def admin_endpoint(user: UserInDB = Depends(require_role(UserRole.ADMIN))): - ... - - Args: - required_role: 需要的最低角色 - - Returns: - 依赖函数 - """ - async def role_checker( - current_user: UserInDB = Depends(get_current_active_user) - ) -> UserInDB: - user_role = UserRole(current_user.role) - - if not user_role.has_permission(required_role): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Insufficient permissions. Required role: {required_role.value}, " - f"Your role: {user_role.value}" - ) - - return current_user - - return role_checker - -# 预定义的权限检查依赖 -require_admin = require_role(UserRole.ADMIN) -require_operator = require_role(UserRole.OPERATOR) -require_user = require_role(UserRole.USER) - -def get_current_admin( - current_user: UserInDB = Depends(require_admin) -) -> UserInDB: - """ - 获取当前管理员用户 - - 等同于 Depends(require_role(UserRole.ADMIN)) - """ - return current_user - -def get_current_operator( - current_user: UserInDB = Depends(require_operator) -) -> UserInDB: - """ - 获取当前操作员用户(或更高权限) - - 等同于 Depends(require_role(UserRole.OPERATOR)) - """ - return current_user - -def check_resource_owner(user_id: int, current_user: UserInDB) -> bool: - """ - 检查是否是资源拥有者或管理员 - - Args: - user_id: 资源拥有者ID - current_user: 当前用户 - - Returns: - 是否有权限 - """ - # 管理员可以访问所有资源 - if UserRole(current_user.role).has_permission(UserRole.ADMIN): - return True - - # 检查是否是资源拥有者 - return current_user.id == user_id - -def require_owner_or_admin(user_id: int): - """ - 要求是资源拥有者或管理员 - - Args: - user_id: 资源拥有者ID - - Returns: - 依赖函数 - """ - async def owner_or_admin_checker( - current_user: UserInDB = Depends(get_current_active_user) - ) -> UserInDB: - if not check_resource_owner(user_id, current_user): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have permission to access this resource" - ) - return current_user - - return owner_or_admin_checker diff --git a/app/core/config.py b/app/core/config.py index 791d470..a4bac3f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -11,14 +11,6 @@ class Settings(BaseSettings): NETWORK_NAME: str = "default_network" - # JWT 配置 - SECRET_KEY: str = ( - "your-secret-key-here-change-in-production-use-openssl-rand-hex-32" - ) - ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - # 数据加密密钥 (使用 Fernet) ENCRYPTION_KEY: str = "" # 必须从环境变量设置 DATABASE_ENCRYPTION_KEY: str = "" # project_databases.dsn_encrypted 专用 @@ -59,7 +51,7 @@ class Settings(BaseSettings): PROJECT_TS_POOL_MIN_SIZE: int = 1 PROJECT_TS_POOL_MAX_SIZE: int = 10 - # Keycloak JWT (optional override) + # Keycloak access token verification KEYCLOAK_PUBLIC_KEY: str = "" KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_AUDIENCE: str = "" diff --git a/app/core/security.py b/app/core/security.py deleted file mode 100644 index a99e69f..0000000 --- a/app/core/security.py +++ /dev/null @@ -1,95 +0,0 @@ -from datetime import datetime, timedelta, timezone -from typing import Optional, Union, Any - -from jose import jwt -from passlib.context import CryptContext -from app.core.config import settings - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def _utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def create_access_token( - subject: Union[str, Any], expires_delta: Optional[timedelta] = None -) -> str: - """ - 创建 JWT Access Token - - Args: - subject: 用户标识(通常是用户名或用户ID) - expires_delta: 过期时间增量 - - Returns: - JWT token 字符串 - """ - if expires_delta: - expire = _utc_now() + expires_delta - else: - expire = _utc_now() + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) - - to_encode = { - "exp": expire, - "sub": str(subject), - "type": "access", - "iat": _utc_now(), - } - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM - ) - return encoded_jwt - - -def create_refresh_token(subject: Union[str, Any]) -> str: - """ - 创建 JWT Refresh Token(长期有效) - - Args: - subject: 用户标识 - - Returns: - JWT refresh token 字符串 - """ - expire = _utc_now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - - to_encode = { - "exp": expire, - "sub": str(subject), - "type": "refresh", - "iat": _utc_now(), - } - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM - ) - return encoded_jwt - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """ - 验证密码 - - Args: - plain_password: 明文密码 - hashed_password: 密码哈希 - - Returns: - 是否匹配 - """ - return pwd_context.verify(plain_password, hashed_password) - - -def get_password_hash(password: str) -> str: - """ - 生成密码哈希 - - Args: - password: 明文密码 - - Returns: - bcrypt 哈希字符串 - """ - return pwd_context.hash(password) diff --git a/app/domain/models/role.py b/app/domain/models/role.py deleted file mode 100644 index 1870bf8..0000000 --- a/app/domain/models/role.py +++ /dev/null @@ -1,36 +0,0 @@ -from enum import Enum - -class UserRole(str, Enum): - """用户角色枚举""" - ADMIN = "ADMIN" # 管理员 - 完全权限 - OPERATOR = "OPERATOR" # 操作员 - 可修改数据 - USER = "USER" # 普通用户 - 读写权限 - VIEWER = "VIEWER" # 观察者 - 仅查询权限 - - def __str__(self): - return self.value - - @classmethod - def get_hierarchy(cls) -> dict: - """ - 获取角色层级(数字越大权限越高) - """ - return { - cls.VIEWER: 1, - cls.USER: 2, - cls.OPERATOR: 3, - cls.ADMIN: 4, - } - - def has_permission(self, required_role: 'UserRole') -> bool: - """ - 检查当前角色是否有足够权限 - - Args: - required_role: 需要的最低角色 - - Returns: - True if has permission - """ - hierarchy = self.get_hierarchy() - return hierarchy[self] >= hierarchy[required_role] diff --git a/app/domain/schemas/user.py b/app/domain/schemas/user.py deleted file mode 100644 index 864035a..0000000 --- a/app/domain/schemas/user.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import datetime -from typing import Optional -from pydantic import BaseModel, EmailStr, Field, ConfigDict -from app.domain.models.role import UserRole - -# ============================================ -# Request Schemas (输入) -# ============================================ - -class UserCreate(BaseModel): - """用户注册""" - username: str = Field(..., min_length=3, max_length=50, - description="用户名,3-50个字符") - email: EmailStr = Field(..., description="邮箱地址") - password: str = Field(..., min_length=6, max_length=100, - description="密码,至少6个字符") - role: UserRole = Field(default=UserRole.USER, description="用户角色") - -class UserLogin(BaseModel): - """用户登录""" - username: str = Field(..., description="用户名或邮箱") - password: str = Field(..., description="密码") - -class UserUpdate(BaseModel): - """用户信息更新""" - email: Optional[EmailStr] = None - password: Optional[str] = Field(None, min_length=6, max_length=100) - role: Optional[UserRole] = None - is_active: Optional[bool] = None - -# ============================================ -# Response Schemas (输出) -# ============================================ - -class UserResponse(BaseModel): - """用户信息响应(不含密码)""" - id: int - username: str - email: str - role: UserRole - is_active: bool - is_superuser: bool - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -class UserInDB(UserResponse): - """数据库中的用户(含密码哈希)""" - hashed_password: str - -# ============================================ -# Token Schemas -# ============================================ - -class Token(BaseModel): - """JWT Token 响应""" - access_token: str - refresh_token: Optional[str] = None - token_type: str = "bearer" - expires_in: int = Field(..., description="过期时间(秒)") - -class TokenPayload(BaseModel): - """JWT Token Payload""" - sub: str = Field(..., description="用户ID或用户名") - exp: Optional[int] = None - iat: Optional[int] = None - type: str = Field(default="access", description="token类型: access 或 refresh") diff --git a/app/infra/audit/middleware.py b/app/infra/audit/middleware.py index d8f1e3e..d0774d4 100644 --- a/app/infra/audit/middleware.py +++ b/app/infra/audit/middleware.py @@ -33,8 +33,6 @@ class AuditMiddleware(BaseHTTPMiddleware): # 需要审计的路径前缀 AUDIT_PATHS = [ - # "/api/v1/auth/", - # "/api/v1/users/", # "/api/v1/projects/", # "/api/v1/networks/", ] @@ -193,20 +191,14 @@ class AuditMiddleware(BaseHTTPMiddleware): return None sub = None try: - key = ( - settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n") - if settings.KEYCLOAK_PUBLIC_KEY - else settings.SECRET_KEY - ) - algorithms = ( - [settings.KEYCLOAK_ALGORITHM] - if settings.KEYCLOAK_PUBLIC_KEY - else [settings.ALGORITHM] - ) + if not settings.KEYCLOAK_PUBLIC_KEY: + return None + + key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n") payload = jwt.decode( token, key, - algorithms=algorithms, + algorithms=[settings.KEYCLOAK_ALGORITHM], audience=settings.KEYCLOAK_AUDIENCE or None, ) sub = payload.get("sub") @@ -221,7 +213,7 @@ class AuditMiddleware(BaseHTTPMiddleware): keycloak_id = UUID(sub) user = await repo.get_user_by_keycloak_id(keycloak_id) except ValueError: - user = await repo.get_user_by_username(sub) + return None if user and user.is_active: return user.id return None diff --git a/app/infra/db/metadb/repositories/user_repository.py b/app/infra/db/metadb/repositories/user_repository.py deleted file mode 100644 index 4d975ec..0000000 --- a/app/infra/db/metadb/repositories/user_repository.py +++ /dev/null @@ -1,235 +0,0 @@ -from typing import Optional, List -from datetime import datetime -from app.infra.db.postgresql.database import Database -from app.domain.schemas.user import UserCreate, UserUpdate, UserInDB -from app.domain.models.role import UserRole -from app.core.security import get_password_hash -import logging - -logger = logging.getLogger(__name__) - -class UserRepository: - """用户数据访问层""" - - def __init__(self, db: Database): - self.db = db - - async def create_user(self, user: UserCreate) -> Optional[UserInDB]: - """ - 创建新用户 - - Args: - user: 用户创建数据 - - Returns: - 创建的用户对象 - """ - hashed_password = get_password_hash(user.password) - - query = """ - INSERT INTO users (username, email, hashed_password, role, is_active, is_superuser) - VALUES (%(username)s, %(email)s, %(hashed_password)s, %(role)s, TRUE, FALSE) - RETURNING id, username, email, hashed_password, role, is_active, is_superuser, - created_at, updated_at - """ - - try: - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, { - 'username': user.username, - 'email': user.email, - 'hashed_password': hashed_password, - 'role': user.role.value - }) - row = await cur.fetchone() - if row: - return UserInDB(**row) - except Exception as e: - logger.error(f"Error creating user: {e}") - raise - - return None - - async def get_user_by_id(self, user_id: int) -> Optional[UserInDB]: - """根据ID获取用户""" - query = """ - SELECT id, username, email, hashed_password, role, is_active, is_superuser, - created_at, updated_at - FROM users - WHERE id = %(user_id)s - """ - - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, {'user_id': user_id}) - row = await cur.fetchone() - if row: - return UserInDB(**row) - - return None - - async def get_user_by_username(self, username: str) -> Optional[UserInDB]: - """根据用户名获取用户""" - query = """ - SELECT id, username, email, hashed_password, role, is_active, is_superuser, - created_at, updated_at - FROM users - WHERE username = %(username)s - """ - - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, {'username': username}) - row = await cur.fetchone() - if row: - return UserInDB(**row) - - return None - - async def get_user_by_email(self, email: str) -> Optional[UserInDB]: - """根据邮箱获取用户""" - query = """ - SELECT id, username, email, hashed_password, role, is_active, is_superuser, - created_at, updated_at - FROM users - WHERE email = %(email)s - """ - - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, {'email': email}) - row = await cur.fetchone() - if row: - return UserInDB(**row) - - return None - - async def get_all_users(self, skip: int = 0, limit: int = 100) -> List[UserInDB]: - """获取所有用户(分页)""" - query = """ - SELECT id, username, email, hashed_password, role, is_active, is_superuser, - created_at, updated_at - FROM users - ORDER BY created_at DESC - LIMIT %(limit)s OFFSET %(skip)s - """ - - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, {'skip': skip, 'limit': limit}) - rows = await cur.fetchall() - return [UserInDB(**row) for row in rows] - - async def update_user(self, user_id: int, user_update: UserUpdate) -> Optional[UserInDB]: - """ - 更新用户信息 - - Args: - user_id: 用户ID - user_update: 更新数据 - - Returns: - 更新后的用户对象 - """ - # 构建动态更新语句 - update_fields = [] - params = {'user_id': user_id} - - if user_update.email is not None: - update_fields.append("email = %(email)s") - params['email'] = user_update.email - - if user_update.password is not None: - update_fields.append("hashed_password = %(hashed_password)s") - params['hashed_password'] = get_password_hash(user_update.password) - - if user_update.role is not None: - update_fields.append("role = %(role)s") - params['role'] = user_update.role.value - - if user_update.is_active is not None: - update_fields.append("is_active = %(is_active)s") - params['is_active'] = user_update.is_active - - if not update_fields: - return await self.get_user_by_id(user_id) - - query = f""" - UPDATE users - SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP - WHERE id = %(user_id)s - RETURNING id, username, email, hashed_password, role, is_active, is_superuser, - created_at, updated_at - """ - - try: - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, params) - row = await cur.fetchone() - if row: - return UserInDB(**row) - except Exception as e: - logger.error(f"Error updating user {user_id}: {e}") - raise - - return None - - async def delete_user(self, user_id: int) -> bool: - """ - 删除用户 - - Args: - user_id: 用户ID - - Returns: - 是否成功删除 - """ - query = "DELETE FROM users WHERE id = %(user_id)s" - - try: - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, {'user_id': user_id}) - return cur.rowcount > 0 - except Exception as e: - logger.error(f"Error deleting user {user_id}: {e}") - return False - - async def user_exists(self, username: str = None, email: str = None) -> bool: - """ - 检查用户是否存在 - - Args: - username: 用户名 - email: 邮箱 - - Returns: - 是否存在 - """ - conditions = [] - params = {} - - if username: - conditions.append("username = %(username)s") - params['username'] = username - - if email: - conditions.append("email = %(email)s") - params['email'] = email - - if not conditions: - return False - - query = f""" - SELECT EXISTS( - SELECT 1 FROM users WHERE {' OR '.join(conditions)} - ) - """ - - async with self.db.get_connection() as conn: - async with conn.cursor() as cur: - await cur.execute(query, params) - result = await cur.fetchone() - return result['exists'] if result else False diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 08a119f..8e16ec2 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -32,7 +32,6 @@ def test_load_auth_context_supports_aliases(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") monkeypatch.setenv("TJWATER_PROJECT_ID", "p1") - monkeypatch.setenv("TJWATER_USER_ID", "u1") monkeypatch.setenv("TJWATER_USERNAME", "tester") monkeypatch.setenv("TJWATER_NETWORK", "net1") @@ -41,7 +40,6 @@ def test_load_auth_context_supports_aliases(monkeypatch): assert auth.server == "http://server" assert auth.access_token == "abc" assert auth.project_id == "p1" - assert auth.user_id == "u1" assert auth.username == "tester" assert auth.network == "net1" @@ -50,7 +48,6 @@ def test_build_runtime_context_uses_default_server(monkeypatch): monkeypatch.delenv("TJWATER_SERVER", raising=False) monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False) monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False) - monkeypatch.delenv("TJWATER_USER_ID", raising=False) monkeypatch.delenv("TJWATER_USERNAME", raising=False) monkeypatch.delenv("TJWATER_NETWORK", raising=False) monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False) diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index a02c4dd..16d7bde 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -46,7 +46,6 @@ class AuthContext: server: str | None = None access_token: str | None = None project_id: str | None = None - user_id: str | None = None username: str | None = None network: str | None = None headers: dict[str, str] = field(default_factory=dict) @@ -98,7 +97,6 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext: "server": os.getenv("TJWATER_SERVER"), "access_token": os.getenv("TJWATER_ACCESS_TOKEN"), "project_id": os.getenv("TJWATER_PROJECT_ID"), - "user_id": os.getenv("TJWATER_USER_ID"), "username": os.getenv("TJWATER_USERNAME"), "network": os.getenv("TJWATER_NETWORK"), "headers": json.loads(extra_headers) if extra_headers else {}, @@ -117,7 +115,6 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext: server=_pick(raw, "server", "base_url"), access_token=_pick(raw, "access_token", "token", "accessToken"), project_id=_pick(raw, "project_id", "projectId", "x_project_id"), - user_id=_pick(raw, "user_id", "userId", "x_user_id"), username=_pick(raw, "username", "preferred_username"), network=_pick(raw, "network", "project_code", "projectCode", "project"), headers={str(key): str(value) for key, value in headers.items()}, @@ -350,8 +347,6 @@ def build_headers( headers["X-Project-Id"] = require_project_id(ctx) elif ctx.auth.project_id: headers["X-Project-Id"] = ctx.auth.project_id - if ctx.auth.user_id: - headers["X-User-Id"] = ctx.auth.user_id return headers diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 1413674..a86dffa 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -306,10 +306,9 @@ app/api/v1/endpoints/snapshots.py app/api/v1/endpoints/cache.py app/api/v1/endpoints/audit.py app/api/v1/endpoints/users.py -app/api/v1/endpoints/user_management.py ``` -这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计、用户管理或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。 +这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。 暂不暴露: @@ -339,10 +338,6 @@ GET /audit/logs/count GET /getuserschema/ GET /getuser/ GET /getallusers/ -PUT /users/{user_id} -DELETE /users/{user_id} -POST /users/{user_id}/activate -POST /users/{user_id}/deactivate ``` ## Help diff --git a/tests/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py new file mode 100644 index 0000000..1acda78 --- /dev/null +++ b/tests/api/test_agent_auth_endpoints.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace +from uuid import uuid4 + +from fastapi import HTTPException, status +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import agent_auth as agent_auth_endpoint +from app.auth.keycloak_dependencies import get_current_keycloak_payload +from app.auth.metadata_dependencies import get_current_metadata_user +from app.auth.project_dependencies import ProjectContext, get_project_context +from tests.conftest import build_test_app + + +def _build_client(*, project_context=None, current_user=None) -> TestClient: + app = build_test_app(agent_auth_endpoint.router, "/api/v1") + if project_context is not None: + app.dependency_overrides[get_project_context] = lambda: project_context + if current_user is not None: + app.dependency_overrides[get_current_metadata_user] = lambda: current_user + app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400} + return TestClient(app) + + +def test_agent_auth_context_returns_metadata_user_and_project_context(): + user_id = uuid4() + keycloak_sub = uuid4() + project_id = uuid4() + client = _build_client( + project_context=ProjectContext( + project_id=project_id, + user_id=user_id, + project_role="editor", + ), + current_user=SimpleNamespace( + id=user_id, + keycloak_id=keycloak_sub, + username="alice", + role="user", + is_superuser=False, + ), + ) + + response = client.get("/api/v1/agent/auth/context") + + assert response.status_code == 200 + assert response.json() == { + "user_id": str(user_id), + "keycloak_sub": str(keycloak_sub), + "username": "alice", + "role": "user", + "is_superuser": False, + "project_id": str(project_id), + "project_role": "editor", + "token_expires_at": "2026-06-11T13:10:00+00:00", + } + + +def test_agent_auth_context_propagates_project_auth_failures(): + def reject_project(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No access to project", + ) + + app = build_test_app(agent_auth_endpoint.router, "/api/v1") + app.dependency_overrides[get_project_context] = reject_project + app.dependency_overrides[get_current_metadata_user] = lambda: SimpleNamespace( + id=uuid4(), + keycloak_id=uuid4(), + username="alice", + role="user", + is_superuser=False, + ) + app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400} + client = TestClient(app) + + response = client.get("/api/v1/agent/auth/context") + + assert response.status_code == 403 + assert response.json()["detail"] == "No access to project" diff --git a/tests/api/test_api_integration.py b/tests/api/test_api_integration.py index a5afbb9..27ec1eb 100755 --- a/tests/api/test_api_integration.py +++ b/tests/api/test_api_integration.py @@ -2,7 +2,7 @@ """ 测试新增 API 集成 -验证新的认证、用户管理和审计日志接口是否正确集成 +验证 Keycloak/metadata 认证和审计日志接口是否正确集成 """ import sys @@ -17,16 +17,15 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../. "module_name, desc", [ ("app.core.encryption", "加密模块"), - ("app.core.security", "安全模块"), ("app.core.audit", "审计模块"), - ("app.domain.models.role", "角色模型"), - ("app.domain.schemas.user", "用户Schema"), ("app.domain.schemas.audit", "审计Schema"), - ("app.auth.permissions", "权限控制"), - ("app.api.v1.endpoints.auth", "认证接口"), - ("app.api.v1.endpoints.user_management", "用户管理接口"), + ("app.auth.keycloak_dependencies", "Keycloak Token 校验"), + ("app.auth.metadata_dependencies", "Metadata 用户解析"), + ("app.auth.project_dependencies", "项目权限控制"), + ("app.api.v1.endpoints.agent_auth", "Agent 认证上下文接口"), + ("app.api.v1.endpoints.meta", "Metadata 接口"), ("app.api.v1.endpoints.audit", "审计日志接口"), - ("app.infra.db.metadb.repositories.user_repository", "用户仓储"), + ("app.infra.db.metadb.repositories.metadata_repository", "Metadata 仓储"), ("app.infra.db.metadb.repositories.audit_repository", "审计仓储"), ("app.infra.audit.middleware", "审计中间件"), ], @@ -49,8 +48,8 @@ def test_router_configuration(): routes = [r.path for r in api_router.routes if hasattr(r, "path")] # 验证基础路径是否存在 - assert any("/auth" in r for r in routes), "缺少认证相关路由 (/auth)" - assert any("/users" in r for r in routes), "缺少用户管理路由 (/users)" + assert any("/agent/auth/context" in r for r in routes), "缺少 Agent 认证上下文路由" + assert any("/meta" in r for r in routes), "缺少 Metadata 路由" assert any("/audit" in r for r in routes), "缺少审计日志路由 (/audit)" except Exception as e: diff --git a/tests/api/test_auth_endpoints.py b/tests/api/test_auth_endpoints.py deleted file mode 100644 index 144e442..0000000 --- a/tests/api/test_auth_endpoints.py +++ /dev/null @@ -1,139 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from fastapi.testclient import TestClient - -from app.api.v1.endpoints import auth as auth_endpoint -from app.auth.dependencies import get_current_active_user, get_user_repository -from app.core.security import create_access_token, create_refresh_token, get_password_hash -from tests.conftest import build_test_app, make_user - - -def _build_client(repo, current_user=None) -> TestClient: - app = build_test_app(auth_endpoint.router, "/api/v1/auth") - app.dependency_overrides[get_user_repository] = lambda: repo - if current_user is not None: - app.dependency_overrides[get_current_active_user] = lambda: current_user - return TestClient(app) - - -def test_register_success(): - repo = SimpleNamespace( - user_exists=AsyncMock(side_effect=[False, False]), - create_user=AsyncMock(return_value=make_user()), - ) - client = _build_client(repo) - - response = client.post( - "/api/v1/auth/register", - json={ - "username": "tester", - "email": "tester@example.com", - "password": "secret123", - }, - ) - - assert response.status_code == 201 - assert response.json()["username"] == "tester" - - -def test_register_rejects_duplicate_username(): - repo = SimpleNamespace( - user_exists=AsyncMock(side_effect=[True]), - create_user=AsyncMock(), - ) - client = _build_client(repo) - - response = client.post( - "/api/v1/auth/register", - json={ - "username": "tester", - "email": "tester@example.com", - "password": "secret123", - }, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Username already registered" - repo.create_user.assert_not_awaited() - - -def test_login_supports_email_lookup(): - hashed_password = get_password_hash("secret123") - repo = SimpleNamespace( - get_user_by_username=AsyncMock(return_value=None), - get_user_by_email=AsyncMock( - return_value=make_user( - email="tester@example.com", - hashed_password=hashed_password, - ) - ), - ) - client = _build_client(repo) - - response = client.post( - "/api/v1/auth/login", - data={"username": "tester@example.com", "password": "secret123"}, - ) - - assert response.status_code == 200 - assert response.json()["token_type"] == "bearer" - repo.get_user_by_email.assert_awaited_once_with("tester@example.com") - - -def test_login_simple_uses_query_params(): - hashed_password = get_password_hash("secret123") - repo = SimpleNamespace( - get_user_by_username=AsyncMock( - return_value=make_user(hashed_password=hashed_password) - ), - get_user_by_email=AsyncMock(), - ) - client = _build_client(repo) - - response = client.post( - "/api/v1/auth/login/simple", - params={"username": "tester", "password": "secret123"}, - ) - - assert response.status_code == 200 - assert response.json()["token_type"] == "bearer" - - -def test_me_returns_current_user_info(): - client = _build_client(SimpleNamespace(), current_user=make_user(username="alice")) - - response = client.get("/api/v1/auth/me") - - assert response.status_code == 200 - assert response.json()["username"] == "alice" - - -def test_refresh_rejects_access_token(): - repo = SimpleNamespace(get_user_by_username=AsyncMock()) - client = _build_client(repo) - - response = client.post( - "/api/v1/auth/refresh", - params={"refresh_token": create_access_token("tester")}, - ) - - assert response.status_code == 401 - - -def test_refresh_success_returns_new_access_token(): - repo = SimpleNamespace( - get_user_by_username=AsyncMock(return_value=make_user()), - ) - client = _build_client(repo) - refresh_token = create_refresh_token("tester") - - response = client.post( - "/api/v1/auth/refresh", - params={"refresh_token": refresh_token}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["refresh_token"] == refresh_token - assert payload["token_type"] == "bearer" diff --git a/tests/api/test_user_management_endpoints.py b/tests/api/test_user_management_endpoints.py deleted file mode 100644 index 8991d5a..0000000 --- a/tests/api/test_user_management_endpoints.py +++ /dev/null @@ -1,95 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from fastapi.testclient import TestClient - -from app.api.v1.endpoints import user_management as user_management_endpoint -from app.auth.dependencies import get_current_active_user, get_user_repository -from app.auth.permissions import get_current_admin -from app.domain.models.role import UserRole -from tests.conftest import build_test_app, make_user - - -def _build_client(repo, *, current_user=None, admin_user=None) -> TestClient: - app = build_test_app(user_management_endpoint.router, "/users") - app.dependency_overrides[get_user_repository] = lambda: repo - if current_user is not None: - app.dependency_overrides[get_current_active_user] = lambda: current_user - if admin_user is not None: - app.dependency_overrides[get_current_admin] = lambda: admin_user - return TestClient(app) - - -def test_list_users_requires_admin_role(): - repo = SimpleNamespace( - get_all_users=AsyncMock( - return_value=[ - make_user(id=1, username="admin", role=UserRole.ADMIN), - make_user(id=2, username="user2"), - ] - ) - ) - client = _build_client( - repo, - current_user=make_user(id=1, role=UserRole.ADMIN), - ) - - response = client.get("/users/", params={"skip": 5, "limit": 2}) - - assert response.status_code == 200 - assert len(response.json()) == 2 - repo.get_all_users.assert_awaited_once_with(skip=5, limit=2) - - -def test_get_user_rejects_non_owner_non_admin(): - repo = SimpleNamespace(get_user_by_id=AsyncMock()) - client = _build_client(repo, current_user=make_user(id=2, role=UserRole.USER)) - - response = client.get("/users/3") - - assert response.status_code == 403 - assert response.json()["detail"] == "You don't have permission to view this user" - repo.get_user_by_id.assert_not_awaited() - - -def test_update_user_blocks_role_change_for_non_admin(): - repo = SimpleNamespace( - get_user_by_id=AsyncMock(return_value=make_user(id=1)), - update_user=AsyncMock(), - ) - client = _build_client(repo, current_user=make_user(id=1, role=UserRole.USER)) - - response = client.put("/users/1", json={"role": "ADMIN"}) - - assert response.status_code == 403 - assert response.json()["detail"] == "Only admins can change user roles" - repo.update_user.assert_not_awaited() - - -def test_delete_user_blocks_self_delete_for_admin(): - admin_user = make_user(id=1, role=UserRole.ADMIN, is_superuser=True) - repo = SimpleNamespace(delete_user=AsyncMock()) - client = _build_client(repo, admin_user=admin_user) - - response = client.delete("/users/1") - - assert response.status_code == 400 - assert response.json()["detail"] == "You cannot delete your own account" - repo.delete_user.assert_not_awaited() - - -def test_activate_user_updates_active_flag(): - repo = SimpleNamespace( - update_user=AsyncMock(return_value=make_user(id=2, is_active=True)), - ) - client = _build_client( - repo, - admin_user=make_user(id=1, role=UserRole.ADMIN, is_superuser=True), - ) - - response = client.post("/users/2/activate") - - assert response.status_code == 200 - assert response.json()["is_active"] is True - user_update = repo.update_user.await_args.args[1] - assert user_update.is_active is True diff --git a/tests/auth/test_security.py b/tests/auth/test_security.py deleted file mode 100644 index 8953108..0000000 --- a/tests/auth/test_security.py +++ /dev/null @@ -1,36 +0,0 @@ -from jose import jwt - -from app.core.config import settings -from app.core.security import ( - create_access_token, - create_refresh_token, - get_password_hash, - verify_password, -) - - -def test_password_hash_roundtrip(): - hashed = get_password_hash("secret123") - assert hashed != "secret123" - assert verify_password("secret123", hashed) is True - assert verify_password("wrong", hashed) is False - - -def test_create_access_token_sets_access_type(): - token = create_access_token("alice") - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - - assert payload["sub"] == "alice" - assert payload["type"] == "access" - assert "exp" in payload - assert "iat" in payload - - -def test_create_refresh_token_sets_refresh_type(): - token = create_refresh_token("alice") - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - - assert payload["sub"] == "alice" - assert payload["type"] == "refresh" - assert "exp" in payload - assert "iat" in payload diff --git a/tests/conftest.py b/tests/conftest.py index d528272..812baa9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -159,25 +159,6 @@ class FakeAsyncSession: self.refreshed.append(obj) -def make_user(**overrides): - from app.domain.models.role import UserRole - from app.domain.schemas.user import UserInDB - - data = { - "id": 1, - "username": "tester", - "email": "tester@example.com", - "hashed_password": "hashed-password", - "role": UserRole.USER, - "is_active": True, - "is_superuser": False, - "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc), - "updated_at": datetime(2025, 1, 1, tzinfo=timezone.utc), - } - data.update(overrides) - return UserInDB(**data) - - def make_audit_log(**overrides): data = { "id": uuid4(), diff --git a/tests/unit/test_audit_repository.py b/tests/unit/test_audit_repository.py index 01a16bb..8397270 100644 --- a/tests/unit/test_audit_repository.py +++ b/tests/unit/test_audit_repository.py @@ -22,14 +22,14 @@ def test_create_log_adds_commits_and_refreshes(monkeypatch): result = asyncio.run( repo.create_log( - action="LOGIN", + action="CREATE_PROJECT", request_method="POST", - request_path="/auth/login", + request_path="/api/v1/projects", response_status=200, ) ) - assert result.action == "LOGIN" + assert result.action == "CREATE_PROJECT" assert result.request_method == "POST" assert session.commit_count == 1 assert len(session.added) == 1 diff --git a/tests/unit/test_auth_dependencies.py b/tests/unit/test_auth_dependencies.py deleted file mode 100644 index 0c556a7..0000000 --- a/tests/unit/test_auth_dependencies.py +++ /dev/null @@ -1,97 +0,0 @@ -import asyncio -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from fastapi import HTTPException - -from app.auth import dependencies -from app.core.security import create_access_token, create_refresh_token -from tests.conftest import make_user - - -def test_get_db_returns_app_state_db(): - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(db="db-instance"))) - - result = asyncio.run(dependencies.get_db(request)) - - assert result == "db-instance" - - -def test_get_db_raises_when_database_missing(): - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(dependencies.get_db(request)) - - assert exc_info.value.status_code == 503 - assert exc_info.value.detail == "Database not initialized" - - -def test_get_current_user_accepts_valid_access_token(): - repo = SimpleNamespace(get_user_by_username=AsyncMock(return_value=make_user())) - - result = asyncio.run( - dependencies.get_current_user( - token=create_access_token("tester"), - user_repo=repo, - ) - ) - - assert result.username == "tester" - repo.get_user_by_username.assert_awaited_once_with("tester") - - -def test_get_current_user_rejects_refresh_token(): - repo = SimpleNamespace(get_user_by_username=AsyncMock()) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run( - dependencies.get_current_user( - token=create_refresh_token("tester"), - user_repo=repo, - ) - ) - - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "Invalid token type. Access token required." - repo.get_user_by_username.assert_not_awaited() - - -def test_get_current_user_rejects_missing_user(): - repo = SimpleNamespace(get_user_by_username=AsyncMock(return_value=None)) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run( - dependencies.get_current_user( - token=create_access_token("ghost"), - user_repo=repo, - ) - ) - - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "Could not validate credentials" - - -def test_get_current_active_user_rejects_inactive_user(): - with pytest.raises(HTTPException) as exc_info: - asyncio.run( - dependencies.get_current_active_user( - current_user=make_user(is_active=False), - ) - ) - - assert exc_info.value.status_code == 403 - assert exc_info.value.detail == "Inactive user" - - -def test_get_current_superuser_rejects_non_superuser(): - with pytest.raises(HTTPException) as exc_info: - asyncio.run( - dependencies.get_current_superuser( - current_user=make_user(is_superuser=False), - ) - ) - - assert exc_info.value.status_code == 403 - assert exc_info.value.detail == "Not enough privileges. Superuser access required." diff --git a/tests/unit/test_permissions.py b/tests/unit/test_permissions.py deleted file mode 100644 index 3dcd86a..0000000 --- a/tests/unit/test_permissions.py +++ /dev/null @@ -1,56 +0,0 @@ -import asyncio -import pytest -from fastapi import HTTPException - -from app.auth import permissions -from app.domain.models.role import UserRole -from tests.conftest import make_user - - -def test_require_role_allows_higher_privilege_user(): - checker = permissions.require_role(UserRole.OPERATOR) - - result = asyncio.run(checker(current_user=make_user(role=UserRole.ADMIN))) - - assert result.role == UserRole.ADMIN - - -def test_require_role_rejects_insufficient_role(): - checker = permissions.require_role(UserRole.ADMIN) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(checker(current_user=make_user(role=UserRole.USER))) - - assert exc_info.value.status_code == 403 - assert "Required role: ADMIN" in exc_info.value.detail - - -def test_check_resource_owner_allows_admin(): - assert permissions.check_resource_owner( - 99, - make_user(id=1, role=UserRole.ADMIN), - ) is True - - -def test_check_resource_owner_allows_owner(): - assert permissions.check_resource_owner( - 7, - make_user(id=7, role=UserRole.USER), - ) is True - - -def test_check_resource_owner_rejects_other_user(): - assert permissions.check_resource_owner( - 7, - make_user(id=8, role=UserRole.USER), - ) is False - - -def test_require_owner_or_admin_rejects_other_user(): - checker = permissions.require_owner_or_admin(7) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(checker(current_user=make_user(id=8, role=UserRole.USER))) - - assert exc_info.value.status_code == 403 - assert exc_info.value.detail == "You don't have permission to access this resource" diff --git a/tests/unit/test_user_repository.py b/tests/unit/test_user_repository.py deleted file mode 100644 index 7d52ad8..0000000 --- a/tests/unit/test_user_repository.py +++ /dev/null @@ -1,124 +0,0 @@ -import asyncio -from unittest.mock import AsyncMock - -import pytest - -from app.domain.models.role import UserRole -from app.domain.schemas.user import UserCreate, UserUpdate -from app.infra.db.metadb.repositories.user_repository import UserRepository -from tests.conftest import FakeCursor, FakeDB - - -def _user_row(**overrides): - base = { - "id": 1, - "username": "tester", - "email": "tester@example.com", - "hashed_password": "hashed-password", - "role": "USER", - "is_active": True, - "is_superuser": False, - "created_at": "2025-01-01T00:00:00+00:00", - "updated_at": "2025-01-01T00:00:00+00:00", - } - base.update(overrides) - return base - - -def test_create_user_hashes_password_and_returns_model(monkeypatch): - cursor = FakeCursor(fetchone_results=[_user_row()]) - repo = UserRepository(FakeDB(cursor)) - monkeypatch.setattr( - "app.infra.db.metadb.repositories.user_repository.get_password_hash", - lambda password: f"hashed::{password}", - ) - - result = asyncio.run( - repo.create_user( - UserCreate( - username="tester", - email="tester@example.com", - password="secret123", - ) - ) - ) - - assert result is not None - assert result.username == "tester" - assert cursor.executed[0][1]["hashed_password"] == "hashed::secret123" - - -def test_update_user_without_fields_returns_existing_user(monkeypatch): - repo = UserRepository(FakeDB(FakeCursor())) - existing_user = AsyncMock(return_value="existing") - monkeypatch.setattr(repo, "get_user_by_id", existing_user) - - result = asyncio.run(repo.update_user(1, UserUpdate())) - - assert result == "existing" - existing_user.assert_awaited_once_with(1) - - -def test_update_user_builds_dynamic_query(monkeypatch): - cursor = FakeCursor(fetchone_results=[_user_row(role="ADMIN", email="new@example.com")]) - repo = UserRepository(FakeDB(cursor)) - monkeypatch.setattr( - "app.infra.db.metadb.repositories.user_repository.get_password_hash", - lambda password: f"hashed::{password}", - ) - - result = asyncio.run( - repo.update_user( - 1, - UserUpdate( - email="new@example.com", - password="new-secret", - role=UserRole.ADMIN, - is_active=False, - ), - ), - ) - - assert result is not None - query, params = cursor.executed[0] - assert "email = %(email)s" in query - assert "hashed_password = %(hashed_password)s" in query - assert "role = %(role)s" in query - assert "is_active = %(is_active)s" in query - assert params["hashed_password"] == "hashed::new-secret" - assert params["role"] == "ADMIN" - assert params["is_active"] is False - - -def test_delete_user_returns_false_when_execute_raises(): - cursor = FakeCursor() - cursor.execute = AsyncMock(side_effect=RuntimeError("boom")) - repo = UserRepository(FakeDB(cursor)) - - result = asyncio.run(repo.delete_user(1)) - - assert result is False - - -def test_user_exists_short_circuits_without_filters(): - cursor = FakeCursor() - repo = UserRepository(FakeDB(cursor)) - - result = asyncio.run(repo.user_exists()) - - assert result is False - assert cursor.executed == [] - - -def test_user_exists_checks_username_or_email(): - cursor = FakeCursor(fetchone_results=[{"exists": True}]) - repo = UserRepository(FakeDB(cursor)) - - result = asyncio.run( - repo.user_exists(username="tester", email="tester@example.com") - ) - - assert result is True - query, params = cursor.executed[0] - assert "username = %(username)s OR email = %(email)s" in query - assert params == {"username": "tester", "email": "tester@example.com"} From a6e7a2e75ca28d5bf7486292b9fb31186042f5e5 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 12 Jun 2026 15:08:37 +0800 Subject: [PATCH 48/93] feat(admin): add project metadata config --- .env.example | 8 +- AUTHENTICATION_AND_USER_MANAGEMENT.md | 95 +++ app/api/v1/endpoints/admin_metadata.py | 804 ++++++++++++++++++ app/api/v1/router.py | 4 + app/auth/metadata_dependencies.py | 50 +- app/domain/schemas/admin_metadata.py | 156 ++++ app/infra/db/dynamic_manager.py | 4 +- .../repositories/metadata_repository.py | 357 +++++++- .../sql/004_metadata_auth_management.sql | 62 ++ .../005_metadata_project_configuration.sql | 78 ++ scripts/migrate_local_users_to_metadata.py | 63 ++ tests/api/test_admin_metadata_endpoints.py | 646 ++++++++++++++ tests/auth/test_metadata_dependencies.py | 83 ++ tests/unit/test_dynamic_manager.py | 12 + .../test_metadata_repository_dsn_decrypt.py | 48 ++ 15 files changed, 2460 insertions(+), 10 deletions(-) create mode 100644 AUTHENTICATION_AND_USER_MANAGEMENT.md create mode 100644 app/api/v1/endpoints/admin_metadata.py create mode 100644 app/domain/schemas/admin_metadata.py create mode 100644 resources/sql/004_metadata_auth_management.sql create mode 100644 resources/sql/005_metadata_project_configuration.sql create mode 100644 scripts/migrate_local_users_to_metadata.py create mode 100644 tests/api/test_admin_metadata_endpoints.py create mode 100644 tests/auth/test_metadata_dependencies.py create mode 100644 tests/unit/test_dynamic_manager.py diff --git a/.env.example b/.env.example index 9b3b90c..6822043 100644 --- a/.env.example +++ b/.env.example @@ -11,10 +11,12 @@ NETWORK_NAME="tjwater" # 生成方式: openssl rand -hex 32 SECRET_KEY=your-secret-key-here-change-in-production-use-openssl-rand-hex-32 -# 数据加密密钥 - 用于敏感数据加密 +# 数据加密密钥 - Fernet 格式,生产环境必须替换为独立密钥 # 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -ENCRYPTION_KEY= -DATABASE_ENCRYPTION_KEY="rJC2VqLg4KrlSq+DGJcYm869q4v5KB2dFAeuQTe0I50=" +# ENCRYPTION_KEY 用于 GeoServer 管理密码等通用敏感配置 +ENCRYPTION_KEY="replace-with-generated-fernet-key" +# DATABASE_ENCRYPTION_KEY 专用于 project_databases.dsn_encrypted +DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key" # ============================================ # 数据库配置 (PostgreSQL) diff --git a/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md new file mode 100644 index 0000000..b4d01a3 --- /dev/null +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -0,0 +1,95 @@ +# TJWater Authentication and Metadata Management + +## Ownership + +Keycloak owns login identity, credentials, token issuance, and token expiry. +TJWater metadata stores only business snapshots and authorization data: + +- `users.keycloak_id` is the stable identity binding. +- `users.username`, `users.email`, and `users.last_login_at` are Keycloak claim caches. +- `users.role`, `users.is_active`, and `users.is_superuser` control TJWater system access. +- `user_project_membership.project_role` controls project access. + +The backend does not accept passwords, does not issue local JWTs, and does not +trust frontend-supplied user IDs. + +## Login Snapshot Refresh + +Every authenticated metadata-user resolution validates the Keycloak access token +and reads `sub`, `preferred_username` or `username`, and `email` claims. The +backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users, +then refreshes `username`, `email`, and `last_login_at`. + +This keeps local display data current without changing the identity binding. +There is no Keycloak webhook requirement; second-level user or permission sync is +out of scope unless explicitly requested later. + +## Admin APIs + +All admin APIs require metadata admin access: `users.is_superuser = true` or +`users.role = 'admin'`. + +User and membership management: + +- `GET /api/v1/admin/me` +- `POST /api/v1/admin/users/sync` +- `POST /api/v1/admin/users/sync/batch` +- `GET /api/v1/admin/users` +- `GET /api/v1/admin/users/{user_id}` +- `PATCH /api/v1/admin/users/{user_id}` +- `GET /api/v1/admin/projects/{project_id}/members` +- `POST /api/v1/admin/projects/{project_id}/members` +- `PATCH /api/v1/admin/projects/{project_id}/members/{user_id}` +- `DELETE /api/v1/admin/projects/{project_id}/members/{user_id}` + +Project configuration: + +- `GET /api/v1/admin/projects` +- `POST /api/v1/admin/projects` +- `PATCH /api/v1/admin/projects/{project_id}` +- `GET /api/v1/admin/projects/{project_id}/databases` +- `PUT /api/v1/admin/projects/{project_id}/databases` +- `DELETE /api/v1/admin/projects/{project_id}/databases/{db_role}` +- `POST /api/v1/admin/projects/{project_id}/databases/{db_role}/health` +- `GET /api/v1/admin/projects/{project_id}/geoserver` +- `PUT /api/v1/admin/projects/{project_id}/geoserver` + +## Secret Handling + +Admins submit plaintext DSNs and GeoServer passwords only through HTTPS admin +APIs. Operators should not write encrypted columns manually. + +- `project_databases.dsn_encrypted` is encrypted with `DATABASE_ENCRYPTION_KEY`. +- `project_geoserver_configs.gs_admin_password_encrypted` is encrypted with + `ENCRYPTION_KEY`. +- Admin responses return only `has_dsn` or `has_password`. +- Audit logs record whether a secret was updated, but never store plaintext DSNs + or passwords. + +Generate both keys with: + +```bash +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Keep keys stable for the lifetime of encrypted metadata. Rotating a key requires +decrypting with the old key and re-encrypting with the new key. + +## Metadata Schema Patches + +Apply metadata patches in order: + +1. `resources/sql/004_metadata_auth_management.sql` +2. `resources/sql/005_metadata_project_configuration.sql` + +`004` creates Keycloak-backed metadata users and project memberships. `005` +creates project, project database routing, and GeoServer configuration tables +with uniqueness, role/type, and pool-size constraints. + +## Frontend System Management + +`/system-admin` is shown only after `GET /api/v1/admin/me` confirms metadata +admin access. The page lets admins maintain metadata users, project members, +projects, project database routing for `biz_data` and `iot_data`, connection +health checks, and GeoServer config. This replaces direct SQL editing for normal +project onboarding. diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py new file mode 100644 index 0000000..7648a25 --- /dev/null +++ b/app/api/v1/endpoints/admin_metadata.py @@ -0,0 +1,804 @@ +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status +from sqlalchemy import text +from sqlalchemy.engine.url import make_url +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.ext.asyncio import create_async_engine + +from app.auth.metadata_dependencies import ( + get_current_metadata_admin, + get_metadata_repository, +) +from app.core.audit import AuditAction, log_audit_event +from app.domain.schemas.admin_metadata import ( + AdminProjectCreateRequest, + AdminProjectResponse, + AdminProjectUpdateRequest, + MetadataUsersBatchSyncRequest, + MetadataUserResponse, + MetadataUserSyncRequest, + MetadataUserSyncResult, + MetadataUserUpdateRequest, + ProjectDatabaseHealthResponse, + ProjectDatabaseHealthRequest, + ProjectDatabaseResponse, + ProjectDatabaseUpsertRequest, + ProjectDbRole, + ProjectGeoServerConfigResponse, + ProjectGeoServerConfigUpsertRequest, + ProjectMemberCreateRequest, + ProjectMemberResponse, + ProjectMemberUpdateRequest, +) +from app.infra.db.metadb import models +from app.infra.db.metadb.repositories.metadata_repository import ( + MetadataRepository, + ProjectDbRouting, +) + +router = APIRouter() + + +def _project_response(project: models.Project) -> AdminProjectResponse: + return AdminProjectResponse( + project_id=project.id, + name=project.name, + code=project.code, + description=project.description, + gs_workspace=project.gs_workspace, + map_extent=project.map_extent, + status=project.status, + created_at=project.created_at, + updated_at=project.updated_at, + ) + + +def _project_database_response( + record: models.ProjectDatabase, +) -> ProjectDatabaseResponse: + return ProjectDatabaseResponse( + id=record.id, + project_id=record.project_id, + db_role=record.db_role, + db_type=record.db_type, + pool_min_size=record.pool_min_size, + pool_max_size=record.pool_max_size, + has_dsn=bool(record.dsn_encrypted), + ) + + +def _geoserver_config_response( + record: models.ProjectGeoServerConfig, +) -> ProjectGeoServerConfigResponse: + return ProjectGeoServerConfigResponse( + id=record.id, + project_id=record.project_id, + gs_base_url=record.gs_base_url, + gs_admin_user=record.gs_admin_user, + gs_datastore_name=record.gs_datastore_name, + default_extent=record.default_extent, + srid=record.srid, + configured=True, + has_password=bool(record.gs_admin_password_encrypted), + updated_at=record.updated_at, + ) + + +def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict: + return { + "db_role": payload.db_role, + "db_type": _db_type_for_role(payload.db_role), + "pool_min_size": payload.pool_min_size, + "pool_max_size": payload.pool_max_size, + "dsn_updated": payload.dsn is not None, + } + + +def _geoserver_audit_payload( + payload: ProjectGeoServerConfigUpsertRequest, +) -> dict: + return { + "gs_base_url": payload.gs_base_url, + "gs_admin_user": payload.gs_admin_user, + "gs_datastore_name": payload.gs_datastore_name, + "default_extent": payload.default_extent, + "srid": payload.srid, + "password_updated": "gs_admin_password" in payload.model_fields_set, + } + + +def _to_async_sqlalchemy_url(dsn: str) -> str: + parsed = make_url(dsn) + if parsed.drivername in {"postgresql", "postgres"}: + parsed = parsed.set(drivername="postgresql+psycopg") + return parsed.render_as_string(hide_password=False) + + +def _db_type_for_role(db_role: str) -> str: + if db_role == "iot_data": + return "timescaledb" + return "postgresql" + + +def _status_for_config_value_error(exc: ValueError) -> int: + if "ENCRYPTION_KEY" in str(exc): + return status.HTTP_503_SERVICE_UNAVAILABLE + return status.HTTP_400_BAD_REQUEST + + +async def _check_database_connection(routing: ProjectDbRouting) -> None: + engine = create_async_engine( + _to_async_sqlalchemy_url(routing.dsn), + pool_size=1, + max_overflow=0, + pool_pre_ping=True, + ) + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + finally: + await engine.dispose() + + +def _database_health_error_detail(exc: Exception) -> str: + message = str(exc) + lower_message = message.lower() + if "password authentication failed" in lower_message: + return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。" + if "connection refused" in lower_message: + return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。" + if "timeout" in lower_message or "timed out" in lower_message: + return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。" + if "could not translate host name" in lower_message or "name or service not known" in lower_message: + return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。" + first_line = message.splitlines()[0] if message else exc.__class__.__name__ + return f"连通性测试失败:{first_line}" + + +async def _upsert_and_audit_metadata_user( + payload: MetadataUserSyncRequest, + *, + current_user, + metadata_repo: MetadataRepository, + response_status: int, +) -> MetadataUserResponse: + user = await metadata_repo.upsert_user_from_keycloak( + keycloak_id=payload.keycloak_id, + username=payload.username, + email=str(payload.email), + role=payload.role, + is_active=payload.is_active, + ) + await log_audit_event( + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_type="metadata_user", + resource_id=str(user.id), + request_data=payload.model_dump(mode="json"), + response_status=response_status, + session=metadata_repo.session, + ) + return MetadataUserResponse.model_validate(user) + + +@router.get("/me", response_model=MetadataUserResponse) +async def get_metadata_admin_me( + current_user=Depends(get_current_metadata_admin), +) -> MetadataUserResponse: + return MetadataUserResponse.model_validate(current_user) + + +@router.post("/users/sync", response_model=MetadataUserResponse) +async def sync_metadata_user( + payload: MetadataUserSyncRequest, + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> MetadataUserResponse: + try: + return await _upsert_and_audit_metadata_user( + payload, + current_user=current_user, + metadata_repo=metadata_repo, + response_status=status.HTTP_200_OK, + ) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User keycloak_id, username, or email conflicts with an existing user", + ) from exc + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Metadata database error: {exc}", + ) from exc + + + +@router.post("/users/sync/batch", response_model=List[MetadataUserSyncResult]) +async def sync_metadata_users_batch( + payload: MetadataUsersBatchSyncRequest, + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> List[MetadataUserSyncResult]: + results: list[MetadataUserSyncResult] = [] + for item in payload.users: + try: + user = await _upsert_and_audit_metadata_user( + item, + current_user=current_user, + metadata_repo=metadata_repo, + response_status=status.HTTP_200_OK, + ) + except IntegrityError as exc: + results.append( + MetadataUserSyncResult( + keycloak_id=item.keycloak_id, + success=False, + error="User keycloak_id, username, or email conflicts with an existing user", + ) + ) + await metadata_repo.session.rollback() + except SQLAlchemyError as exc: + results.append( + MetadataUserSyncResult( + keycloak_id=item.keycloak_id, + success=False, + error=f"Metadata database error: {exc}", + ) + ) + await metadata_repo.session.rollback() + else: + results.append( + MetadataUserSyncResult( + keycloak_id=item.keycloak_id, + success=True, + user=user, + ) + ) + return results + + +@router.get("/users", response_model=List[MetadataUserResponse]) +async def list_metadata_users( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> List[MetadataUserResponse]: + users = await metadata_repo.list_users(skip=skip, limit=limit) + return [MetadataUserResponse.model_validate(user) for user in users] + + +@router.get("/projects", response_model=List[AdminProjectResponse]) +async def list_admin_projects( + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> List[AdminProjectResponse]: + projects = await metadata_repo.list_project_records() + return [_project_response(project) for project in projects] + + +@router.post( + "/projects", + response_model=AdminProjectResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_admin_project( + payload: AdminProjectCreateRequest, + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> AdminProjectResponse: + try: + project = await metadata_repo.create_project( + name=payload.name, + code=payload.code, + description=payload.description, + gs_workspace=payload.gs_workspace, + map_extent=payload.map_extent, + status=payload.status, + ) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project code or GeoServer workspace conflicts with an existing project", + ) from exc + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Metadata database error: {exc}", + ) from exc + + await log_audit_event( + action=AuditAction.CREATE, + user_id=current_user.id, + project_id=project.id, + resource_type="project", + resource_id=str(project.id), + request_data=payload.model_dump(mode="json"), + response_status=status.HTTP_201_CREATED, + session=metadata_repo.session, + ) + return _project_response(project) + + +@router.patch( + "/projects/{project_id}", + response_model=AdminProjectResponse, +) +async def update_admin_project( + payload: AdminProjectUpdateRequest, + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> AdminProjectResponse: + updates = payload.model_dump(mode="json", exclude_unset=True) + try: + project = await metadata_repo.update_project(project_id, updates=updates) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project code or GeoServer workspace conflicts with an existing project", + ) from exc + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Metadata database error: {exc}", + ) from exc + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + await log_audit_event( + action=AuditAction.UPDATE, + user_id=current_user.id, + project_id=project.id, + resource_type="project", + resource_id=str(project.id), + request_data=updates, + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + return _project_response(project) + + +@router.get( + "/projects/{project_id}/databases", + response_model=List[ProjectDatabaseResponse], +) +async def list_project_databases( + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> List[ProjectDatabaseResponse]: + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + records = await metadata_repo.list_project_databases(project_id) + return [_project_database_response(record) for record in records] + + +@router.put( + "/projects/{project_id}/databases", + response_model=ProjectDatabaseResponse, +) +async def upsert_project_database( + payload: ProjectDatabaseUpsertRequest, + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectDatabaseResponse: + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + try: + routing = ( + ProjectDbRouting( + project_id=project_id, + db_role=payload.db_role, + db_type=_db_type_for_role(payload.db_role), + dsn=payload.dsn, + pool_min_size=payload.pool_min_size, + pool_max_size=payload.pool_max_size, + ) + if payload.dsn + else await metadata_repo.get_project_db_routing(project_id, payload.db_role) + ) + if routing is None: + raise ValueError("dsn is required when creating project database config") + await _check_database_connection(routing) + except ValueError as exc: + raise HTTPException( + status_code=_status_for_config_value_error(exc), + detail=str(exc), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=_database_health_error_detail(exc), + ) from exc + + try: + record = await metadata_repo.upsert_project_database_config( + project_id, + db_role=payload.db_role, + db_type=_db_type_for_role(payload.db_role), + dsn=payload.dsn, + pool_min_size=payload.pool_min_size, + pool_max_size=payload.pool_max_size, + ) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project database role conflicts with an existing config", + ) from exc + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Metadata database error: {exc}", + ) from exc + + await log_audit_event( + action=AuditAction.CONFIG_CHANGE, + user_id=current_user.id, + project_id=project_id, + resource_type="project_database", + resource_id=payload.db_role, + request_data=_database_audit_payload(payload), + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + return _project_database_response(record) + + +@router.delete( + "/projects/{project_id}/databases/{db_role}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_project_database( + project_id: UUID = Path(...), + db_role: ProjectDbRole = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> None: + removed = await metadata_repo.delete_project_database_config(project_id, db_role) + if not removed: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project database config not found", + ) + await log_audit_event( + action=AuditAction.CONFIG_CHANGE, + user_id=current_user.id, + project_id=project_id, + resource_type="project_database", + resource_id=db_role, + request_data={"deleted": True}, + response_status=status.HTTP_204_NO_CONTENT, + session=metadata_repo.session, + ) + + +@router.post( + "/projects/{project_id}/databases/{db_role}/health", + response_model=ProjectDatabaseHealthResponse, +) +async def check_project_database_health( + response: Response, + project_id: UUID = Path(...), + db_role: ProjectDbRole = Path(...), + payload: ProjectDatabaseHealthRequest | None = None, + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectDatabaseHealthResponse: + dsn_to_test = payload.dsn if payload and payload.dsn else None + if dsn_to_test: + routing = ProjectDbRouting( + project_id=project_id, + db_role=db_role, + db_type=_db_type_for_role(db_role), + dsn=dsn_to_test, + pool_min_size=1, + pool_max_size=1, + ) + else: + try: + routing = await metadata_repo.get_project_db_routing(project_id, db_role) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Project database routing DSN is invalid: {exc}", + ) from exc + if routing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project database config not found", + ) + + try: + await _check_database_connection(routing) + except Exception as exc: # health endpoint should return diagnostic status + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return ProjectDatabaseHealthResponse( + project_id=project_id, + db_role=db_role, + db_type=routing.db_type, + ok=False, + detail=_database_health_error_detail(exc), + ) + return ProjectDatabaseHealthResponse( + project_id=project_id, + db_role=db_role, + db_type=routing.db_type, + ok=True, + detail="连通性测试通过", + ) + + +@router.get( + "/projects/{project_id}/geoserver", + response_model=ProjectGeoServerConfigResponse, +) +async def get_project_geoserver_config( + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectGeoServerConfigResponse: + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + record = await metadata_repo.get_geoserver_config_record(project_id) + if record is None: + return ProjectGeoServerConfigResponse( + id=None, + project_id=project_id, + gs_base_url=None, + gs_admin_user=None, + gs_datastore_name="ds_postgis", + default_extent=None, + srid=4326, + configured=False, + has_password=False, + updated_at=None, + ) + return _geoserver_config_response(record) + + +@router.put( + "/projects/{project_id}/geoserver", + response_model=ProjectGeoServerConfigResponse, +) +async def upsert_project_geoserver_config( + payload: ProjectGeoServerConfigUpsertRequest, + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectGeoServerConfigResponse: + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + try: + record = await metadata_repo.upsert_geoserver_config( + project_id, + gs_base_url=payload.gs_base_url, + gs_admin_user=payload.gs_admin_user, + gs_admin_password=payload.gs_admin_password, + password_update_requested="gs_admin_password" in payload.model_fields_set, + gs_datastore_name=payload.gs_datastore_name, + default_extent=payload.default_extent, + srid=payload.srid, + ) + except ValueError as exc: + raise HTTPException( + status_code=_status_for_config_value_error(exc), + detail=str(exc), + ) from exc + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Metadata database error: {exc}", + ) from exc + + await log_audit_event( + action=AuditAction.CONFIG_CHANGE, + user_id=current_user.id, + project_id=project_id, + resource_type="project_geoserver", + resource_id=str(project_id), + request_data=_geoserver_audit_payload(payload), + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + return _geoserver_config_response(record) + + +@router.get("/users/{user_id}", response_model=MetadataUserResponse) +async def get_metadata_user( + user_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> MetadataUserResponse: + user = await metadata_repo.get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return MetadataUserResponse.model_validate(user) + + +@router.patch("/users/{user_id}", response_model=MetadataUserResponse) +async def update_metadata_user( + payload: MetadataUserUpdateRequest, + user_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> MetadataUserResponse: + updates = payload.model_dump(mode="json", exclude_unset=True) + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Users cannot modify themselves", + ) + user = await metadata_repo.update_user_admin( + user_id, + updates=updates, + ) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + await log_audit_event( + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_type="metadata_user", + resource_id=str(user.id), + request_data=updates, + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + return MetadataUserResponse.model_validate(user) + + +@router.get( + "/projects/{project_id}/members", + response_model=List[ProjectMemberResponse], +) +async def list_project_members( + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> List[ProjectMemberResponse]: + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" + ) + members = await metadata_repo.list_project_members(project_id) + return [ProjectMemberResponse(**member.__dict__) for member in members] + + +@router.post( + "/projects/{project_id}/members", + response_model=ProjectMemberResponse, + status_code=status.HTTP_201_CREATED, +) +async def add_project_member( + payload: ProjectMemberCreateRequest, + project_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectMemberResponse: + if payload.user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Users cannot modify their own project membership", + ) + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" + ) + user = await metadata_repo.get_user_by_id(payload.user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + existing = await metadata_repo.get_project_membership(project_id, payload.user_id) + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User is already a project member", + ) + + membership = await metadata_repo.add_project_member( + project_id, payload.user_id, payload.project_role + ) + await log_audit_event( + action=AuditAction.PERMISSION_CHANGE, + user_id=current_user.id, + project_id=project_id, + resource_type="project_member", + resource_id=str(payload.user_id), + request_data=payload.model_dump(mode="json"), + response_status=status.HTTP_201_CREATED, + session=metadata_repo.session, + ) + return ProjectMemberResponse( + id=membership.id, + user_id=membership.user_id, + project_id=membership.project_id, + project_role=membership.project_role, + username=user.username, + email=user.email, + is_active=user.is_active, + ) + + +@router.patch( + "/projects/{project_id}/members/{user_id}", + response_model=ProjectMemberResponse, +) +async def update_project_member( + payload: ProjectMemberUpdateRequest, + project_id: UUID = Path(...), + user_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectMemberResponse: + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Users cannot modify their own project membership", + ) + user = await metadata_repo.get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + membership = await metadata_repo.update_project_member_role( + project_id, user_id, payload.project_role + ) + if membership is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found" + ) + await log_audit_event( + action=AuditAction.PERMISSION_CHANGE, + user_id=current_user.id, + project_id=project_id, + resource_type="project_member", + resource_id=str(user_id), + request_data=payload.model_dump(mode="json"), + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + return ProjectMemberResponse( + id=membership.id, + user_id=membership.user_id, + project_id=membership.project_id, + project_role=membership.project_role, + username=user.username, + email=user.email, + is_active=user.is_active, + ) + + +@router.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_project_member( + project_id: UUID = Path(...), + user_id: UUID = Path(...), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> None: + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Users cannot modify their own project membership", + ) + removed = await metadata_repo.remove_project_member(project_id, user_id) + if not removed: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found" + ) + await log_audit_event( + action=AuditAction.PERMISSION_CHANGE, + user_id=current_user.id, + project_id=project_id, + resource_type="project_member", + resource_id=str(user_id), + response_status=status.HTTP_204_NO_CONTENT, + session=metadata_repo.session, + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 3e4e7d3..99da712 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( + admin_metadata, agent_auth, project, simulation, @@ -54,6 +55,9 @@ api_router = APIRouter() # Core Services api_router.include_router(agent_auth.router, tags=["Agent Auth"]) +api_router.include_router( + admin_metadata.router, prefix="/admin", tags=["Metadata Admin"] +) api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增 api_router.include_router(meta.router, tags=["Metadata"]) api_router.include_router(project.router, tags=["Project"]) diff --git a/app/auth/metadata_dependencies.py b/app/auth/metadata_dependencies.py index 8424429..021063e 100644 --- a/app/auth/metadata_dependencies.py +++ b/app/auth/metadata_dependencies.py @@ -6,8 +6,7 @@ from fastapi import Depends, HTTPException, status from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.keycloak_dependencies import get_current_keycloak_sub -from app.core.config import settings +from app.auth.keycloak_dependencies import get_current_keycloak_payload from app.infra.db.metadb.database import get_metadata_session from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository @@ -20,10 +19,40 @@ async def get_metadata_repository( return MetadataRepository(session) +def _keycloak_sub_from_payload(payload: dict) -> UUID: + sub = payload.get("sub") + if not sub: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing subject claim", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + return UUID(str(sub)) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid subject claim", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + +def _username_from_payload(payload: dict) -> str | None: + username = payload.get("preferred_username") or payload.get("username") + return str(username) if username else None + + +def _email_from_payload(payload: dict) -> str | None: + email = payload.get("email") + return str(email) if email else None + + async def get_current_metadata_user( - keycloak_sub: UUID = Depends(get_current_keycloak_sub), + keycloak_payload: dict = Depends(get_current_keycloak_payload), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ): + keycloak_sub = _keycloak_sub_from_payload(keycloak_payload) try: user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub) except SQLAlchemyError as exc: @@ -39,6 +68,21 @@ async def get_current_metadata_user( raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" ) + try: + user = await metadata_repo.refresh_user_keycloak_snapshot( + user, + username=_username_from_payload(keycloak_payload), + email=_email_from_payload(keycloak_payload), + ) + except SQLAlchemyError as exc: + logger.error( + "Metadata DB error while refreshing current user snapshot", + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Metadata database error: {exc}", + ) from exc return user diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py new file mode 100644 index 0000000..0138348 --- /dev/null +++ b/app/domain/schemas/admin_metadata.py @@ -0,0 +1,156 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +BusinessRole = Literal["admin", "user", "operator", "viewer"] +ProjectRole = Literal["owner", "admin", "member", "viewer"] +ProjectStatus = Literal["active", "inactive", "archived"] +ProjectDbRole = Literal["biz_data", "iot_data"] + + +class MetadataUserSyncRequest(BaseModel): + keycloak_id: UUID + username: str = Field(..., min_length=1, max_length=50) + email: str = Field(..., min_length=1, max_length=100) + role: BusinessRole = "user" + is_active: bool = True + + +class MetadataUsersBatchSyncRequest(BaseModel): + users: list[MetadataUserSyncRequest] = Field(..., min_length=1, max_length=500) + + +class MetadataUserUpdateRequest(BaseModel): + role: BusinessRole | None = None + is_active: bool | None = None + + +class MetadataUserResponse(BaseModel): + id: UUID + keycloak_id: UUID + username: str + email: str + role: str + is_active: bool + is_superuser: bool + created_at: datetime + updated_at: datetime + last_login_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + +class MetadataUserSyncResult(BaseModel): + keycloak_id: UUID + user: MetadataUserResponse | None = None + success: bool + error: str | None = None + + +class ProjectMemberCreateRequest(BaseModel): + user_id: UUID + project_role: ProjectRole = "viewer" + + +class ProjectMemberUpdateRequest(BaseModel): + project_role: ProjectRole + + +class ProjectMemberResponse(BaseModel): + id: UUID + user_id: UUID + project_id: UUID + project_role: str + username: str + email: str + is_active: bool + + +class AdminProjectCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + code: str = Field(..., min_length=1, max_length=50) + description: str | None = None + gs_workspace: str = Field(..., min_length=1, max_length=100) + map_extent: dict | None = None + status: ProjectStatus = "active" + + +class AdminProjectUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + code: str | None = Field(default=None, min_length=1, max_length=50) + description: str | None = None + gs_workspace: str | None = Field(default=None, min_length=1, max_length=100) + map_extent: dict | None = None + status: ProjectStatus | None = None + + +class AdminProjectResponse(BaseModel): + project_id: UUID + name: str + code: str + description: str | None = None + gs_workspace: str + map_extent: dict | None = None + status: str + created_at: datetime + updated_at: datetime + + +class ProjectDatabaseUpsertRequest(BaseModel): + db_role: ProjectDbRole + dsn: str | None = Field(default=None, min_length=1) + pool_min_size: int = Field(default=2, ge=1) + pool_max_size: int = Field(default=10, ge=1) + + @model_validator(mode="after") + def validate_pool_bounds(self): + if self.pool_max_size < self.pool_min_size: + raise ValueError("pool_max_size must be greater than or equal to pool_min_size") + return self + + +class ProjectDatabaseResponse(BaseModel): + id: UUID + project_id: UUID + db_role: str + db_type: str + pool_min_size: int + pool_max_size: int + has_dsn: bool + + +class ProjectDatabaseHealthRequest(BaseModel): + dsn: str | None = Field(default=None, min_length=1) + + +class ProjectDatabaseHealthResponse(BaseModel): + project_id: UUID + db_role: str + db_type: str + ok: bool + detail: str + + +class ProjectGeoServerConfigUpsertRequest(BaseModel): + gs_base_url: str | None = None + gs_admin_user: str | None = Field(default=None, max_length=50) + gs_admin_password: str | None = Field(default=None, min_length=1) + gs_datastore_name: str = Field(default="ds_postgis", min_length=1, max_length=100) + default_extent: dict | None = None + srid: int = Field(default=4326, ge=1) + + +class ProjectGeoServerConfigResponse(BaseModel): + id: UUID | None = None + project_id: UUID + gs_base_url: str | None = None + gs_admin_user: str | None = None + gs_datastore_name: str + default_extent: dict | None = None + srid: int + configured: bool = True + has_password: bool + updated_at: datetime | None = None diff --git a/app/infra/db/dynamic_manager.py b/app/infra/db/dynamic_manager.py index e444a6e..c78a2e5 100644 --- a/app/infra/db/dynamic_manager.py +++ b/app/infra/db/dynamic_manager.py @@ -54,9 +54,9 @@ class ProjectConnectionManager: def _normalize_pg_url(self, url: str) -> str: parsed = make_url(url) - if parsed.drivername == "postgresql": + if parsed.drivername in {"postgresql", "postgres"}: parsed = parsed.set(drivername="postgresql+psycopg") - return str(parsed) + return parsed.render_as_string(hide_password=False) async def get_pg_sessionmaker( self, diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index 9631d7b..2555fc1 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -1,9 +1,10 @@ from dataclasses import dataclass +from datetime import datetime, timezone from typing import Optional, List -from uuid import UUID +from uuid import UUID, uuid4 from cryptography.fernet import InvalidToken -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.encryption import ( @@ -78,6 +79,33 @@ class ProjectDetail: geoserver: Optional[ProjectGeoServerInfo] +@dataclass(frozen=True) +class ProjectMemberSummary: + id: UUID + user_id: UUID + project_id: UUID + project_role: str + username: str + email: str + is_active: bool + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _encrypt_database_secret(value: str) -> str: + if not is_database_encryption_configured(): + raise ValueError("DATABASE_ENCRYPTION_KEY is not configured") + return get_database_encryptor().encrypt(value) + + +def _encrypt_general_secret(value: str) -> str: + if not is_encryption_configured(): + raise ValueError("ENCRYPTION_KEY is not configured") + return get_encryptor().encrypt(value) + + class MetadataRepository: """元数据访问层(system_hub)""" @@ -96,6 +124,86 @@ class MetadataRepository: ) return result.scalar_one_or_none() + async def get_user_by_id(self, user_id: UUID) -> Optional[models.User]: + result = await self.session.execute( + select(models.User).where(models.User.id == user_id) + ) + return result.scalar_one_or_none() + + async def list_users(self, skip: int = 0, limit: int = 100) -> List[models.User]: + result = await self.session.execute( + select(models.User) + .order_by(models.User.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return list(result.scalars().all()) + + async def upsert_user_from_keycloak( + self, + *, + keycloak_id: UUID, + username: str, + email: str, + role: str, + is_active: bool, + ) -> models.User: + user = await self.get_user_by_keycloak_id(keycloak_id) + if user is None: + user = models.User( + id=uuid4(), + keycloak_id=keycloak_id, + username=username, + email=email, + role=role, + is_active=is_active, + is_superuser=False, + ) + self.session.add(user) + else: + user.username = username + user.email = email + user.role = role + user.is_active = is_active + await self.session.commit() + await self.session.refresh(user) + return user + + async def refresh_user_keycloak_snapshot( + self, + user: models.User, + *, + username: str | None, + email: str | None, + last_login_at: datetime | None = None, + ) -> models.User: + if username: + user.username = username + if email: + user.email = email + user.last_login_at = last_login_at or _utcnow() + user.updated_at = _utcnow() + await self.session.commit() + await self.session.refresh(user) + return user + + async def update_user_admin( + self, + user_id: UUID, + *, + updates: dict, + ) -> Optional[models.User]: + user = await self.get_user_by_id(user_id) + if user is None: + return None + if "role" in updates: + user.role = updates["role"] + if "is_active" in updates: + user.is_active = updates["is_active"] + await self.session.commit() + await self.session.refresh(user) + return user + async def get_project_by_id(self, project_id: UUID) -> Optional[models.Project]: result = await self.session.execute( select(models.Project).where(models.Project.id == project_id) @@ -108,6 +216,62 @@ class MetadataRepository: ) return result.scalar_one_or_none() + async def list_project_records(self) -> List[models.Project]: + result = await self.session.execute( + select(models.Project).order_by(models.Project.name) + ) + return list(result.scalars().all()) + + async def create_project( + self, + *, + name: str, + code: str, + description: str | None, + gs_workspace: str, + map_extent: dict | None, + status: str, + ) -> models.Project: + project = models.Project( + id=uuid4(), + name=name, + code=code, + description=description, + gs_workspace=gs_workspace, + map_extent=map_extent, + status=status, + created_at=_utcnow(), + updated_at=_utcnow(), + ) + self.session.add(project) + await self.session.commit() + await self.session.refresh(project) + return project + + async def update_project( + self, + project_id: UUID, + *, + updates: dict, + ) -> Optional[models.Project]: + project = await self.get_project_by_id(project_id) + if project is None: + return None + for field in ( + "name", + "code", + "description", + "gs_workspace", + "map_extent", + "status", + ): + if field in updates: + setattr(project, field, updates[field]) + project.updated_at = _utcnow() + await self.session.commit() + await self.session.refresh(project) + return project + async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]: project = await self.get_project_by_code(code) if not project: @@ -137,6 +301,142 @@ class MetadataRepository: ) return result.scalar_one_or_none() + async def list_project_members( + self, project_id: UUID + ) -> List[ProjectMemberSummary]: + stmt = ( + select(models.UserProjectMembership, models.User) + .join(models.User, models.User.id == models.UserProjectMembership.user_id) + .where(models.UserProjectMembership.project_id == project_id) + .order_by(models.User.username) + ) + result = await self.session.execute(stmt) + return [ + ProjectMemberSummary( + id=membership.id, + user_id=membership.user_id, + project_id=membership.project_id, + project_role=membership.project_role, + username=user.username, + email=user.email, + is_active=user.is_active, + ) + for membership, user in result.all() + ] + + async def get_project_membership( + self, project_id: UUID, user_id: UUID + ) -> Optional[models.UserProjectMembership]: + result = await self.session.execute( + select(models.UserProjectMembership).where( + models.UserProjectMembership.project_id == project_id, + models.UserProjectMembership.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + async def add_project_member( + self, project_id: UUID, user_id: UUID, project_role: str + ) -> models.UserProjectMembership: + membership = models.UserProjectMembership( + id=uuid4(), + user_id=user_id, + project_id=project_id, + project_role=project_role, + ) + self.session.add(membership) + await self.session.commit() + await self.session.refresh(membership) + return membership + + async def update_project_member_role( + self, project_id: UUID, user_id: UUID, project_role: str + ) -> Optional[models.UserProjectMembership]: + membership = await self.get_project_membership(project_id, user_id) + if membership is None: + return None + membership.project_role = project_role + await self.session.commit() + await self.session.refresh(membership) + return membership + + async def remove_project_member(self, project_id: UUID, user_id: UUID) -> bool: + result = await self.session.execute( + delete(models.UserProjectMembership).where( + models.UserProjectMembership.project_id == project_id, + models.UserProjectMembership.user_id == user_id, + ) + ) + await self.session.commit() + return bool(result.rowcount) + + async def list_project_databases( + self, project_id: UUID + ) -> List[models.ProjectDatabase]: + result = await self.session.execute( + select(models.ProjectDatabase) + .where(models.ProjectDatabase.project_id == project_id) + .order_by(models.ProjectDatabase.db_role) + ) + return list(result.scalars().all()) + + async def get_project_database_config( + self, project_id: UUID, db_role: str + ) -> Optional[models.ProjectDatabase]: + result = await self.session.execute( + select(models.ProjectDatabase).where( + models.ProjectDatabase.project_id == project_id, + models.ProjectDatabase.db_role == db_role, + ) + ) + return result.scalar_one_or_none() + + async def upsert_project_database_config( + self, + project_id: UUID, + *, + db_role: str, + db_type: str, + dsn: str | None, + pool_min_size: int, + pool_max_size: int, + ) -> models.ProjectDatabase: + record = await self.get_project_database_config(project_id, db_role) + if record is None: + if dsn is None: + raise ValueError("dsn is required when creating project database config") + record = models.ProjectDatabase( + id=uuid4(), + project_id=project_id, + db_role=db_role, + db_type=db_type, + dsn_encrypted=_encrypt_database_secret(dsn), + pool_min_size=pool_min_size, + pool_max_size=pool_max_size, + ) + self.session.add(record) + else: + record.db_type = db_type + if dsn is not None: + record.dsn_encrypted = _encrypt_database_secret(dsn) + record.pool_min_size = pool_min_size + record.pool_max_size = pool_max_size + await self.session.commit() + await self.session.refresh(record) + return record + + async def delete_project_database_config( + self, project_id: UUID, db_role: str + ) -> bool: + result = await self.session.execute( + delete(models.ProjectDatabase).where( + models.ProjectDatabase.project_id == project_id, + models.ProjectDatabase.db_role == db_role, + ) + ) + await self.session.commit() + return bool(result.rowcount) + async def get_project_db_routing( self, project_id: UUID, db_role: str ) -> Optional[ProjectDbRouting]: @@ -198,6 +498,59 @@ class MetadataRepository: srid=record.srid, ) + async def get_geoserver_config_record( + self, project_id: UUID + ) -> Optional[models.ProjectGeoServerConfig]: + result = await self.session.execute( + select(models.ProjectGeoServerConfig).where( + models.ProjectGeoServerConfig.project_id == project_id + ) + ) + return result.scalar_one_or_none() + + async def upsert_geoserver_config( + self, + project_id: UUID, + *, + gs_base_url: str | None, + gs_admin_user: str | None, + gs_admin_password: str | None, + password_update_requested: bool, + gs_datastore_name: str, + default_extent: dict | None, + srid: int, + ) -> models.ProjectGeoServerConfig: + record = await self.get_geoserver_config_record(project_id) + encrypted_password: str | None = None + if password_update_requested and gs_admin_password is not None: + encrypted_password = _encrypt_general_secret(gs_admin_password) + + if record is None: + record = models.ProjectGeoServerConfig( + id=uuid4(), + project_id=project_id, + gs_base_url=gs_base_url, + gs_admin_user=gs_admin_user, + gs_admin_password_encrypted=encrypted_password, + gs_datastore_name=gs_datastore_name, + default_extent=default_extent, + srid=srid, + updated_at=_utcnow(), + ) + self.session.add(record) + else: + record.gs_base_url = gs_base_url + record.gs_admin_user = gs_admin_user + if password_update_requested: + record.gs_admin_password_encrypted = encrypted_password + record.gs_datastore_name = gs_datastore_name + record.default_extent = default_extent + record.srid = srid + record.updated_at = _utcnow() + await self.session.commit() + await self.session.refresh(record) + return record + async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]: stmt = ( select(models.Project, models.UserProjectMembership.project_role) diff --git a/resources/sql/004_metadata_auth_management.sql b/resources/sql/004_metadata_auth_management.sql new file mode 100644 index 0000000..e3d682b --- /dev/null +++ b/resources/sql/004_metadata_auth_management.sql @@ -0,0 +1,62 @@ +-- Metadata auth management schema patch. +-- Keycloak owns login credentials; TJWater stores only business identity and access. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +DECLARE + users_id_type text; +BEGIN + SELECT data_type INTO users_id_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'id'; + + IF users_id_type IS NULL THEN + CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + keycloak_id UUID UNIQUE NOT NULL, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + role VARCHAR(20) DEFAULT 'user' NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL, + is_superuser BOOLEAN DEFAULT FALSE NOT NULL, + attributes JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_login_at TIMESTAMP WITH TIME ZONE + ); + ELSIF users_id_type <> 'uuid' THEN + RAISE EXCEPTION + 'Existing public.users.id is %, not uuid. Export old local users, create Keycloak accounts, then migrate to metadata UUID users before applying this patch.', + users_id_type; + END IF; +END $$; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS keycloak_id UUID, + ADD COLUMN IF NOT EXISTS attributes JSONB, + ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP WITH TIME ZONE; + +ALTER TABLE users + ALTER COLUMN role SET DEFAULT 'user'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active); + +CREATE TABLE IF NOT EXISTS user_project_membership ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + project_id UUID NOT NULL, + project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL, + CONSTRAINT user_project_membership_role_check + CHECK (project_role IN ('owner', 'admin', 'member', 'viewer')), + CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_project_membership_user_id + ON user_project_membership(user_id); +CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id + ON user_project_membership(project_id); diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql new file mode 100644 index 0000000..1d6f910 --- /dev/null +++ b/resources/sql/005_metadata_project_configuration.sql @@ -0,0 +1,78 @@ +-- Metadata project configuration schema patch. +-- Admin APIs write these tables; operators should not hand-edit encrypted values. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + code VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + gs_workspace VARCHAR(100) UNIQUE NOT NULL, + map_extent JSONB, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived')) +); + +CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); +CREATE INDEX IF NOT EXISTS idx_projects_code ON projects(code); + +DROP TRIGGER IF EXISTS update_projects_updated_at ON projects; +CREATE TRIGGER update_projects_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS project_databases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + db_role VARCHAR(20) NOT NULL, + db_type VARCHAR(20) NOT NULL, + dsn_encrypted TEXT NOT NULL, + pool_min_size INTEGER DEFAULT 2 NOT NULL, + pool_max_size INTEGER DEFAULT 10 NOT NULL, + CONSTRAINT project_databases_unique_role UNIQUE (project_id, db_role), + CONSTRAINT project_databases_role_check CHECK (db_role IN ('biz_data', 'iot_data')), + CONSTRAINT project_databases_type_check CHECK (db_type IN ('postgresql', 'timescaledb')), + CONSTRAINT project_databases_pool_check CHECK ( + pool_min_size >= 1 AND pool_max_size >= pool_min_size + ) +); + +CREATE INDEX IF NOT EXISTS idx_project_databases_project_id + ON project_databases(project_id); +CREATE INDEX IF NOT EXISTS idx_project_databases_role + ON project_databases(db_role); + +CREATE TABLE IF NOT EXISTS project_geoserver_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID UNIQUE NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + gs_base_url TEXT, + gs_admin_user VARCHAR(50), + gs_admin_password_encrypted TEXT, + gs_datastore_name VARCHAR(100) DEFAULT 'ds_postgis' NOT NULL, + default_extent JSONB, + srid INTEGER DEFAULT 4326 NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT project_geoserver_configs_srid_check CHECK (srid >= 1) +); + +CREATE INDEX IF NOT EXISTS idx_project_geoserver_configs_project_id + ON project_geoserver_configs(project_id); + +DROP TRIGGER IF EXISTS update_project_geoserver_configs_updated_at + ON project_geoserver_configs; +CREATE TRIGGER update_project_geoserver_configs_updated_at + BEFORE UPDATE ON project_geoserver_configs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/scripts/migrate_local_users_to_metadata.py b/scripts/migrate_local_users_to_metadata.py new file mode 100644 index 0000000..55edbc3 --- /dev/null +++ b/scripts/migrate_local_users_to_metadata.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Build metadata user sync payloads from an old-user to Keycloak mapping CSV.""" + +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path + + +REQUIRED_COLUMNS = {"keycloak_id", "username", "email"} + + +def parse_bool(value: str | None) -> bool: + if value is None or value == "": + return True + return value.strip().lower() not in {"0", "false", "no", "n", "disabled"} + + +def build_payload(mapping_csv: Path) -> dict: + with mapping_csv.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + missing = REQUIRED_COLUMNS.difference(reader.fieldnames or []) + if missing: + raise SystemExit(f"missing required CSV columns: {', '.join(sorted(missing))}") + + users = [] + for row in reader: + users.append( + { + "keycloak_id": row["keycloak_id"].strip(), + "username": row["username"].strip(), + "email": row["email"].strip(), + "role": (row.get("role") or "user").strip().lower(), + "is_active": parse_bool(row.get("is_active")), + } + ) + + return {"users": users} + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Convert old local user mappings into a JSON body for " + "POST /api/v1/admin/users/sync/batch. Passwords are never migrated." + ) + ) + parser.add_argument("mapping_csv", type=Path) + parser.add_argument("-o", "--output", type=Path) + args = parser.parse_args() + + payload = build_payload(args.mapping_csv) + content = json.dumps(payload, ensure_ascii=False, indent=2) + if args.output: + args.output.write_text(content + "\n", encoding="utf-8") + else: + print(content) + + +if __name__ == "__main__": + main() diff --git a/tests/api/test_admin_metadata_endpoints.py b/tests/api/test_admin_metadata_endpoints.py new file mode 100644 index 0000000..e76c4fa --- /dev/null +++ b/tests/api/test_admin_metadata_endpoints.py @@ -0,0 +1,646 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from fastapi import Response + +from app.auth.metadata_dependencies import get_current_metadata_admin +from app.api.v1.endpoints import admin_metadata +from app.domain.schemas.admin_metadata import ( + AdminProjectCreateRequest, + MetadataUsersBatchSyncRequest, + MetadataUserSyncRequest, + MetadataUserUpdateRequest, + ProjectDatabaseUpsertRequest, + ProjectGeoServerConfigUpsertRequest, + ProjectMemberCreateRequest, + ProjectMemberUpdateRequest, +) +from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def _user(**overrides): + data = { + "id": uuid4(), + "keycloak_id": uuid4(), + "username": "alice", + "email": "alice@example.com", + "role": "user", + "is_active": True, + "is_superuser": False, + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "last_login_at": None, + } + data.update(overrides) + return SimpleNamespace(**data) + + +def _project(**overrides): + data = {"id": uuid4(), "name": "Demo"} + data.update(overrides) + return SimpleNamespace(**data) + + +def _membership(**overrides): + data = { + "id": uuid4(), + "user_id": uuid4(), + "project_id": uuid4(), + "project_role": "viewer", + } + data.update(overrides) + return SimpleNamespace(**data) + + +def _database_config(**overrides): + data = { + "id": uuid4(), + "project_id": uuid4(), + "db_role": "biz_data", + "db_type": "postgresql", + "dsn_encrypted": "encrypted-dsn", + "pool_min_size": 1, + "pool_max_size": 5, + } + data.update(overrides) + return SimpleNamespace(**data) + + +def _geoserver_config(**overrides): + data = { + "id": uuid4(), + "project_id": uuid4(), + "gs_base_url": "http://geoserver", + "gs_admin_user": "admin", + "gs_admin_password_encrypted": "encrypted-password", + "gs_datastore_name": "ds_postgis", + "default_extent": {"bbox": [1, 2, 3, 4]}, + "srid": 4326, + "updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + } + data.update(overrides) + return SimpleNamespace(**data) + + +def test_to_async_sqlalchemy_url_preserves_password(): + url = admin_metadata._to_async_sqlalchemy_url( + "postgresql://tjwater:secret@192.168.1.114:5433/tjwater" + ) + + assert url == "postgresql+psycopg://tjwater:secret@192.168.1.114:5433/tjwater" + assert "***" not in url + + +@pytest.mark.anyio +async def test_sync_metadata_user_upserts_without_password(monkeypatch): + keycloak_id = uuid4() + synced_user = _user(keycloak_id=keycloak_id, username="new-user") + admin = _user(role="admin", is_superuser=True) + repo = SimpleNamespace( + session=object(), + upsert_user_from_keycloak=AsyncMock(return_value=synced_user), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.sync_metadata_user( + MetadataUserSyncRequest( + keycloak_id=keycloak_id, + username="new-user", + email="new-user@example.com", + role="user", + is_active=True, + ), + current_user=admin, + metadata_repo=repo, + ) + + repo.upsert_user_from_keycloak.assert_awaited_once() + kwargs = repo.upsert_user_from_keycloak.await_args.kwargs + assert kwargs["keycloak_id"] == keycloak_id + assert "password" not in kwargs + assert response.username == "new-user" + admin_metadata.log_audit_event.assert_awaited_once() + + +@pytest.mark.anyio +async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch): + users = [_user(username="alice"), _user(username="bob")] + admin = _user(role="admin", is_superuser=True) + repo = SimpleNamespace( + session=SimpleNamespace(rollback=AsyncMock()), + upsert_user_from_keycloak=AsyncMock(side_effect=users), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.sync_metadata_users_batch( + MetadataUsersBatchSyncRequest( + users=[ + MetadataUserSyncRequest( + keycloak_id=users[0].keycloak_id, + username="alice", + email="alice@example.com", + role="user", + is_active=True, + ), + MetadataUserSyncRequest( + keycloak_id=users[1].keycloak_id, + username="bob", + email="bob@example.com", + role="viewer", + is_active=True, + ), + ] + ), + current_user=admin, + metadata_repo=repo, + ) + + assert [item.success for item in response] == [True, True] + assert [item.user.username for item in response] == ["alice", "bob"] + assert repo.upsert_user_from_keycloak.await_count == 2 + assert admin_metadata.log_audit_event.await_count == 2 + + +@pytest.mark.anyio +async def test_update_metadata_user_updates_role_and_active_status(monkeypatch): + user_id = uuid4() + updated = _user(id=user_id, role="operator", is_active=False) + repo = SimpleNamespace( + session=object(), + update_user_admin=AsyncMock(return_value=updated), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.update_metadata_user( + MetadataUserUpdateRequest( + role="operator", + is_active=False, + ), + user_id=user_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + repo.update_user_admin.assert_awaited_once_with( + user_id, + updates={"role": "operator", "is_active": False}, + ) + assert response.role == "operator" + admin_metadata.log_audit_event.assert_awaited_once() + + +@pytest.mark.anyio +async def test_update_metadata_user_rejects_self_update(monkeypatch): + current_user = _user(role="admin", is_superuser=True) + repo = SimpleNamespace( + session=object(), + update_user_admin=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await admin_metadata.update_metadata_user( + MetadataUserUpdateRequest(role="viewer"), + user_id=current_user.id, + current_user=current_user, + metadata_repo=repo, + ) + + assert exc.value.status_code == 403 + repo.update_user_admin.assert_not_called() + admin_metadata.log_audit_event.assert_not_called() + + +@pytest.mark.anyio +async def test_create_project_audits_metadata_admin_change(monkeypatch): + project = SimpleNamespace( + id=uuid4(), + name="Demo Project", + code="demo", + description="desc", + gs_workspace="demo_ws", + map_extent={"bbox": [1, 2, 3, 4]}, + status="active", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + repo = SimpleNamespace( + session=object(), + create_project=AsyncMock(return_value=project), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.create_admin_project( + AdminProjectCreateRequest( + name="Demo Project", + code="demo", + description="desc", + gs_workspace="demo_ws", + map_extent={"bbox": [1, 2, 3, 4]}, + status="active", + ), + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.project_id == project.id + repo.create_project.assert_awaited_once() + admin_metadata.log_audit_event.assert_awaited_once() + + +@pytest.mark.anyio +async def test_upsert_project_database_hides_dsn_and_audits_without_plaintext(monkeypatch): + project_id = uuid4() + record = _database_config(project_id=project_id) + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock(return_value=_project(id=project_id)), + upsert_project_database_config=AsyncMock(return_value=record), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock()) + + response = await admin_metadata.upsert_project_database( + ProjectDatabaseUpsertRequest( + db_role="biz_data", + dsn="postgresql://user:secret@localhost/db", + pool_min_size=1, + pool_max_size=5, + ), + project_id=project_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.has_dsn is True + assert "dsn" not in response.model_dump() + admin_metadata._check_database_connection.assert_awaited_once() + repo.upsert_project_database_config.assert_awaited_once() + assert repo.upsert_project_database_config.await_args.kwargs["db_type"] == "postgresql" + request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"] + assert request_data["dsn_updated"] is True + assert request_data["db_type"] == "postgresql" + assert "dsn" not in request_data + assert "postgresql://user:secret@localhost/db" not in str(request_data) + + +@pytest.mark.anyio +async def test_upsert_project_database_rejects_unhealthy_connection(monkeypatch): + project_id = uuid4() + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock(return_value=_project(id=project_id)), + upsert_project_database_config=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + monkeypatch.setattr( + admin_metadata, + "_check_database_connection", + AsyncMock( + side_effect=Exception( + 'FATAL: password authentication failed for user "tjwater"' + ) + ), + ) + + with pytest.raises(HTTPException) as exc: + await admin_metadata.upsert_project_database( + ProjectDatabaseUpsertRequest( + db_role="iot_data", + dsn="postgresql://tjwater:bad@192.168.1.114:5433/tjwater", + pool_min_size=1, + pool_max_size=5, + ), + project_id=project_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert exc.value.status_code == 400 + assert exc.value.detail == "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。" + repo.upsert_project_database_config.assert_not_called() + admin_metadata.log_audit_event.assert_not_called() + + +@pytest.mark.anyio +async def test_project_database_health_returns_ok(monkeypatch): + project_id = uuid4() + repo = SimpleNamespace( + get_project_db_routing=AsyncMock( + return_value=ProjectDbRouting( + project_id=project_id, + db_role="biz_data", + db_type="postgresql", + dsn="postgresql://user:secret@localhost/db", + pool_min_size=1, + pool_max_size=5, + ) + ) + ) + monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock()) + + response = await admin_metadata.check_project_database_health( + project_id=project_id, + db_role="biz_data", + response=Response(), + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.ok is True + assert response.detail == "连通性测试通过" + admin_metadata._check_database_connection.assert_awaited_once() + + +@pytest.mark.anyio +async def test_project_database_health_can_test_unsaved_plaintext_dsn(monkeypatch): + project_id = uuid4() + repo = SimpleNamespace( + get_project_db_routing=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock()) + + response = await admin_metadata.check_project_database_health( + project_id=project_id, + db_role="iot_data", + payload=admin_metadata.ProjectDatabaseHealthRequest( + dsn="postgresql://tjwater:secret@192.168.1.114:5433/tjwater" + ), + response=Response(), + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.ok is True + assert response.db_type == "timescaledb" + routing = admin_metadata._check_database_connection.await_args.args[0] + assert routing.dsn == "postgresql://tjwater:secret@192.168.1.114:5433/tjwater" + repo.get_project_db_routing.assert_not_called() + + +@pytest.mark.anyio +async def test_project_database_health_sanitizes_password_failures(monkeypatch): + project_id = uuid4() + repo = SimpleNamespace( + get_project_db_routing=AsyncMock( + return_value=ProjectDbRouting( + project_id=project_id, + db_role="iot_data", + db_type="timescaledb", + dsn="postgresql://tjwater:bad-password@192.168.1.114:5433/db", + pool_min_size=1, + pool_max_size=5, + ) + ) + ) + monkeypatch.setattr( + admin_metadata, + "_check_database_connection", + AsyncMock( + side_effect=Exception( + '(psycopg.OperationalError) connection failed: FATAL: ' + 'password authentication failed for user "tjwater"' + ) + ), + ) + + fastapi_response = Response() + response = await admin_metadata.check_project_database_health( + project_id=project_id, + db_role="iot_data", + response=fastapi_response, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert fastapi_response.status_code == 503 + assert response.ok is False + assert response.db_type == "timescaledb" + assert response.detail == "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。" + assert "psycopg" not in response.detail + + +@pytest.mark.anyio +async def test_upsert_geoserver_config_hides_password_and_audits_without_plaintext(monkeypatch): + project_id = uuid4() + record = _geoserver_config(project_id=project_id) + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock(return_value=_project(id=project_id)), + upsert_geoserver_config=AsyncMock(return_value=record), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.upsert_project_geoserver_config( + ProjectGeoServerConfigUpsertRequest( + gs_base_url="http://geoserver", + gs_admin_user="admin", + gs_admin_password="secret-password", + gs_datastore_name="ds_postgis", + default_extent={"bbox": [1, 2, 3, 4]}, + srid=4326, + ), + project_id=project_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.has_password is True + assert "password" not in response.model_dump() + request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"] + assert request_data["password_updated"] is True + assert "secret-password" not in str(request_data) + + +@pytest.mark.anyio +async def test_get_geoserver_config_returns_empty_state_when_unconfigured(): + project_id = uuid4() + repo = SimpleNamespace( + get_project_by_id=AsyncMock(return_value=_project(id=project_id)), + get_geoserver_config_record=AsyncMock(return_value=None), + ) + + response = await admin_metadata.get_project_geoserver_config( + project_id=project_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.project_id == project_id + assert response.configured is False + assert response.has_password is False + assert response.gs_datastore_name == "ds_postgis" + + +@pytest.mark.anyio +async def test_metadata_admin_dependency_rejects_non_admin_user(): + with pytest.raises(HTTPException) as exc: + await get_current_metadata_admin(_user(role="user", is_superuser=False)) + + assert exc.value.status_code == 403 + assert exc.value.detail == "Admin access required" + + +@pytest.mark.anyio +async def test_add_project_member_rejects_duplicate(monkeypatch): + project_id = uuid4() + user_id = uuid4() + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock(return_value=_project(id=project_id)), + get_user_by_id=AsyncMock(return_value=_user(id=user_id)), + get_project_membership=AsyncMock(return_value=_membership()), + add_project_member=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await admin_metadata.add_project_member( + ProjectMemberCreateRequest(user_id=user_id, project_role="viewer"), + project_id=project_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert exc.value.status_code == 409 + repo.add_project_member.assert_not_called() + + +@pytest.mark.anyio +async def test_add_project_member_rejects_self_membership_change(monkeypatch): + project_id = uuid4() + current_user = _user(role="admin", is_superuser=True) + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock(), + add_project_member=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await admin_metadata.add_project_member( + ProjectMemberCreateRequest( + user_id=current_user.id, + project_role="viewer", + ), + project_id=project_id, + current_user=current_user, + metadata_repo=repo, + ) + + assert exc.value.status_code == 403 + repo.get_project_by_id.assert_not_called() + repo.add_project_member.assert_not_called() + admin_metadata.log_audit_event.assert_not_called() + + +@pytest.mark.anyio +async def test_update_project_member_role_audits_change(monkeypatch): + project_id = uuid4() + user_id = uuid4() + user = _user(id=user_id, username="bob", email="bob@example.com") + membership = _membership( + user_id=user_id, + project_id=project_id, + project_role="admin", + ) + repo = SimpleNamespace( + session=object(), + get_user_by_id=AsyncMock(return_value=user), + update_project_member_role=AsyncMock(return_value=membership), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.update_project_member( + ProjectMemberUpdateRequest(project_role="admin"), + project_id=project_id, + user_id=user_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.project_role == "admin" + repo.update_project_member_role.assert_awaited_once_with( + project_id, user_id, "admin" + ) + admin_metadata.log_audit_event.assert_awaited_once() + + +@pytest.mark.anyio +async def test_update_project_member_rejects_self_membership_change(monkeypatch): + project_id = uuid4() + current_user = _user(role="admin", is_superuser=True) + repo = SimpleNamespace( + session=object(), + get_user_by_id=AsyncMock(), + update_project_member_role=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await admin_metadata.update_project_member( + ProjectMemberUpdateRequest(project_role="admin"), + project_id=project_id, + user_id=current_user.id, + current_user=current_user, + metadata_repo=repo, + ) + + assert exc.value.status_code == 403 + repo.get_user_by_id.assert_not_called() + repo.update_project_member_role.assert_not_called() + admin_metadata.log_audit_event.assert_not_called() + + +@pytest.mark.anyio +async def test_remove_project_member_audits_change(monkeypatch): + project_id = uuid4() + user_id = uuid4() + repo = SimpleNamespace( + session=object(), + remove_project_member=AsyncMock(return_value=True), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + response = await admin_metadata.remove_project_member( + project_id=project_id, + user_id=user_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response is None + repo.remove_project_member.assert_awaited_once_with(project_id, user_id) + admin_metadata.log_audit_event.assert_awaited_once() + + +@pytest.mark.anyio +async def test_remove_project_member_rejects_self_membership_change(monkeypatch): + project_id = uuid4() + current_user = _user(role="admin", is_superuser=True) + repo = SimpleNamespace( + session=object(), + remove_project_member=AsyncMock(), + ) + monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await admin_metadata.remove_project_member( + project_id=project_id, + user_id=current_user.id, + current_user=current_user, + metadata_repo=repo, + ) + + assert exc.value.status_code == 403 + repo.remove_project_member.assert_not_called() + admin_metadata.log_audit_event.assert_not_called() diff --git a/tests/auth/test_metadata_dependencies.py b/tests/auth/test_metadata_dependencies.py new file mode 100644 index 0000000..3abfbd8 --- /dev/null +++ b/tests/auth/test_metadata_dependencies.py @@ -0,0 +1,83 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.auth import metadata_dependencies + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def _user(**overrides): + data = { + "id": uuid4(), + "keycloak_id": uuid4(), + "username": "old-name", + "email": "old@example.com", + "role": "user", + "is_active": True, + "is_superuser": False, + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "last_login_at": None, + } + data.update(overrides) + return SimpleNamespace(**data) + + +@pytest.mark.anyio +async def test_current_metadata_user_refreshes_keycloak_claim_snapshot(): + keycloak_id = uuid4() + user = _user(keycloak_id=keycloak_id) + refreshed = _user( + id=user.id, + keycloak_id=keycloak_id, + username="alice", + email="alice@example.com", + last_login_at=datetime(2026, 6, 12, tzinfo=timezone.utc), + ) + repo = SimpleNamespace( + get_user_by_keycloak_id=AsyncMock(return_value=user), + refresh_user_keycloak_snapshot=AsyncMock(return_value=refreshed), + ) + + response = await metadata_dependencies.get_current_metadata_user( + { + "sub": str(keycloak_id), + "preferred_username": "alice", + "email": "alice@example.com", + }, + metadata_repo=repo, + ) + + assert response.username == "alice" + repo.get_user_by_keycloak_id.assert_awaited_once_with(keycloak_id) + repo.refresh_user_keycloak_snapshot.assert_awaited_once_with( + user, + username="alice", + email="alice@example.com", + ) + + +@pytest.mark.anyio +async def test_current_metadata_user_rejects_invalid_keycloak_sub(): + repo = SimpleNamespace( + get_user_by_keycloak_id=AsyncMock(), + refresh_user_keycloak_snapshot=AsyncMock(), + ) + + with pytest.raises(HTTPException) as exc: + await metadata_dependencies.get_current_metadata_user( + {"sub": "not-a-uuid"}, + metadata_repo=repo, + ) + + assert exc.value.status_code == 401 + repo.get_user_by_keycloak_id.assert_not_called() + repo.refresh_user_keycloak_snapshot.assert_not_called() diff --git a/tests/unit/test_dynamic_manager.py b/tests/unit/test_dynamic_manager.py new file mode 100644 index 0000000..e8dd81d --- /dev/null +++ b/tests/unit/test_dynamic_manager.py @@ -0,0 +1,12 @@ +from app.infra.db.dynamic_manager import ProjectConnectionManager + + +def test_normalize_pg_url_preserves_password(): + manager = ProjectConnectionManager() + + url = manager._normalize_pg_url( + "postgresql://tjwater:secret@192.168.1.114:5433/tjwater" + ) + + assert url == "postgresql+psycopg://tjwater:secret@192.168.1.114:5433/tjwater" + assert "***" not in url diff --git a/tests/unit/test_metadata_repository_dsn_decrypt.py b/tests/unit/test_metadata_repository_dsn_decrypt.py index e1a74f8..0548d9e 100644 --- a/tests/unit/test_metadata_repository_dsn_decrypt.py +++ b/tests/unit/test_metadata_repository_dsn_decrypt.py @@ -23,6 +23,11 @@ class _DummyEncryptor: self._raise_invalid_token = raise_invalid_token self.encrypted_values = [] + def encrypt(self, value): + encrypted = f"encrypted::{value}" + self.encrypted_values.append(value) + return encrypted + def decrypt(self, _value): if self._raise_invalid_token: raise InvalidToken() @@ -117,3 +122,46 @@ def test_encrypted_dsn_decrypts_without_migration(monkeypatch): assert routing.dsn == "postgresql://u:p%40ss@host/db" session.commit.assert_not_awaited() + + +def test_upsert_project_database_config_encrypts_plaintext_dsn(monkeypatch): + project_id = uuid4() + session = SimpleNamespace( + execute=None, + add=None, + commit=None, + refresh=None, + ) + added = [] + session.execute = AsyncMock(return_value=_DummyResult(None)) + session.add = lambda item: added.append(item) + session.commit = AsyncMock() + session.refresh = AsyncMock() + encryptor = _DummyEncryptor() + repo = MetadataRepository(session) + + monkeypatch.setattr( + "app.infra.db.metadb.repositories.metadata_repository.is_database_encryption_configured", + lambda: True, + ) + monkeypatch.setattr( + "app.infra.db.metadb.repositories.metadata_repository.get_database_encryptor", + lambda: encryptor, + ) + + record = asyncio.run( + repo.upsert_project_database_config( + project_id, + db_role="biz_data", + db_type="postgresql", + dsn="postgresql://user:secret@localhost/db", + pool_min_size=1, + pool_max_size=5, + ) + ) + + assert encryptor.encrypted_values == ["postgresql://user:secret@localhost/db"] + assert record.dsn_encrypted == "encrypted::postgresql://user:secret@localhost/db" + assert added == [record] + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once_with(record) From d99f4cec6a53c8c34ab98123cf81e98544937f17 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 12 Jun 2026 15:28:14 +0800 Subject: [PATCH 49/93] refactor(admin): remove geoserver config --- .env.example | 12 +- app/api/v1/endpoints/admin_metadata.py | 111 +----------------- app/api/v1/endpoints/meta.py | 16 +-- app/api/v1/endpoints/project.py | 14 +-- app/core/config.py | 5 +- app/core/encryption.py | 10 +- app/domain/schemas/admin_metadata.py | 22 ---- app/domain/schemas/metadata.py | 9 -- app/infra/db/metadb/models.py | 20 ---- .../repositories/metadata_repository.py | 106 ----------------- .../005_metadata_project_configuration.sql | 23 ---- tests/api/test_admin_metadata_endpoints.py | 69 ----------- tests/api/test_meta_endpoints.py | 5 - tests/api/test_project_endpoints.py | 12 +- 14 files changed, 14 insertions(+), 420 deletions(-) diff --git a/.env.example b/.env.example index 6822043..0f63d17 100644 --- a/.env.example +++ b/.env.example @@ -4,18 +4,12 @@ ENVIRONMENT="production" NETWORK_NAME="tjwater" # ============================================ -# 安全配置 (必填) +# 敏感配置加密 (必填) # ============================================ -# JWT 密钥 - 用于生成和验证 Token -# 生成方式: openssl rand -hex 32 -SECRET_KEY=your-secret-key-here-change-in-production-use-openssl-rand-hex-32 - -# 数据加密密钥 - Fernet 格式,生产环境必须替换为独立密钥 +# Fernet 格式,生产环境必须替换为独立密钥 # 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -# ENCRYPTION_KEY 用于 GeoServer 管理密码等通用敏感配置 -ENCRYPTION_KEY="replace-with-generated-fernet-key" -# DATABASE_ENCRYPTION_KEY 专用于 project_databases.dsn_encrypted +# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置 DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key" # ============================================ diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py index 7648a25..6143723 100644 --- a/app/api/v1/endpoints/admin_metadata.py +++ b/app/api/v1/endpoints/admin_metadata.py @@ -26,8 +26,6 @@ from app.domain.schemas.admin_metadata import ( ProjectDatabaseResponse, ProjectDatabaseUpsertRequest, ProjectDbRole, - ProjectGeoServerConfigResponse, - ProjectGeoServerConfigUpsertRequest, ProjectMemberCreateRequest, ProjectMemberResponse, ProjectMemberUpdateRequest, @@ -69,23 +67,6 @@ def _project_database_response( ) -def _geoserver_config_response( - record: models.ProjectGeoServerConfig, -) -> ProjectGeoServerConfigResponse: - return ProjectGeoServerConfigResponse( - id=record.id, - project_id=record.project_id, - gs_base_url=record.gs_base_url, - gs_admin_user=record.gs_admin_user, - gs_datastore_name=record.gs_datastore_name, - default_extent=record.default_extent, - srid=record.srid, - configured=True, - has_password=bool(record.gs_admin_password_encrypted), - updated_at=record.updated_at, - ) - - def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict: return { "db_role": payload.db_role, @@ -96,19 +77,6 @@ def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict: } -def _geoserver_audit_payload( - payload: ProjectGeoServerConfigUpsertRequest, -) -> dict: - return { - "gs_base_url": payload.gs_base_url, - "gs_admin_user": payload.gs_admin_user, - "gs_datastore_name": payload.gs_datastore_name, - "default_extent": payload.default_extent, - "srid": payload.srid, - "password_updated": "gs_admin_password" in payload.model_fields_set, - } - - def _to_async_sqlalchemy_url(dsn: str) -> str: parsed = make_url(dsn) if parsed.drivername in {"postgresql", "postgres"}: @@ -123,7 +91,7 @@ def _db_type_for_role(db_role: str) -> str: def _status_for_config_value_error(exc: ValueError) -> int: - if "ENCRYPTION_KEY" in str(exc): + if "DATABASE_ENCRYPTION_KEY" in str(exc): return status.HTTP_503_SERVICE_UNAVAILABLE return status.HTTP_400_BAD_REQUEST @@ -535,83 +503,6 @@ async def check_project_database_health( ) -@router.get( - "/projects/{project_id}/geoserver", - response_model=ProjectGeoServerConfigResponse, -) -async def get_project_geoserver_config( - project_id: UUID = Path(...), - current_user=Depends(get_current_metadata_admin), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), -) -> ProjectGeoServerConfigResponse: - project = await metadata_repo.get_project_by_id(project_id) - if project is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - record = await metadata_repo.get_geoserver_config_record(project_id) - if record is None: - return ProjectGeoServerConfigResponse( - id=None, - project_id=project_id, - gs_base_url=None, - gs_admin_user=None, - gs_datastore_name="ds_postgis", - default_extent=None, - srid=4326, - configured=False, - has_password=False, - updated_at=None, - ) - return _geoserver_config_response(record) - - -@router.put( - "/projects/{project_id}/geoserver", - response_model=ProjectGeoServerConfigResponse, -) -async def upsert_project_geoserver_config( - payload: ProjectGeoServerConfigUpsertRequest, - project_id: UUID = Path(...), - current_user=Depends(get_current_metadata_admin), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), -) -> ProjectGeoServerConfigResponse: - project = await metadata_repo.get_project_by_id(project_id) - if project is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - try: - record = await metadata_repo.upsert_geoserver_config( - project_id, - gs_base_url=payload.gs_base_url, - gs_admin_user=payload.gs_admin_user, - gs_admin_password=payload.gs_admin_password, - password_update_requested="gs_admin_password" in payload.model_fields_set, - gs_datastore_name=payload.gs_datastore_name, - default_extent=payload.default_extent, - srid=payload.srid, - ) - except ValueError as exc: - raise HTTPException( - status_code=_status_for_config_value_error(exc), - detail=str(exc), - ) from exc - except SQLAlchemyError as exc: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Metadata database error: {exc}", - ) from exc - - await log_audit_event( - action=AuditAction.CONFIG_CHANGE, - user_id=current_user.id, - project_id=project_id, - resource_type="project_geoserver", - resource_id=str(project_id), - request_data=_geoserver_audit_payload(payload), - response_status=status.HTTP_200_OK, - session=metadata_repo.session, - ) - return _geoserver_config_response(record) - - @router.get("/users/{user_id}", response_model=MetadataUserResponse) async def get_metadata_user( user_id: UUID = Path(...), diff --git a/app/api/v1/endpoints/meta.py b/app/api/v1/endpoints/meta.py index c6a6f45..969c471 100644 --- a/app/api/v1/endpoints/meta.py +++ b/app/api/v1/endpoints/meta.py @@ -16,7 +16,6 @@ from app.auth.project_dependencies import ( from app.auth.metadata_dependencies import get_current_metadata_user from app.core.config import settings from app.domain.schemas.metadata import ( - GeoServerConfigResponse, ProjectMetaResponse, ProjectSummaryResponse, ) @@ -34,25 +33,13 @@ async def get_project_metadata( """ 获取项目元数据 - 返回当前项目的完整元数据,包括项目基本信息和GeoServer配置 + 返回当前项目的完整元数据,包括项目基本信息和项目权限 """ project = await metadata_repo.get_project_by_id(ctx.project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) - geoserver = await metadata_repo.get_geoserver_config(ctx.project_id) - geoserver_payload = ( - GeoServerConfigResponse( - gs_base_url=geoserver.gs_base_url, - gs_admin_user=geoserver.gs_admin_user, - gs_datastore_name=geoserver.gs_datastore_name, - default_extent=geoserver.default_extent, - srid=geoserver.srid, - ) - if geoserver - else None - ) return ProjectMetaResponse( project_id=project.id, name=project.name, @@ -62,7 +49,6 @@ async def get_project_metadata( map_extent=project.map_extent, status=project.status, project_role=ctx.project_role, - geoserver=geoserver_payload, ) diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index 95584b6..3b8cc45 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -4,7 +4,7 @@ from fastapi.responses import PlainTextResponse from typing import Any, Dict, List from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository from app.auth.project_dependencies import get_metadata_repository -from app.domain.schemas.metadata import ProjectMetaResponse, GeoServerConfigResponse +from app.domain.schemas.metadata import ProjectMetaResponse import app.services.project_info as project_info from app.infra.db.postgresql.database import get_database_instance as get_pg_db from app.infra.db.timescaledb.database import get_database_instance as get_ts_db @@ -55,17 +55,6 @@ async def get_project_info_endpoint( project_detail = await metadata_repo.get_project_detail_by_code(network) if not project_detail: raise HTTPException(status_code=404, detail=f"Project {network} not found") - - geoserver_payload = None - if project_detail.geoserver: - geoserver_payload = GeoServerConfigResponse( - gs_base_url=project_detail.geoserver.gs_base_url, - gs_admin_user=project_detail.geoserver.gs_admin_user, - gs_datastore_name=project_detail.geoserver.gs_datastore_name, - default_extent=project_detail.geoserver.default_extent, - srid=project_detail.geoserver.srid, - ) - return ProjectMetaResponse( project_id=project_detail.project_id, name=project_detail.name, @@ -75,7 +64,6 @@ async def get_project_info_endpoint( map_extent=project_detail.map_extent, status=project_detail.status, project_role="viewer", # Default role for public access - geoserver=geoserver_payload ) @router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。") diff --git a/app/core/config.py b/app/core/config.py index a4bac3f..3aca912 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -11,9 +11,8 @@ class Settings(BaseSettings): NETWORK_NAME: str = "default_network" - # 数据加密密钥 (使用 Fernet) - ENCRYPTION_KEY: str = "" # 必须从环境变量设置 - DATABASE_ENCRYPTION_KEY: str = "" # project_databases.dsn_encrypted 专用 + # 敏感配置加密密钥 (Fernet) + DATABASE_ENCRYPTION_KEY: str = "" # Database Config (PostgreSQL) DB_NAME: str = "tjwater" diff --git a/app/core/encryption.py b/app/core/encryption.py index 9b5f6c2..a14ca62 100644 --- a/app/core/encryption.py +++ b/app/core/encryption.py @@ -20,10 +20,10 @@ class Encryptor: key: 加密密钥,如果为 None 则从环境变量读取 """ if key is None: - key_str = os.getenv("ENCRYPTION_KEY") or settings.ENCRYPTION_KEY + key_str = os.getenv("DATABASE_ENCRYPTION_KEY") or settings.DATABASE_ENCRYPTION_KEY if not key_str: raise ValueError( - "ENCRYPTION_KEY not found in environment variables or .env. " + "DATABASE_ENCRYPTION_KEY not found in environment variables or .env. " "Generate one using: Encryptor.generate_key()" ) key = key_str.encode() @@ -80,15 +80,13 @@ _database_encryptor: Optional[Encryptor] = None def is_encryption_configured() -> bool: - return bool(os.getenv("ENCRYPTION_KEY") or settings.ENCRYPTION_KEY) + return is_database_encryption_configured() def is_database_encryption_configured() -> bool: return bool( os.getenv("DATABASE_ENCRYPTION_KEY") or settings.DATABASE_ENCRYPTION_KEY - or os.getenv("ENCRYPTION_KEY") - or settings.ENCRYPTION_KEY ) @@ -107,8 +105,6 @@ def get_database_encryptor() -> Encryptor: key_str = ( os.getenv("DATABASE_ENCRYPTION_KEY") or settings.DATABASE_ENCRYPTION_KEY - or os.getenv("ENCRYPTION_KEY") - or settings.ENCRYPTION_KEY ) if not key_str: raise ValueError( diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py index 0138348..98d2d57 100644 --- a/app/domain/schemas/admin_metadata.py +++ b/app/domain/schemas/admin_metadata.py @@ -132,25 +132,3 @@ class ProjectDatabaseHealthResponse(BaseModel): db_type: str ok: bool detail: str - - -class ProjectGeoServerConfigUpsertRequest(BaseModel): - gs_base_url: str | None = None - gs_admin_user: str | None = Field(default=None, max_length=50) - gs_admin_password: str | None = Field(default=None, min_length=1) - gs_datastore_name: str = Field(default="ds_postgis", min_length=1, max_length=100) - default_extent: dict | None = None - srid: int = Field(default=4326, ge=1) - - -class ProjectGeoServerConfigResponse(BaseModel): - id: UUID | None = None - project_id: UUID - gs_base_url: str | None = None - gs_admin_user: str | None = None - gs_datastore_name: str - default_extent: dict | None = None - srid: int - configured: bool = True - has_password: bool - updated_at: datetime | None = None diff --git a/app/domain/schemas/metadata.py b/app/domain/schemas/metadata.py index db3220a..f161a5f 100644 --- a/app/domain/schemas/metadata.py +++ b/app/domain/schemas/metadata.py @@ -4,14 +4,6 @@ from uuid import UUID from pydantic import BaseModel -class GeoServerConfigResponse(BaseModel): - gs_base_url: Optional[str] = None - gs_admin_user: Optional[str] = None - gs_datastore_name: str - default_extent: Optional[dict] = None - srid: int - - class ProjectMetaResponse(BaseModel): project_id: UUID name: str @@ -21,7 +13,6 @@ class ProjectMetaResponse(BaseModel): map_extent: Optional[dict] = None status: str project_role: str - geoserver: Optional[GeoServerConfigResponse] = None class ProjectSummaryResponse(BaseModel): diff --git a/app/infra/db/metadb/models.py b/app/infra/db/metadb/models.py index 6236080..5588954 100644 --- a/app/infra/db/metadb/models.py +++ b/app/infra/db/metadb/models.py @@ -64,26 +64,6 @@ class ProjectDatabase(Base): pool_max_size: Mapped[int] = mapped_column(Integer, default=10) -class ProjectGeoServerConfig(Base): - __tablename__ = "project_geoserver_configs" - - id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True) - project_id: Mapped[UUID] = mapped_column( - PGUUID(as_uuid=True), unique=True, index=True - ) - gs_base_url: Mapped[str | None] = mapped_column(Text, nullable=True) - gs_admin_user: Mapped[str | None] = mapped_column(String(50), nullable=True) - gs_admin_password_encrypted: Mapped[str | None] = mapped_column( - Text, nullable=True - ) - gs_datastore_name: Mapped[str] = mapped_column(String(100), default="ds_postgis") - default_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True) - srid: Mapped[int] = mapped_column(Integer, default=4326) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=datetime.utcnow - ) - - class UserProjectMembership(Base): __tablename__ = "user_project_membership" diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index 2555fc1..f35ef71 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -9,9 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.encryption import ( get_database_encryptor, - get_encryptor, is_database_encryption_configured, - is_encryption_configured, ) from app.infra.db.metadb import models @@ -44,17 +42,6 @@ class ProjectDbRouting: pool_max_size: int -@dataclass(frozen=True) -class ProjectGeoServerInfo: - project_id: UUID - gs_base_url: Optional[str] - gs_admin_user: Optional[str] - gs_admin_password: Optional[str] - gs_datastore_name: str - default_extent: Optional[dict] - srid: int - - @dataclass(frozen=True) class ProjectSummary: project_id: UUID @@ -76,7 +63,6 @@ class ProjectDetail: gs_workspace: str map_extent: Optional[dict] status: str - geoserver: Optional[ProjectGeoServerInfo] @dataclass(frozen=True) @@ -100,12 +86,6 @@ def _encrypt_database_secret(value: str) -> str: return get_database_encryptor().encrypt(value) -def _encrypt_general_secret(value: str) -> str: - if not is_encryption_configured(): - raise ValueError("ENCRYPTION_KEY is not configured") - return get_encryptor().encrypt(value) - - class MetadataRepository: """元数据访问层(system_hub)""" @@ -276,9 +256,6 @@ class MetadataRepository: project = await self.get_project_by_code(code) if not project: return None - - geoserver = await self.get_geoserver_config(project.id) - return ProjectDetail( project_id=project.id, name=project.name, @@ -287,7 +264,6 @@ class MetadataRepository: gs_workspace=project.gs_workspace, map_extent=project.map_extent, status=project.status, - geoserver=geoserver ) async def get_membership_role( @@ -469,88 +445,6 @@ class MetadataRepository: pool_max_size=record.pool_max_size, ) - async def get_geoserver_config( - self, project_id: UUID - ) -> Optional[ProjectGeoServerInfo]: - result = await self.session.execute( - select(models.ProjectGeoServerConfig).where( - models.ProjectGeoServerConfig.project_id == project_id - ) - ) - record = result.scalar_one_or_none() - if not record: - return None - if record.gs_admin_password_encrypted: - if is_encryption_configured(): - encryptor = get_encryptor() - password = encryptor.decrypt(record.gs_admin_password_encrypted) - else: - password = record.gs_admin_password_encrypted - else: - password = None - return ProjectGeoServerInfo( - project_id=record.project_id, - gs_base_url=record.gs_base_url, - gs_admin_user=record.gs_admin_user, - gs_admin_password=password, - gs_datastore_name=record.gs_datastore_name, - default_extent=record.default_extent, - srid=record.srid, - ) - - async def get_geoserver_config_record( - self, project_id: UUID - ) -> Optional[models.ProjectGeoServerConfig]: - result = await self.session.execute( - select(models.ProjectGeoServerConfig).where( - models.ProjectGeoServerConfig.project_id == project_id - ) - ) - return result.scalar_one_or_none() - - async def upsert_geoserver_config( - self, - project_id: UUID, - *, - gs_base_url: str | None, - gs_admin_user: str | None, - gs_admin_password: str | None, - password_update_requested: bool, - gs_datastore_name: str, - default_extent: dict | None, - srid: int, - ) -> models.ProjectGeoServerConfig: - record = await self.get_geoserver_config_record(project_id) - encrypted_password: str | None = None - if password_update_requested and gs_admin_password is not None: - encrypted_password = _encrypt_general_secret(gs_admin_password) - - if record is None: - record = models.ProjectGeoServerConfig( - id=uuid4(), - project_id=project_id, - gs_base_url=gs_base_url, - gs_admin_user=gs_admin_user, - gs_admin_password_encrypted=encrypted_password, - gs_datastore_name=gs_datastore_name, - default_extent=default_extent, - srid=srid, - updated_at=_utcnow(), - ) - self.session.add(record) - else: - record.gs_base_url = gs_base_url - record.gs_admin_user = gs_admin_user - if password_update_requested: - record.gs_admin_password_encrypted = encrypted_password - record.gs_datastore_name = gs_datastore_name - record.default_extent = default_extent - record.srid = srid - record.updated_at = _utcnow() - await self.session.commit() - await self.session.refresh(record) - return record - async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]: stmt = ( select(models.Project, models.UserProjectMembership.project_role) diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql index 1d6f910..d3f9e12 100644 --- a/resources/sql/005_metadata_project_configuration.sql +++ b/resources/sql/005_metadata_project_configuration.sql @@ -53,26 +53,3 @@ CREATE INDEX IF NOT EXISTS idx_project_databases_project_id ON project_databases(project_id); CREATE INDEX IF NOT EXISTS idx_project_databases_role ON project_databases(db_role); - -CREATE TABLE IF NOT EXISTS project_geoserver_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID UNIQUE NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - gs_base_url TEXT, - gs_admin_user VARCHAR(50), - gs_admin_password_encrypted TEXT, - gs_datastore_name VARCHAR(100) DEFAULT 'ds_postgis' NOT NULL, - default_extent JSONB, - srid INTEGER DEFAULT 4326 NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - CONSTRAINT project_geoserver_configs_srid_check CHECK (srid >= 1) -); - -CREATE INDEX IF NOT EXISTS idx_project_geoserver_configs_project_id - ON project_geoserver_configs(project_id); - -DROP TRIGGER IF EXISTS update_project_geoserver_configs_updated_at - ON project_geoserver_configs; -CREATE TRIGGER update_project_geoserver_configs_updated_at - BEFORE UPDATE ON project_geoserver_configs - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); diff --git a/tests/api/test_admin_metadata_endpoints.py b/tests/api/test_admin_metadata_endpoints.py index e76c4fa..579ede4 100644 --- a/tests/api/test_admin_metadata_endpoints.py +++ b/tests/api/test_admin_metadata_endpoints.py @@ -15,7 +15,6 @@ from app.domain.schemas.admin_metadata import ( MetadataUserSyncRequest, MetadataUserUpdateRequest, ProjectDatabaseUpsertRequest, - ProjectGeoServerConfigUpsertRequest, ProjectMemberCreateRequest, ProjectMemberUpdateRequest, ) @@ -75,22 +74,6 @@ def _database_config(**overrides): return SimpleNamespace(**data) -def _geoserver_config(**overrides): - data = { - "id": uuid4(), - "project_id": uuid4(), - "gs_base_url": "http://geoserver", - "gs_admin_user": "admin", - "gs_admin_password_encrypted": "encrypted-password", - "gs_datastore_name": "ds_postgis", - "default_extent": {"bbox": [1, 2, 3, 4]}, - "srid": 4326, - "updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc), - } - data.update(overrides) - return SimpleNamespace(**data) - - def test_to_async_sqlalchemy_url_preserves_password(): url = admin_metadata._to_async_sqlalchemy_url( "postgresql://tjwater:secret@192.168.1.114:5433/tjwater" @@ -429,58 +412,6 @@ async def test_project_database_health_sanitizes_password_failures(monkeypatch): assert "psycopg" not in response.detail -@pytest.mark.anyio -async def test_upsert_geoserver_config_hides_password_and_audits_without_plaintext(monkeypatch): - project_id = uuid4() - record = _geoserver_config(project_id=project_id) - repo = SimpleNamespace( - session=object(), - get_project_by_id=AsyncMock(return_value=_project(id=project_id)), - upsert_geoserver_config=AsyncMock(return_value=record), - ) - monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) - - response = await admin_metadata.upsert_project_geoserver_config( - ProjectGeoServerConfigUpsertRequest( - gs_base_url="http://geoserver", - gs_admin_user="admin", - gs_admin_password="secret-password", - gs_datastore_name="ds_postgis", - default_extent={"bbox": [1, 2, 3, 4]}, - srid=4326, - ), - project_id=project_id, - current_user=_user(role="admin", is_superuser=True), - metadata_repo=repo, - ) - - assert response.has_password is True - assert "password" not in response.model_dump() - request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"] - assert request_data["password_updated"] is True - assert "secret-password" not in str(request_data) - - -@pytest.mark.anyio -async def test_get_geoserver_config_returns_empty_state_when_unconfigured(): - project_id = uuid4() - repo = SimpleNamespace( - get_project_by_id=AsyncMock(return_value=_project(id=project_id)), - get_geoserver_config_record=AsyncMock(return_value=None), - ) - - response = await admin_metadata.get_project_geoserver_config( - project_id=project_id, - current_user=_user(role="admin", is_superuser=True), - metadata_repo=repo, - ) - - assert response.project_id == project_id - assert response.configured is False - assert response.has_password is False - assert response.gs_datastore_name == "ds_postgis" - - @pytest.mark.anyio async def test_metadata_admin_dependency_rejects_non_admin_user(): with pytest.raises(HTTPException) as exc: diff --git a/tests/api/test_meta_endpoints.py b/tests/api/test_meta_endpoints.py index 2313b03..899c49e 100644 --- a/tests/api/test_meta_endpoints.py +++ b/tests/api/test_meta_endpoints.py @@ -36,7 +36,6 @@ def test_meta_project_returns_map_extent(monkeypatch): project_id = uuid4() repo = SimpleNamespace( get_project_by_id=lambda _project_id: None, - get_geoserver_config=lambda _project_id: None, ) async def get_project_by_id(_project_id): @@ -50,11 +49,7 @@ def test_meta_project_returns_map_extent(monkeypatch): status="active", ) - async def get_geoserver_config(_project_id): - return None - repo.get_project_by_id = get_project_by_id - repo.get_geoserver_config = get_geoserver_config app = build_test_app(module.router, "/api/v1") app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace( diff --git a/tests/api/test_project_endpoints.py b/tests/api/test_project_endpoints.py index 374f4ad..d92c375 100644 --- a/tests/api/test_project_endpoints.py +++ b/tests/api/test_project_endpoints.py @@ -84,7 +84,7 @@ def test_project_info_returns_404_when_missing(monkeypatch): assert response.json()["detail"] == "Project missing not found" -def test_project_info_returns_geoserver_payload(monkeypatch): +def test_project_info_returns_project_workspace(monkeypatch): module = _load_project_module(monkeypatch) detail = SimpleNamespace( project_id=uuid4(), @@ -94,13 +94,6 @@ def test_project_info_returns_geoserver_payload(monkeypatch): gs_workspace="ws", map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}, status="active", - geoserver=SimpleNamespace( - gs_base_url="http://gs", - gs_admin_user="admin", - gs_datastore_name="store", - default_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}, - srid=4326, - ), ) repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=detail)) app = build_test_app(module.router, "/api/v1") @@ -112,7 +105,8 @@ def test_project_info_returns_geoserver_payload(monkeypatch): assert response.status_code == 200 payload = response.json() assert payload["code"] == "demo" - assert payload["geoserver"]["gs_base_url"] == "http://gs" + assert payload["gs_workspace"] == "ws" + assert "geoserver" not in payload def test_open_project_returns_network_even_when_db_connection_fails(monkeypatch): From 5a55d6500216f396db1b35121eae1bf7b465244f Mon Sep 17 00:00:00 2001 From: Jiang Date: Sat, 13 Jun 2026 13:07:16 +0800 Subject: [PATCH 50/93] refactor(api): add kebab-case legacy aliases --- BACKEND_NAMING_AUDIT.md | 77 ++++++++++++++++++++++++++++++ app/api/v1/endpoints/misc.py | 3 +- app/api/v1/endpoints/project.py | 6 ++- app/api/v1/endpoints/schemes.py | 3 +- app/api/v1/endpoints/simulation.py | 18 ++++--- 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 BACKEND_NAMING_AUDIT.md diff --git a/BACKEND_NAMING_AUDIT.md b/BACKEND_NAMING_AUDIT.md new file mode 100644 index 0000000..dc5572b --- /dev/null +++ b/BACKEND_NAMING_AUDIT.md @@ -0,0 +1,77 @@ +# Backend Naming Audit + +DOC-003 audit for the internal `TJWaterServerBinary` backend. + +## Scope + +Reviewed FastAPI route decorators under `app/api/v1/endpoints`, router prefixes in `app/api/v1/router.py`, and public request/response schema fields in `app/api` and `app/domain`. + +The backend is mounted only under `/api/v1` from `app/main.py`; the old no-prefix router include remains commented out. + +## Current Good Surface + +These newer routes already follow the naming rule for public HTTP paths: + +- Metadata/admin: `/api/v1/admin/projects`, `/api/v1/admin/users/sync`, `/api/v1/admin/projects/{project_id}/members` +- Audit: `/api/v1/audit/logs`, `/api/v1/audit/logs/count` +- Agent auth: `/api/v1/agent/auth/context` +- Business APIs: `/api/v1/burst-detection/detect`, `/api/v1/burst-location/locate`, `/api/v1/leakage/identify` +- Time-series APIs: `/api/v1/scada/by-ids-time-range`, `/api/v1/scada/by-ids-field-time-range`, `/api/v1/composite/clean-scada` +- Project data APIs: `/api/v1/scada-info`, `/api/v1/scheme-list`, `/api/v1/burst-locate-result` +- Web integrations: `/api/v1/web-search`, `/api/v1/geocode` + +Path template parameters such as `{project_id}`, `{user_id}`, `{device_id}`, `{scheme_name}`, and `{link_id}` intentionally remain `snake_case`. + +## Legacy URL Categories + +### Keep With Compatibility + +These now have `kebab-case` aliases. The frontend has been migrated to the replacement paths; keep the old paths as deprecated compatibility aliases for Agent planning, tests, customer scripts, or external callers: + +| Current URL | Suggested replacement | +| --- | --- | +| `/api/v1/openproject/` | `/api/v1/projects/open` | +| `/api/v1/project_info/` | `/api/v1/project-info` | +| `/api/v1/getallschemes/` | `/api/v1/schemes` | +| `/api/v1/getallsensorplacements/` | `/api/v1/sensor-placement-schemes` | +| `/api/v1/sensorplacementscheme/create` | `/api/v1/sensor-placement-schemes` | +| `/api/v1/burst_analysis/` | `/api/v1/burst-analysis` | +| `/api/v1/valve_isolation_analysis/` | `/api/v1/valve-isolation-analysis` | +| `/api/v1/flushing_analysis/` | `/api/v1/flushing-analysis` | +| `/api/v1/contaminant_simulation/` | `/api/v1/contaminant-simulation` | +| `/api/v1/runsimulationmanuallybydate/` | `/api/v1/simulations/run-by-date` | + +### Broad Legacy Surface + +These route groups expose many command-style concatenated paths. They should not be copied into new work; replace only when a caller migration is planned: + +- Project lifecycle: `listprojects`, `createproject`, `deleteproject`, `isprojectopen`, `closeproject`, `copyproject`, `importinp`, `exportinp`, `readinp`, `dumpinp`, `lockproject`, `unlockproject` +- Network object CRUD: `addjunction`, `getjunctionelevation`, `setpipediameter`, `getvalvesetting`, and similar junction/pipe/pump/tank/reservoir/valve routes +- Region/DMA/VD commands: `calculatedistrictmeteringareaforregion`, `getdistrictmeteringarea`, `generatevirtualdistrict`, and related routes +- SCADA native CRUD: `getscadadevice`, `setscadadevicedata`, `cleanscadaelement`, and related routes +- Snapshot/cache utilities: `takesnapshotforoperation`, `syncwithserver`, `clearrediskey`, `queryredis` +- Advanced simulation endpoints with underscore paths: `pressure_regulation`, `daily_scheduling_analysis`, `network_update`, `pressure_sensor_placement_kmeans` + +### Direct Cleanup Candidates + +These are likely safe only after confirming no caller uses them: + +- `/api/v1/test_dict/`: development/test utility in `misc.py`. +- `/api/v1/takenapshotforcurrentoperation`: typo compatibility path; keep deprecated if any client may still call it. +- `/api/v1/getpumpenergyproperties//` and `/api/v1/setpumpenergyproperties//`: double-slash paths in options endpoints. + +## Field Naming + +Most public JSON, query, and SSE fields are already `snake_case`, including `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `device_ids`, `session_id`, and `request_id`. + +Known legacy exception: + +- `BurstAnalysis.burst_ID` in `app/api/v1/endpoints/simulation.py` should become `burst_id` on a new API contract. Preserve `burst_ID` only for the legacy body shape. + +Headers keep standard HTTP casing: + +- `X-Project-Id` + +## Recommendation + +Do not rename existing legacy routes in place. For each active legacy route, keep the new `kebab-case` alias as the documented path, keep the old route marked deprecated, migrate remaining Agent/customer/script callers, then remove only after a documented compatibility window. diff --git a/app/api/v1/endpoints/misc.py b/app/api/v1/endpoints/misc.py index 248032f..5500268 100644 --- a/app/api/v1/endpoints/misc.py +++ b/app/api/v1/endpoints/misc.py @@ -28,7 +28,8 @@ async def fastapi_get_json(): ) -@router.get("/getallsensorplacements/", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息") +@router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息") +@router.get("/getallsensorplacements/", summary="获取所有传感器位置(旧路径)", description="获取网络中所有传感器的放置位置信息", deprecated=True) async def fastapi_get_all_sensor_placements(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: """ 获取所有传感器位置 diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index 3b8cc45..c4424ba 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -42,7 +42,8 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere. router = APIRouter() lockedPrjs: Dict[str, str] = {} -@router.get("/project_info/", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse) +@router.get("/project-info", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse) +@router.get("/project_info/", summary="获取项目信息(旧路径)", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse, deprecated=True) async def get_project_info_endpoint( network: str = Query(..., description="管网名称(或项目代码)"), metadata_repo: MetadataRepository = Depends(get_metadata_repository), @@ -121,7 +122,8 @@ async def is_project_open_endpoint( """ return is_project_open(network) -@router.post("/openproject/", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。") +@router.post("/projects/open", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。") +@router.post("/openproject/", summary="打开项目(旧路径)", description="将指定项目加载到内存中,并初始化数据库连接池。", deprecated=True) async def open_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)") ): diff --git a/app/api/v1/endpoints/schemes.py b/app/api/v1/endpoints/schemes.py index 3b650e4..a08746e 100644 --- a/app/api/v1/endpoints/schemes.py +++ b/app/api/v1/endpoints/schemes.py @@ -22,7 +22,8 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称 """ return get_scheme(network, schema_name) -@router.get("/getallschemes/", summary="获取所有方案", description="获取指定网络的所有方案信息") +@router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息") +@router.get("/getallschemes/", summary="获取所有方案(旧路径)", description="获取指定网络的所有方案信息", deprecated=True) async def fastapi_get_all_schemes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: """ 获取所有方案列表 diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 27be343..c8ad1f4 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -188,7 +188,8 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输 # Analysis Endpoints -@router.get("/burst_analysis/", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") +@router.get("/burst-analysis", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") +@router.get("/burst_analysis/", summary="爆管分析(高级,旧路径)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。", deprecated=True) async def fastapi_burst_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"), @@ -249,7 +250,8 @@ async def fastapi_valve_close_analysis( return result or "success" -@router.get("/valve_isolation_analysis/", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。") +@router.get("/valve-isolation-analysis", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。") +@router.get("/valve_isolation_analysis/", summary="阀门隔离分析(旧路径)", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。", deprecated=True) async def valve_isolation_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"), @@ -289,7 +291,8 @@ async def valve_isolation_endpoint( return result -@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") +@router.get("/flushing-analysis", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") +@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级,旧路径)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", deprecated=True) async def fastapi_flushing_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"), @@ -329,7 +332,8 @@ async def fastapi_flushing_analysis( return result or "success" -@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。") +@router.get("/contaminant-simulation", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。") +@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟(旧路径)", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。", deprecated=True) async def fastapi_contaminant_simulation( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"), @@ -715,7 +719,8 @@ async def fastapi_pressure_sensor_placement_kmeans( ) -@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。") +@router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。") +@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建(旧路径)", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。", deprecated=True) async def fastapi_pressure_sensor_placement( network: str = Query(..., description="管网名称(或数据库名称)"), scheme_name: str = Query(..., description="放置方案名称"), @@ -763,7 +768,8 @@ async def fastapi_pressure_sensor_placement( return "success" -@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") +@router.post("/simulations/run-by-date", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") +@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟(旧路径)", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。", deprecated=True) async def fastapi_run_simulation_manually_by_date( data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"), ) -> dict[str, str]: From 80ca985c28dcbc66224c133592235926aca54564 Mon Sep 17 00:00:00 2001 From: Jiang Date: Sat, 13 Jun 2026 13:56:44 +0800 Subject: [PATCH 51/93] fix(cli): use renamed backend APIs --- cli/tjwater_cli/commands_analysis.py | 10 +++++----- cli/tjwater_cli/commands_data.py | 2 +- cli/tjwater_cli/registry.py | 6 +++--- cli/tjwater_cli_endpoint_scope.md | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 1c2592e..c4b673a 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -53,7 +53,7 @@ def simulation_run( ctx, summary="触发模拟成功", method="POST", - path="/runsimulationmanuallybydate/", + path="/simulations/run-by-date", json_body=body, require_auth=True, require_network_ctx=True, @@ -87,7 +87,7 @@ def analysis_burst( ctx, summary="爆管分析执行成功", method="GET", - path="/burst_analysis/", + path="/burst-analysis", params=params, require_auth=True, require_network_ctx=True, @@ -151,7 +151,7 @@ def analysis_valve( ctx, summary="阀门隔离分析执行成功", method="GET", - path="/valve_isolation_analysis/", + path="/valve-isolation-analysis", params=params, require_auth=True, require_network_ctx=True, @@ -186,7 +186,7 @@ def analysis_flushing( ctx, summary="冲洗分析执行成功", method="GET", - path="/flushing_analysis/", + path="/flushing-analysis", params=params, require_auth=True, require_network_ctx=True, @@ -240,7 +240,7 @@ def analysis_contaminant( ctx, summary="污染物模拟执行成功", method="GET", - path="/contaminant_simulation/", + path="/contaminant-simulation", params=params, require_auth=True, require_network_ctx=True, diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py index 69b8968..5e0810b 100644 --- a/cli/tjwater_cli/commands_data.py +++ b/cli/tjwater_cli/commands_data.py @@ -501,7 +501,7 @@ def data_scheme_list(ctx: typer.Context) -> None: ctx, summary="读取方案列表成功", method="GET", - path="/getallschemes/", + path="/schemes", params={"network": require_network(runtime)}, require_auth=True, require_network_ctx=True, diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 201afd8..540f84e 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -137,7 +137,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("simulation", "run"): CommandDoc( path=("simulation", "run"), summary="触发指定绝对时间的模拟运行", - description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", + description="把显式带时区的 RFC3339 start-time 直接传给 /simulations/run-by-date;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续分钟数", required=True), @@ -211,7 +211,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "contaminant"): CommandDoc( path=("analysis", "contaminant"), summary="执行污染物模拟", - description="调用 /contaminant_simulation/。duration 单位为秒。", + description="调用 /contaminant-simulation。duration 单位为秒。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续秒数", required=True), @@ -499,7 +499,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "scheme", "list"): CommandDoc( path=("data", "scheme", "list"), summary="列出方案", - description="调用 /getallschemes/。", + description="调用 /schemes。", examples=("tjwater-cli data scheme list",), ), } diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index a86dffa..5af650f 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -194,12 +194,12 @@ app/api/v1/endpoints/risk.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | -| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | -| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 | -| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | +| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /simulations/run-by-date` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | +| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst-analysis` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | +| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve-isolation-analysis` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 | +| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing-analysis` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | | `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | -| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant-simulation` | 污染物模拟 | | `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | | `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | | `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | @@ -229,7 +229,7 @@ POST /daily_scheduling_analysis/ - 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。 - `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。 -- 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。 +- 当前 `simulations/run-by-date` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。 - `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。 - 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。 - `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。 From 4c0a4b29e99d8edbbd714e7e122e6ceb27614d51 Mon Sep 17 00:00:00 2001 From: Jiang Date: Sat, 13 Jun 2026 14:57:33 +0800 Subject: [PATCH 52/93] refactor(metadata): drop geoserver config refs --- AUTHENTICATION_AND_USER_MANAGEMENT.md | 21 ++++++++------------- app/api/v1/endpoints/admin_metadata.py | 4 ++-- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md index b4d01a3..5cad69f 100644 --- a/AUTHENTICATION_AND_USER_MANAGEMENT.md +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -51,22 +51,18 @@ Project configuration: - `PUT /api/v1/admin/projects/{project_id}/databases` - `DELETE /api/v1/admin/projects/{project_id}/databases/{db_role}` - `POST /api/v1/admin/projects/{project_id}/databases/{db_role}/health` -- `GET /api/v1/admin/projects/{project_id}/geoserver` -- `PUT /api/v1/admin/projects/{project_id}/geoserver` ## Secret Handling -Admins submit plaintext DSNs and GeoServer passwords only through HTTPS admin -APIs. Operators should not write encrypted columns manually. +Admins submit plaintext DSNs only through HTTPS admin APIs. Operators should not +write encrypted columns manually. - `project_databases.dsn_encrypted` is encrypted with `DATABASE_ENCRYPTION_KEY`. -- `project_geoserver_configs.gs_admin_password_encrypted` is encrypted with - `ENCRYPTION_KEY`. -- Admin responses return only `has_dsn` or `has_password`. +- Admin responses return only `has_dsn`. - Audit logs record whether a secret was updated, but never store plaintext DSNs - or passwords. + or other secrets. -Generate both keys with: +Generate the database encryption key with: ```bash python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" @@ -83,13 +79,12 @@ Apply metadata patches in order: 2. `resources/sql/005_metadata_project_configuration.sql` `004` creates Keycloak-backed metadata users and project memberships. `005` -creates project, project database routing, and GeoServer configuration tables -with uniqueness, role/type, and pool-size constraints. +creates project and project database routing tables with uniqueness, role/type, +and pool-size constraints. ## Frontend System Management `/system-admin` is shown only after `GET /api/v1/admin/me` confirms metadata admin access. The page lets admins maintain metadata users, project members, projects, project database routing for `biz_data` and `iot_data`, connection -health checks, and GeoServer config. This replaces direct SQL editing for normal -project onboarding. +health checks. This replaces direct SQL editing for normal project onboarding. diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py index 6143723..9ecd0ee 100644 --- a/app/api/v1/endpoints/admin_metadata.py +++ b/app/api/v1/endpoints/admin_metadata.py @@ -270,7 +270,7 @@ async def create_admin_project( except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Project code or GeoServer workspace conflicts with an existing project", + detail="Project code or workspace conflicts with an existing project", ) from exc except SQLAlchemyError as exc: raise HTTPException( @@ -307,7 +307,7 @@ async def update_admin_project( except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Project code or GeoServer workspace conflicts with an existing project", + detail="Project code or workspace conflicts with an existing project", ) from exc except SQLAlchemyError as exc: raise HTTPException( From d62bcae85e00906a128bf1a69035b46ab0b3286c Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 8 Jul 2026 17:17:41 +0800 Subject: [PATCH 53/93] fix(burst-location): normalize simulation ids --- app/infra/db/timescaledb/internal_queries.py | 26 +++++++---- app/services/burst_location.py | 45 ++++++++++++++---- tests/unit/test_burst_location_service.py | 48 ++++++++++++++++++++ 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/app/infra/db/timescaledb/internal_queries.py b/app/infra/db/timescaledb/internal_queries.py index fda46f3..c4fcfe9 100644 --- a/app/infra/db/timescaledb/internal_queries.py +++ b/app/infra/db/timescaledb/internal_queries.py @@ -229,7 +229,14 @@ class InternalQueries: scheme_type: str | None = None, scheme_name: str | None = None, ) -> dict[str, list[dict]]: - if not element_ids: + normalized_element_ids = list( + dict.fromkeys( + normalized + for normalized in (str(element_id).strip() for element_id in element_ids) + if normalized + ) + ) + if not normalized_element_ids: return {} start_dt = parse_utc_time(start_time, field_name="start_time") @@ -253,9 +260,9 @@ class InternalQueries: with conn.cursor(row_factory=dict_row) as cur: if schema_name == "scheme": query = sql.SQL( - "SELECT id, time, {} FROM {}.{} " + "SELECT btrim(id::text) AS id, time, {} FROM {}.{} " "WHERE scheme_type = %s AND scheme_name = %s " - "AND time >= %s AND time <= %s AND id = ANY(%s)" + "AND time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)" ).format( sql.Identifier(field), sql.Identifier(schema_name), @@ -268,25 +275,26 @@ class InternalQueries: scheme_name, start_dt, end_dt, - element_ids, + normalized_element_ids, ), ) else: query = sql.SQL( - "SELECT id, time, {} FROM {}.{} " - "WHERE time >= %s AND time <= %s AND id = ANY(%s)" + "SELECT btrim(id::text) AS id, time, {} FROM {}.{} " + "WHERE time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)" ).format( sql.Identifier(field), sql.Identifier(schema_name), sql.Identifier(table_name), ) - cur.execute(query, (start_dt, end_dt, element_ids)) + cur.execute(query, (start_dt, end_dt, normalized_element_ids)) rows = cur.fetchall() result: dict[str, list[dict]] = { - element_id: [] for element_id in element_ids + element_id: [] for element_id in normalized_element_ids } for row in rows: - result.setdefault(row["id"], []).append( + element_id = str(row["id"]).strip() + result.setdefault(element_id, []).append( {"time": row["time"].isoformat(), "value": row[field]} ) for element_id in result: diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 5a6b52b..95acb16 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -40,7 +40,7 @@ def _normalize_series(data: SeriesInput, field_name: str) -> pd.Series: else: raise ValueError(f"Unsupported data format for {field_name}.") - series.index = series.index.map(str) + series.index = series.index.map(_normalize_identifier) return pd.to_numeric(series, errors="raise") @@ -385,6 +385,7 @@ def _build_observed_series_from_scada( data_type: str, series_name: str, ) -> tuple[pd.Series, int]: + sensor_ids = _dedupe_ids(sensor_ids) scada_mapping = _build_scada_mapping(network=network, data_type=data_type) missing_ids = [ sensor_id for sensor_id in sensor_ids if sensor_id not in scada_mapping @@ -400,6 +401,7 @@ def _build_observed_series_from_scada( start_time=start_dt.isoformat(), end_time=end_dt.isoformat(), ) + scada_data = _normalize_timeseries_by_id(scada_data) values: dict[str, float] = {} sample_counts: list[int] = [] for sensor_id, query_id in zip(sensor_ids, query_ids): @@ -427,6 +429,7 @@ def _build_observed_series_from_simulation( simulation_scheme_name: str | None, simulation_scheme_type: str, ) -> tuple[pd.Series, int]: + sensor_ids = _dedupe_ids(sensor_ids) sensor_metadata = _build_sensor_metadata(network=network, data_type=data_type) missing_ids = [ sensor_id for sensor_id in sensor_ids if sensor_id not in sensor_metadata @@ -446,6 +449,7 @@ def _build_observed_series_from_simulation( simulation_scheme_name=simulation_scheme_name, simulation_scheme_type=simulation_scheme_type, ) + simulation_data = _normalize_timeseries_by_id(simulation_data) values: dict[str, float] = {} sample_counts: list[int] = [] for sensor_id in sensor_ids: @@ -476,6 +480,7 @@ def _query_simulation_data_by_sensor_ids( if simulation_source not in {"scheme", "realtime"}: raise ValueError(f"Unsupported simulation_source: {simulation_source}") + sensor_ids = _dedupe_ids(sensor_ids) result: dict[str, list[dict[str, Any]]] = { sensor_id: [] for sensor_id in sensor_ids } @@ -556,6 +561,7 @@ def _query_simulation_values( simulation_scheme_name: str | None, simulation_scheme_type: str, ) -> dict[str, list[dict[str, Any]]]: + element_ids = _dedupe_ids(element_ids) if not element_ids: return {} if simulation_source == "scheme": @@ -595,14 +601,9 @@ def _build_sensor_metadata(network: str, data_type: str) -> dict[str, dict[str, continue else: raise ValueError(f"Unsupported data_type: {data_type}") - element_id = item.get("associated_element_id") - query_id = item.get("api_query_id") - if ( - isinstance(element_id, str) - and element_id - and isinstance(query_id, str) - and query_id - ): + element_id = _normalize_identifier(item.get("associated_element_id")) + query_id = _normalize_identifier(item.get("api_query_id")) + if element_id and query_id: metadata[element_id] = {"query_id": query_id, "scada_type": scada_type} return metadata @@ -638,7 +639,31 @@ def _get_sensor_nodes(network: str, data_type: str) -> list[str]: def _dedupe_ids(ids: list[str] | None) -> list[str]: if ids is None: return [] - return list(dict.fromkeys([str(item) for item in ids if item])) + return list( + dict.fromkeys( + normalized + for normalized in (_normalize_identifier(item) for item in ids) + if normalized + ) + ) + + +def _normalize_identifier(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _normalize_timeseries_by_id( + data: dict[Any, list[dict[str, Any]]] | None, +) -> dict[str, list[dict[str, Any]]]: + normalized_data: dict[str, list[dict[str, Any]]] = {} + for raw_id, records in (data or {}).items(): + normalized_id = _normalize_identifier(raw_id) + if not normalized_id: + continue + normalized_data.setdefault(normalized_id, []).extend(records or []) + return normalized_data def _to_datetime(value: datetime | str) -> datetime: diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index e5132fb..c62c460 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -248,6 +248,54 @@ def test_run_burst_location_requires_simulation_scheme_name(monkeypatch, tmp_pat ) +def test_build_observed_series_from_simulation_normalizes_result_ids(monkeypatch): + module = _load_burst_location_module() + query_calls = [] + + monkeypatch.setattr( + module, + "get_all_scada_info", + lambda network: [ + { + "type": "pressure", + "associated_element_id": " 100026 ", + "api_query_id": " pressure-query ", + } + ], + ) + + def fake_scheme_query(**kwargs): + query_calls.append(kwargs) + return { + 100026: [ + {"time": kwargs["start_time"], "value": 10.0}, + {"time": kwargs["end_time"], "value": 14.0}, + ] + } + + monkeypatch.setattr( + module.InternalQueries, + "query_scheme_simulation_by_ids_timerange", + staticmethod(fake_scheme_query), + ) + + series, sample_count = module._build_observed_series_from_simulation( + network="tjwater", + sensor_ids=["100026"], + start_dt=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + end_dt=datetime(2025, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + data_type="pressure", + series_name="burst_pressure", + simulation_source="scheme", + simulation_scheme_name="BurstSchemeA", + simulation_scheme_type="burst_analysis", + ) + + assert query_calls[0]["element_ids"] == ["100026"] + assert sample_count == 2 + assert series["100026"] == pytest.approx(12.0) + + def test_run_burst_location_monitoring_uses_scada_for_burst_and_realtime_for_normal( monkeypatch, tmp_path ): From 5a91da09044dcde698c4015055ddb01e1ce02bf5 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 8 Jul 2026 17:51:08 +0800 Subject: [PATCH 54/93] fix(burst-location): use correct data sources Simulation mode reads scheme data for both burst and normal observations. Monitoring mode reuses the burst window when no normal window is provided. --- app/api/v1/endpoints/burst_location.py | 6 +- app/services/burst_location.py | 162 ++++++++++++++++------ tests/unit/test_burst_location_service.py | 136 +++++++++++++++--- 3 files changed, 242 insertions(+), 62 deletions(-) diff --git a/app/api/v1/endpoints/burst_location.py b/app/api/v1/endpoints/burst_location.py index bc4023c..0bb36e2 100644 --- a/app/api/v1/endpoints/burst_location.py +++ b/app/api/v1/endpoints/burst_location.py @@ -29,8 +29,10 @@ class BurstLocationRequest(BaseModel): normal_flow: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="正常时的流量数据") min_dpressure: float = Field(2.0, description="最小压力差(bar)") basic_pressure: float = Field(10.0, description="基准压力(bar)") - scada_burst_start: datetime | None = Field(None, description="SCADA爆管开始时间") - scada_burst_end: datetime | None = Field(None, description="SCADA爆管结束时间") + scada_burst_start: datetime | None = Field(None, description="爆管/模拟方案开始时间") + scada_burst_end: datetime | None = Field(None, description="爆管/模拟方案结束时间") + scada_normal_start: datetime | None = Field(None, description="监测数据正常工况开始时间") + scada_normal_end: datetime | None = Field(None, description="监测数据正常工况结束时间") use_scada_flow: bool = Field(False, description="是否使用SCADA流量数据") scheme_name: str | None = Field(None, description="方案名称") simulation_scheme_name: str | None = Field(None, description="模拟方案名称") diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 95acb16..3925b80 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -60,6 +60,8 @@ def run_burst_location_by_network( basic_pressure: float = 10.0, scada_burst_start: datetime | str | None = None, scada_burst_end: datetime | str | None = None, + scada_normal_start: datetime | str | None = None, + scada_normal_end: datetime | str | None = None, use_scada_flow: bool = False, scheme_name: str | None = None, simulation_scheme_name: str | None = None, @@ -87,12 +89,37 @@ def run_burst_location_by_network( for value in [ scada_burst_start, scada_burst_end, + scada_normal_start, + scada_normal_end, ] ) if use_scada_pressure: - burst_start_dt, burst_end_dt = _validate_scada_windows( - scada_burst_start=scada_burst_start, - scada_burst_end=scada_burst_end, + burst_start_dt, burst_end_dt = _validate_time_window( + start_value=scada_burst_start, + end_value=scada_burst_end, + start_field="scada_burst_start", + end_field="scada_burst_end", + label=( + "爆管方案时间窗" + if normalized_data_source == "simulation" + else "爆管时段 SCADA 时间窗" + ), + ) + normal_start_dt: datetime | None = None + normal_end_dt: datetime | None = None + if scada_normal_start is not None or scada_normal_end is not None: + normal_start_dt, normal_end_dt = _validate_time_window( + start_value=scada_normal_start, + end_value=scada_normal_end, + start_field="scada_normal_start", + end_field="scada_normal_end", + label="正常时段 SCADA 时间窗", + ) + + normal_pressure_from_payload = ( + _normalize_series(normal_pressure, "normal_pressure") + if normal_pressure is not None + else None ) if normalized_data_source == "simulation": if not simulation_scheme_name: @@ -117,15 +144,15 @@ def run_burst_location_by_network( ) = _build_observed_series_from_simulation( network=network, sensor_ids=selected_pressure_ids, - start_dt=burst_start_dt, - end_dt=burst_end_dt, + start_dt=normal_start_dt or burst_start_dt, + end_dt=normal_end_dt or burst_end_dt, data_type="pressure", series_name="normal_pressure", - simulation_source="realtime", - simulation_scheme_name=None, + simulation_source="scheme", + simulation_scheme_name=simulation_scheme_name, simulation_scheme_type=resolved_simulation_scheme_type, ) - observed_source = "simulation_scheme_burst_realtime_normal_timerange" + observed_source = "simulation_scheme_timerange" else: ( burst_pressure_series, @@ -138,21 +165,27 @@ def run_burst_location_by_network( data_type="pressure", series_name="burst_pressure", ) - ( - normal_pressure_series, - normal_pressure_samples, - ) = _build_observed_series_from_simulation( - network=network, - sensor_ids=selected_pressure_ids, - start_dt=burst_start_dt, - end_dt=burst_end_dt, - data_type="pressure", - series_name="normal_pressure", - simulation_source="realtime", - simulation_scheme_name=None, - simulation_scheme_type=resolved_simulation_scheme_type, - ) - observed_source = "scada_burst_realtime_normal_timerange" + if normal_pressure_from_payload is None: + ( + normal_pressure_series, + normal_pressure_samples, + ) = _build_observed_series_from_scada( + network=network, + sensor_ids=selected_pressure_ids, + start_dt=normal_start_dt or burst_start_dt, + end_dt=normal_end_dt or burst_end_dt, + data_type="pressure", + series_name="normal_pressure", + ) + observed_source = ( + "scada_burst_scada_normal_timerange" + if normal_start_dt is not None and normal_end_dt is not None + else "scada_timerange" + ) + else: + normal_pressure_series = normal_pressure_from_payload + normal_pressure_samples = 1 + observed_source = "scada_burst_payload_normal_timerange" else: if burst_pressure is None or normal_pressure is None: raise ValueError( @@ -179,6 +212,11 @@ def run_burst_location_by_network( ) if not selected_flow_ids: raise ValueError("未找到可用流量传感器,无法从 SCADA 查询流量数据。") + normal_flow_from_payload = ( + _normalize_series(normal_flow, "normal_flow") + if normal_flow is not None + else None + ) if normalized_data_source == "simulation": if not simulation_scheme_name: raise ValueError("模拟方案模式必须提供 simulation_scheme_name。") @@ -199,12 +237,12 @@ def run_burst_location_by_network( _build_observed_series_from_simulation( network=network, sensor_ids=selected_flow_ids, - start_dt=burst_start_dt, - end_dt=burst_end_dt, + start_dt=normal_start_dt or burst_start_dt, + end_dt=normal_end_dt or burst_end_dt, data_type="flow", series_name="normal_flow", - simulation_source="realtime", - simulation_scheme_name=None, + simulation_source="scheme", + simulation_scheme_name=simulation_scheme_name, simulation_scheme_type=resolved_simulation_scheme_type, ) ) @@ -217,19 +255,20 @@ def run_burst_location_by_network( data_type="flow", series_name="burst_flow", ) - normal_flow_series, normal_flow_samples = ( - _build_observed_series_from_simulation( - network=network, - sensor_ids=selected_flow_ids, - start_dt=burst_start_dt, - end_dt=burst_end_dt, - data_type="flow", - series_name="normal_flow", - simulation_source="realtime", - simulation_scheme_name=None, - simulation_scheme_type=resolved_simulation_scheme_type, + if normal_flow_from_payload is None: + normal_flow_series, normal_flow_samples = ( + _build_observed_series_from_scada( + network=network, + sensor_ids=selected_flow_ids, + start_dt=normal_start_dt or burst_start_dt, + end_dt=normal_end_dt or burst_end_dt, + data_type="flow", + series_name="normal_flow", + ) ) - ) + else: + normal_flow_series = normal_flow_from_payload + normal_flow_samples = 1 else: if flow_scada_ids is not None: selected_flow_ids = _dedupe_ids(flow_scada_ids) @@ -281,6 +320,13 @@ def run_burst_location_by_network( "burst_start": burst_start_dt.isoformat(), "burst_end": burst_end_dt.isoformat(), } + if normal_start_dt is not None and normal_end_dt is not None: + payload["scada_window"].update( + { + "normal_start": normal_start_dt.isoformat(), + "normal_end": normal_end_dt.isoformat(), + } + ) if normalized_data_source == "simulation": payload["simulation_scheme"] = { "name": simulation_scheme_name, @@ -376,6 +422,23 @@ def _validate_scada_windows( return burst_start_dt, burst_end_dt +def _validate_time_window( + *, + start_value: datetime | str | None, + end_value: datetime | str | None, + start_field: str, + end_field: str, + label: str, +) -> tuple[datetime, datetime]: + if start_value is None or end_value is None: + raise ValueError(f"{label}必须同时提供 {start_field}/{end_field}。") + start_dt = _to_datetime(start_value) + end_dt = _to_datetime(end_value) + if start_dt >= end_dt: + raise ValueError(f"{label}非法:{start_field} 必须早于 {end_field}。") + return start_dt, end_dt + + def _build_observed_series_from_scada( *, network: str, @@ -392,7 +455,7 @@ def _build_observed_series_from_scada( ] if missing_ids: preview = ", ".join(missing_ids[:10]) - raise ValueError(f"{series_name} 缺少可用 SCADA 映射: {preview}") + raise ValueError(f"{_series_display_name(series_name)} 缺少可用 SCADA 映射: {preview}") query_ids = [scada_mapping[sensor_id] for sensor_id in sensor_ids] scada_data = InternalQueries.query_scada_by_ids_timerange( @@ -410,7 +473,9 @@ def _build_observed_series_from_scada( float(item["value"]) for item in records if item.get("value") is not None ] if not numeric_values: - raise ValueError(f"{series_name} 在时间窗内无有效数据: {sensor_id}") + raise ValueError( + f"{_series_display_name(series_name)} 在时间窗内无有效数据: {sensor_id}" + ) values[sensor_id] = float(sum(numeric_values) / len(numeric_values)) sample_counts.append(len(numeric_values)) @@ -436,7 +501,7 @@ def _build_observed_series_from_simulation( ] if missing_ids: preview = ", ".join(missing_ids[:10]) - raise ValueError(f"{series_name} 缺少可用 SCADA 映射: {preview}") + raise ValueError(f"{_series_display_name(series_name)} 缺少可用 SCADA 映射: {preview}") simulation_data = _query_simulation_data_by_sensor_ids( network=network, @@ -458,13 +523,24 @@ def _build_observed_series_from_simulation( float(item["value"]) for item in records if item.get("value") is not None ] if not numeric_values: - raise ValueError(f"{series_name} 在时间窗内无有效模拟数据: {sensor_id}") + raise ValueError( + f"{_series_display_name(series_name)} 在时间窗内无有效模拟数据: {sensor_id}" + ) values[sensor_id] = float(sum(numeric_values) / len(numeric_values)) sample_counts.append(len(numeric_values)) return pd.Series(values, dtype=float), min(sample_counts) +def _series_display_name(series_name: str) -> str: + return { + "burst_pressure": "爆管压力数据", + "normal_pressure": "正常压力数据", + "burst_flow": "爆管流量数据", + "normal_flow": "正常流量数据", + }.get(series_name, series_name) + + def _query_simulation_data_by_sensor_ids( *, network: str, diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index c62c460..a5f00e0 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -190,7 +190,7 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey use_scada_flow=True, ) - assert result["observed_source"] == "simulation_scheme_burst_realtime_normal_timerange" + assert result["observed_source"] == "simulation_scheme_timerange" assert result["simulation_scheme"] == { "name": "BurstSchemeA", "type": "burst_analysis", @@ -199,22 +199,17 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey assert result["flow_samples"] == {"burst": 4, "normal": 4} assert list(captured["burst_pressure"].index) == ["J1"] assert captured["burst_pressure"]["J1"] == pytest.approx(15.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) + assert captured["normal_pressure"]["J1"] == pytest.approx(15.0) assert captured["burst_flow"]["J2"] == pytest.approx(6.0) assert captured["burst_flow"]["P1"] == pytest.approx(8.0) - assert captured["normal_flow"]["J2"] == pytest.approx(4.0) - assert captured["normal_flow"]["P1"] == pytest.approx(5.0) + assert captured["normal_flow"]["J2"] == pytest.approx(6.0) + assert captured["normal_flow"]["P1"] == pytest.approx(8.0) assert all(call["scheme_name"] == "BurstSchemeA" for call in scheme_calls) - assert len(scheme_calls) == 3 + assert len(scheme_calls) == 6 assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in scheme_calls) assert any(call["element_type"] == "link" and call["field"] == "flow" for call in scheme_calls) assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in scheme_calls) - assert len(realtime_calls) == 3 - assert all(datetime.fromisoformat(call["start_time"]).hour == 0 for call in realtime_calls) - assert all(datetime.fromisoformat(call["end_time"]).hour == 1 for call in realtime_calls) - assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in realtime_calls) - assert any(call["element_type"] == "link" and call["field"] == "flow" for call in realtime_calls) - assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in realtime_calls) + assert realtime_calls == [] assert result["scada_window"] == { "burst_start": "2025-01-01T00:00:00+00:00", "burst_end": "2025-01-01T01:00:00+00:00", @@ -296,7 +291,42 @@ def test_build_observed_series_from_simulation_normalizes_result_ids(monkeypatch assert series["100026"] == pytest.approx(12.0) -def test_run_burst_location_monitoring_uses_scada_for_burst_and_realtime_for_normal( +def test_build_observed_series_from_scada_uses_chinese_error_label(monkeypatch): + module = _load_burst_location_module() + + monkeypatch.setattr( + module, + "get_all_scada_info", + lambda network: [ + { + "type": "pressure", + "associated_element_id": "100026", + "api_query_id": "pressure-query", + } + ], + ) + monkeypatch.setattr( + module.InternalQueries, + "query_scada_by_ids_timerange", + staticmethod(lambda **kwargs: {"pressure-query": []}), + ) + + with pytest.raises(ValueError) as exc_info: + module._build_observed_series_from_scada( + network="tjwater", + sensor_ids=["100026"], + start_dt=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + end_dt=datetime(2025, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + data_type="pressure", + series_name="burst_pressure", + ) + + message = str(exc_info.value) + assert "爆管压力数据 在时间窗内无有效数据: 100026" in message + assert "burst_pressure" not in message + + +def test_run_burst_location_monitoring_uses_scada_for_burst_and_normal( monkeypatch, tmp_path ): module = _load_burst_location_module() @@ -324,10 +354,14 @@ def test_run_burst_location_monitoring_uses_scada_for_burst_and_realtime_for_nor def fake_scada_query(**kwargs): scada_calls.append(kwargs) + start_hour = datetime.fromisoformat(kwargs["start_time"]).astimezone( + timezone(timedelta(hours=8)) + ).hour + values = [20.0, 22.0] if start_hour == 8 else [10.0, 12.0] return { "pressure-query": [ - {"time": kwargs["start_time"], "value": 20.0}, - {"time": kwargs["end_time"], "value": 22.0}, + {"time": kwargs["start_time"], "value": values[0]}, + {"time": kwargs["end_time"], "value": values[1]}, ] } @@ -358,10 +392,78 @@ def test_run_burst_location_monitoring_uses_scada_for_burst_and_realtime_for_nor burst_leakage=1.0, scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_normal_start=datetime(2025, 1, 1, 7, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_normal_end=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), ) - assert result["observed_source"] == "scada_burst_realtime_normal_timerange" - assert len(scada_calls) == 1 - assert len(realtime_calls) == 1 + assert result["observed_source"] == "scada_burst_scada_normal_timerange" + assert len(scada_calls) == 2 + assert len(realtime_calls) == 0 assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) + assert result["scada_window"] == { + "burst_start": "2025-01-01T00:00:00+00:00", + "burst_end": "2025-01-01T01:00:00+00:00", + "normal_start": "2024-12-31T23:00:00+00:00", + "normal_end": "2025-01-01T00:00:00+00:00", + } + + +def test_run_burst_location_monitoring_reuses_burst_window_for_normal( + monkeypatch, tmp_path +): + module = _load_burst_location_module() + captured = {} + scada_calls = [] + + monkeypatch.setattr( + module, + "get_all_scada_info", + lambda network: [ + { + "type": "pressure", + "associated_element_id": "J1", + "api_query_id": "pressure-query", + } + ], + ) + monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) + monkeypatch.setattr( + module, + "run_burst_location", + lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, + ) + monkeypatch.setattr( + module.InternalQueries, + "query_scada_by_ids_timerange", + staticmethod( + lambda **kwargs: scada_calls.append(kwargs) + or { + "pressure-query": [ + {"time": kwargs["start_time"], "value": 20.0}, + {"time": kwargs["end_time"], "value": 22.0}, + ] + } + ), + ) + monkeypatch.setattr( + module.InternalQueries, + "query_realtime_simulation_by_ids_timerange", + staticmethod(lambda **kwargs: pytest.fail("monitoring mode must not query realtime simulation")), + ) + + result = module.run_burst_location_by_network( + network="tjwater", + username="testuser", + data_source="monitoring", + burst_leakage=1.0, + scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), + ) + + assert result["observed_source"] == "scada_timerange" + assert len(scada_calls) == 2 + assert scada_calls[0]["start_time"] == scada_calls[1]["start_time"] + assert scada_calls[0]["end_time"] == scada_calls[1]["end_time"] + assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) + assert captured["normal_pressure"]["J1"] == pytest.approx(21.0) From 76cf6c32bc6f4b0d34eb1573529e6010909c7157 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 8 Jul 2026 18:41:56 +0800 Subject: [PATCH 55/93] fix(agent): expose network context --- app/api/v1/endpoints/agent_auth.py | 2 ++ app/auth/project_dependencies.py | 2 ++ cli/tjwater_cli/core.py | 18 ++++++++++++++++-- tests/api/test_agent_auth_endpoints.py | 2 ++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/api/v1/endpoints/agent_auth.py b/app/api/v1/endpoints/agent_auth.py index c3f91d6..c2637de 100644 --- a/app/api/v1/endpoints/agent_auth.py +++ b/app/api/v1/endpoints/agent_auth.py @@ -20,6 +20,7 @@ class AgentAuthContextResponse(BaseModel): role: str is_superuser: bool project_id: str + network: str project_role: str token_expires_at: str | None = None @@ -43,6 +44,7 @@ async def get_agent_auth_context( role=current_user.role, is_superuser=current_user.is_superuser, project_id=str(ctx.project_id), + network=ctx.project_code, project_role=ctx.project_role, token_expires_at=token_expires_at, ) diff --git a/app/auth/project_dependencies.py b/app/auth/project_dependencies.py index 6513c93..0bcaa43 100644 --- a/app/auth/project_dependencies.py +++ b/app/auth/project_dependencies.py @@ -25,6 +25,7 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True) class ProjectContext: project_id: UUID + project_code: str user_id: UUID project_role: str @@ -85,6 +86,7 @@ async def get_project_context( return ProjectContext( project_id=project.id, + project_code=project.code, user_id=user.id, project_role=membership_role, ) diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 16d7bde..2fb33da 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -404,6 +404,14 @@ def _parse_response_body(response: requests.Response) -> Any: return {} +def _with_network_param(params: dict[str, Any] | None, network: str) -> dict[str, Any]: + params = dict(params or {}) + if "network" in params or "network_name" in params or "name" in params: + return params + params["network"] = network + return params + + def request_json( ctx: RuntimeContext, *, @@ -417,10 +425,13 @@ def request_json( require_username_ctx: bool = False, ) -> tuple[Any, int]: require_server(ctx) + network = None if require_network_ctx: - require_network(ctx) + network = require_network(ctx) if require_username_ctx: require_username(ctx) + if network and (params is not None or json_body is None): + params = _with_network_param(params, network) url = f"{require_server(ctx)}/api/v1{path}" headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) @@ -474,8 +485,11 @@ def request_bytes( require_network_ctx: bool = False, ) -> tuple[bytes, int]: require_server(ctx) + network = None if require_network_ctx: - require_network(ctx) + network = require_network(ctx) + if network: + params = _with_network_param(params, network) url = f"{require_server(ctx)}/api/v1{path}" headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) diff --git a/tests/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py index 1acda78..38fbdb2 100644 --- a/tests/api/test_agent_auth_endpoints.py +++ b/tests/api/test_agent_auth_endpoints.py @@ -28,6 +28,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): client = _build_client( project_context=ProjectContext( project_id=project_id, + project_code="fengyang", user_id=user_id, project_role="editor", ), @@ -50,6 +51,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): "role": "user", "is_superuser": False, "project_id": str(project_id), + "network": "fengyang", "project_role": "editor", "token_expires_at": "2026-06-11T13:10:00+00:00", } From 71fa2ae18ca6914dd46b14cc9ab38a59e6827e2d Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 8 Jul 2026 19:02:54 +0800 Subject: [PATCH 56/93] ci: unzip health model in image build --- .dockerignore | 1 + Dockerfile | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.dockerignore b/.dockerignore index 44aa59a..25bdfa1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,3 +16,4 @@ inp/ # .env *.pyc *.dump +app/algorithms/health/model/my_survival_forest_model_quxi.joblib diff --git a/Dockerfile b/Dockerfile index b10b3b5..bea8fce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ RUN uv pip install --system --no-cache-dir -r requirements.txt # 将代码放入子目录 'app',将数据放入子目录 'db_inp' # 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 COPY app ./app +RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \ + rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip # COPY db_inp ./db_inp COPY .env . RUN mkdir -p db_inp temp data inp From ca97de2e51749d4ea821940ecadedc9de9a9f3c9 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 9 Jul 2026 10:49:53 +0800 Subject: [PATCH 57/93] fix(burst-location): tolerate partial SCADA gaps --- app/services/burst_location.py | 41 +++++++- tests/unit/test_burst_location_service.py | 108 ++++++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 3925b80..4feffec 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -186,6 +186,14 @@ def run_burst_location_by_network( normal_pressure_series = normal_pressure_from_payload normal_pressure_samples = 1 observed_source = "scada_burst_payload_normal_timerange" + selected_pressure_ids, burst_pressure_series, normal_pressure_series = ( + _align_observed_series_pair( + ids=selected_pressure_ids, + burst_series=burst_pressure_series, + normal_series=normal_pressure_series, + data_label="压力数据", + ) + ) else: if burst_pressure is None or normal_pressure is None: raise ValueError( @@ -269,6 +277,14 @@ def run_burst_location_by_network( else: normal_flow_series = normal_flow_from_payload normal_flow_samples = 1 + selected_flow_ids, burst_flow_series, normal_flow_series = ( + _align_observed_series_pair( + ids=selected_flow_ids, + burst_series=burst_flow_series, + normal_series=normal_flow_series, + data_label="流量数据", + ) + ) else: if flow_scada_ids is not None: selected_flow_ids = _dedupe_ids(flow_scada_ids) @@ -439,6 +455,23 @@ def _validate_time_window( return start_dt, end_dt +def _align_observed_series_pair( + *, + ids: list[str], + burst_series: pd.Series, + normal_series: pd.Series, + data_label: str, +) -> tuple[list[str], pd.Series, pd.Series]: + common_ids = [ + sensor_id + for sensor_id in _dedupe_ids(ids) + if sensor_id in burst_series.index and sensor_id in normal_series.index + ] + if not common_ids: + raise ValueError(f"{data_label}没有同时具备爆管时段和正常时段有效数据的点位。") + return common_ids, burst_series.loc[common_ids], normal_series.loc[common_ids] + + def _build_observed_series_from_scada( *, network: str, @@ -473,11 +506,13 @@ def _build_observed_series_from_scada( float(item["value"]) for item in records if item.get("value") is not None ] if not numeric_values: - raise ValueError( - f"{_series_display_name(series_name)} 在时间窗内无有效数据: {sensor_id}" - ) + continue values[sensor_id] = float(sum(numeric_values) / len(numeric_values)) sample_counts.append(len(numeric_values)) + if not values: + raise ValueError( + f"{_series_display_name(series_name)} 在时间窗内无有效数据: {', '.join(sensor_ids[:10])}" + ) return pd.Series(values, dtype=float), min(sample_counts) diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index a5f00e0..ba08a76 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -326,6 +326,51 @@ def test_build_observed_series_from_scada_uses_chinese_error_label(monkeypatch): assert "burst_pressure" not in message +def test_build_observed_series_from_scada_skips_missing_sensor_values(monkeypatch): + module = _load_burst_location_module() + + monkeypatch.setattr( + module, + "get_all_scada_info", + lambda network: [ + {"type": "pressure", "associated_element_id": "J1", "api_query_id": "q1"}, + {"type": "pressure", "associated_element_id": "J2", "api_query_id": "q2"}, + {"type": "pressure", "associated_element_id": "J3", "api_query_id": "q3"}, + ], + ) + monkeypatch.setattr( + module.InternalQueries, + "query_scada_by_ids_timerange", + staticmethod( + lambda **kwargs: { + "q1": [ + {"time": kwargs["start_time"], "value": 10.0}, + {"time": kwargs["end_time"], "value": 12.0}, + ], + "q2": [], + "q3": [ + {"time": kwargs["start_time"], "value": None}, + {"time": kwargs["end_time"], "value": 18.0}, + ], + } + ), + ) + + series, sample_count = module._build_observed_series_from_scada( + network="tjwater", + sensor_ids=["J1", "J2", "J3"], + start_dt=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + end_dt=datetime(2025, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + data_type="pressure", + series_name="burst_pressure", + ) + + assert list(series.index) == ["J1", "J3"] + assert series["J1"] == pytest.approx(11.0) + assert series["J3"] == pytest.approx(18.0) + assert sample_count == 1 + + def test_run_burst_location_monitoring_uses_scada_for_burst_and_normal( monkeypatch, tmp_path ): @@ -467,3 +512,66 @@ def test_run_burst_location_monitoring_reuses_burst_window_for_normal( assert scada_calls[0]["end_time"] == scada_calls[1]["end_time"] assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) assert captured["normal_pressure"]["J1"] == pytest.approx(21.0) + + +def test_run_burst_location_monitoring_aligns_partial_scada_data( + monkeypatch, tmp_path +): + module = _load_burst_location_module() + captured = {} + + monkeypatch.setattr( + module, + "get_all_scada_info", + lambda network: [ + {"type": "pressure", "associated_element_id": "J1", "api_query_id": "q1"}, + {"type": "pressure", "associated_element_id": "J2", "api_query_id": "q2"}, + {"type": "pressure", "associated_element_id": "J3", "api_query_id": "q3"}, + ], + ) + monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) + monkeypatch.setattr( + module, + "run_burst_location", + lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, + ) + + def fake_scada_query(**kwargs): + start_hour = datetime.fromisoformat(kwargs["start_time"]).astimezone( + timezone(timedelta(hours=8)) + ).hour + if start_hour == 8: + return { + "q1": [{"time": kwargs["start_time"], "value": 20.0}], + "q2": [{"time": kwargs["start_time"], "value": 30.0}], + "q3": [], + } + return { + "q1": [{"time": kwargs["start_time"], "value": 10.0}], + "q2": [], + "q3": [{"time": kwargs["start_time"], "value": 12.0}], + } + + monkeypatch.setattr( + module.InternalQueries, + "query_scada_by_ids_timerange", + staticmethod(fake_scada_query), + ) + + result = module.run_burst_location_by_network( + network="tjwater", + username="testuser", + data_source="monitoring", + burst_leakage=1.0, + scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_normal_start=datetime(2025, 1, 1, 7, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_normal_end=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + ) + + assert result["pressure_scada_ids"] == ["J1"] + assert captured["pressure_scada_ids"] == ["J1"] + assert list(captured["burst_pressure"].index) == ["J1"] + assert list(captured["normal_pressure"].index) == ["J1"] + assert captured["burst_pressure"]["J1"] == pytest.approx(20.0) + assert captured["normal_pressure"]["J1"] == pytest.approx(10.0) From baeaa8a2e101e507c962769a09e5202522cfb367 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 9 Jul 2026 11:51:40 +0800 Subject: [PATCH 58/93] fix(burst-location): correct normal data window --- .../burst_location/burst_location.py | 2 +- app/services/burst_location.py | 47 +++--- tests/unit/test_burst_location_service.py | 153 +++++++++++++++--- 3 files changed, 162 insertions(+), 40 deletions(-) diff --git a/app/algorithms/burst_location/burst_location.py b/app/algorithms/burst_location/burst_location.py index 4f4f971..54a0130 100644 --- a/app/algorithms/burst_location/burst_location.py +++ b/app/algorithms/burst_location/burst_location.py @@ -121,7 +121,7 @@ def run_burst_location( basic_pressure: float = 10.0, n_workers: int = DEFAULT_N_WORKERS, partition_on_full_graph: bool = True, - visualize_partition: bool = True, + visualize_partition: bool = False, visualize_pause_seconds: float = 0.3, final_candidates_csv_path: ( str | None diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 4feffec..a1bcf73 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from datetime import datetime +from datetime import datetime, timedelta from typing import Any import pandas as pd @@ -124,6 +124,8 @@ def run_burst_location_by_network( if normalized_data_source == "simulation": if not simulation_scheme_name: raise ValueError("模拟方案模式必须提供 simulation_scheme_name。") + normal_start_dt = burst_start_dt + normal_end_dt = burst_end_dt ( burst_pressure_series, burst_pressure_samples, @@ -144,16 +146,21 @@ def run_burst_location_by_network( ) = _build_observed_series_from_simulation( network=network, sensor_ids=selected_pressure_ids, - start_dt=normal_start_dt or burst_start_dt, - end_dt=normal_end_dt or burst_end_dt, + start_dt=normal_start_dt, + end_dt=normal_end_dt, data_type="pressure", series_name="normal_pressure", - simulation_source="scheme", - simulation_scheme_name=simulation_scheme_name, + simulation_source="realtime", + simulation_scheme_name=None, simulation_scheme_type=resolved_simulation_scheme_type, ) - observed_source = "simulation_scheme_timerange" + observed_source = "simulation_scheme_burst_realtime_normal_timerange" else: + if normal_pressure_from_payload is None and ( + normal_start_dt is None or normal_end_dt is None + ): + normal_start_dt = burst_start_dt - timedelta(days=1) + normal_end_dt = burst_end_dt - timedelta(days=1) ( burst_pressure_series, burst_pressure_samples, @@ -172,16 +179,12 @@ def run_burst_location_by_network( ) = _build_observed_series_from_scada( network=network, sensor_ids=selected_pressure_ids, - start_dt=normal_start_dt or burst_start_dt, - end_dt=normal_end_dt or burst_end_dt, + start_dt=normal_start_dt, + end_dt=normal_end_dt, data_type="pressure", series_name="normal_pressure", ) - observed_source = ( - "scada_burst_scada_normal_timerange" - if normal_start_dt is not None and normal_end_dt is not None - else "scada_timerange" - ) + observed_source = "scada_burst_scada_normal_timerange" else: normal_pressure_series = normal_pressure_from_payload normal_pressure_samples = 1 @@ -245,16 +248,21 @@ def run_burst_location_by_network( _build_observed_series_from_simulation( network=network, sensor_ids=selected_flow_ids, - start_dt=normal_start_dt or burst_start_dt, - end_dt=normal_end_dt or burst_end_dt, + start_dt=normal_start_dt, + end_dt=normal_end_dt, data_type="flow", series_name="normal_flow", - simulation_source="scheme", - simulation_scheme_name=simulation_scheme_name, + simulation_source="realtime", + simulation_scheme_name=None, simulation_scheme_type=resolved_simulation_scheme_type, ) ) else: + if normal_flow_from_payload is None and ( + normal_start_dt is None or normal_end_dt is None + ): + normal_start_dt = burst_start_dt - timedelta(days=1) + normal_end_dt = burst_end_dt - timedelta(days=1) burst_flow_series, burst_flow_samples = _build_observed_series_from_scada( network=network, sensor_ids=selected_flow_ids, @@ -268,8 +276,8 @@ def run_burst_location_by_network( _build_observed_series_from_scada( network=network, sensor_ids=selected_flow_ids, - start_dt=normal_start_dt or burst_start_dt, - end_dt=normal_end_dt or burst_end_dt, + start_dt=normal_start_dt, + end_dt=normal_end_dt, data_type="flow", series_name="normal_flow", ) @@ -313,6 +321,7 @@ def run_burst_location_by_network( normal_flow=normal_flow_series, min_dpressure=min_dpressure, basic_pressure=basic_pressure, + visualize_partition=False, ) payload: dict[str, Any] = { diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index ba08a76..d9e6414 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -190,29 +190,41 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey use_scada_flow=True, ) - assert result["observed_source"] == "simulation_scheme_timerange" + assert result["observed_source"] == "simulation_scheme_burst_realtime_normal_timerange" assert result["simulation_scheme"] == { "name": "BurstSchemeA", "type": "burst_analysis", } assert result["pressure_samples"] == {"burst": 4, "normal": 4} assert result["flow_samples"] == {"burst": 4, "normal": 4} + assert captured["visualize_partition"] is False assert list(captured["burst_pressure"].index) == ["J1"] assert captured["burst_pressure"]["J1"] == pytest.approx(15.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(15.0) + assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) assert captured["burst_flow"]["J2"] == pytest.approx(6.0) assert captured["burst_flow"]["P1"] == pytest.approx(8.0) - assert captured["normal_flow"]["J2"] == pytest.approx(6.0) - assert captured["normal_flow"]["P1"] == pytest.approx(8.0) + assert captured["normal_flow"]["J2"] == pytest.approx(4.0) + assert captured["normal_flow"]["P1"] == pytest.approx(5.0) assert all(call["scheme_name"] == "BurstSchemeA" for call in scheme_calls) - assert len(scheme_calls) == 6 + assert len(scheme_calls) == 3 assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in scheme_calls) assert any(call["element_type"] == "link" and call["field"] == "flow" for call in scheme_calls) assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in scheme_calls) - assert realtime_calls == [] + assert len(realtime_calls) == 3 + assert any(call["element_type"] == "node" and call["field"] == "pressure" for call in realtime_calls) + assert any(call["element_type"] == "link" and call["field"] == "flow" for call in realtime_calls) + assert any(call["element_type"] == "node" and call["field"] == "actual_demand" for call in realtime_calls) + assert {call["start_time"] for call in scheme_calls + realtime_calls} == { + "2025-01-01T00:00:00+00:00" + } + assert {call["end_time"] for call in scheme_calls + realtime_calls} == { + "2025-01-01T01:00:00+00:00" + } assert result["scada_window"] == { "burst_start": "2025-01-01T00:00:00+00:00", "burst_end": "2025-01-01T01:00:00+00:00", + "normal_start": "2025-01-01T00:00:00+00:00", + "normal_end": "2025-01-01T01:00:00+00:00", } @@ -454,7 +466,7 @@ def test_run_burst_location_monitoring_uses_scada_for_burst_and_normal( } -def test_run_burst_location_monitoring_reuses_burst_window_for_normal( +def test_run_burst_location_monitoring_defaults_normal_window_to_previous_day( monkeypatch, tmp_path ): module = _load_burst_location_module() @@ -478,18 +490,26 @@ def test_run_burst_location_monitoring_reuses_burst_window_for_normal( "run_burst_location", lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, ) + + def fake_scada_query(**kwargs): + scada_calls.append(kwargs) + start_time = datetime.fromisoformat(kwargs["start_time"]) + values = ( + [20.0, 22.0] + if start_time.date().isoformat() == "2025-01-01" + else [10.0, 12.0] + ) + return { + "pressure-query": [ + {"time": kwargs["start_time"], "value": values[0]}, + {"time": kwargs["end_time"], "value": values[1]}, + ] + } + monkeypatch.setattr( module.InternalQueries, "query_scada_by_ids_timerange", - staticmethod( - lambda **kwargs: scada_calls.append(kwargs) - or { - "pressure-query": [ - {"time": kwargs["start_time"], "value": 20.0}, - {"time": kwargs["end_time"], "value": 22.0}, - ] - } - ), + staticmethod(fake_scada_query), ) monkeypatch.setattr( module.InternalQueries, @@ -506,12 +526,105 @@ def test_run_burst_location_monitoring_reuses_burst_window_for_normal( scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), ) - assert result["observed_source"] == "scada_timerange" + assert result["observed_source"] == "scada_burst_scada_normal_timerange" assert len(scada_calls) == 2 - assert scada_calls[0]["start_time"] == scada_calls[1]["start_time"] - assert scada_calls[0]["end_time"] == scada_calls[1]["end_time"] + assert datetime.fromisoformat(scada_calls[1]["start_time"]) == ( + datetime.fromisoformat(scada_calls[0]["start_time"]) - timedelta(days=1) + ) + assert datetime.fromisoformat(scada_calls[1]["end_time"]) == ( + datetime.fromisoformat(scada_calls[0]["end_time"]) - timedelta(days=1) + ) assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) - assert captured["normal_pressure"]["J1"] == pytest.approx(21.0) + assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) + assert result["scada_window"] == { + "burst_start": "2025-01-01T00:00:00+00:00", + "burst_end": "2025-01-01T01:00:00+00:00", + "normal_start": "2024-12-31T00:00:00+00:00", + "normal_end": "2024-12-31T01:00:00+00:00", + } + + +def test_run_burst_location_monitoring_flow_uses_previous_day_normal_window( + monkeypatch, tmp_path +): + module = _load_burst_location_module() + captured = {} + scada_calls = [] + + monkeypatch.setattr( + module, + "get_all_scada_info", + lambda network: [ + { + "type": "pressure", + "associated_element_id": "J1", + "api_query_id": "pressure-query", + }, + { + "type": "pipe_flow", + "associated_element_id": "P1", + "api_query_id": "flow-query", + }, + ], + ) + monkeypatch.setattr(module, "_prepare_burst_inp", lambda network: str(tmp_path / "fake.inp")) + monkeypatch.setattr( + module, + "run_burst_location", + lambda **kwargs: captured.update(kwargs) or {"located_pipe": "Pipe-001"}, + ) + + def fake_scada_query(**kwargs): + scada_calls.append(kwargs) + is_burst_day = ( + datetime.fromisoformat(kwargs["start_time"]).date().isoformat() + == "2025-01-01" + ) + if kwargs["device_ids"] == ["pressure-query"]: + values = [20.0, 22.0] if is_burst_day else [10.0, 12.0] + query_id = "pressure-query" + else: + values = [7.0, 9.0] if is_burst_day else [3.0, 5.0] + query_id = "flow-query" + return { + query_id: [ + {"time": kwargs["start_time"], "value": values[0]}, + {"time": kwargs["end_time"], "value": values[1]}, + ] + } + + monkeypatch.setattr( + module.InternalQueries, + "query_scada_by_ids_timerange", + staticmethod(fake_scada_query), + ) + + result = module.run_burst_location_by_network( + network="tjwater", + username="testuser", + data_source="monitoring", + burst_leakage=1.0, + scada_burst_start=datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=8))), + scada_burst_end=datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=8))), + use_scada_flow=True, + ) + + assert result["observed_source"] == "scada_burst_scada_normal_timerange" + assert len(scada_calls) == 4 + for burst_call, normal_call in [ + (scada_calls[0], scada_calls[1]), + (scada_calls[2], scada_calls[3]), + ]: + assert datetime.fromisoformat(normal_call["start_time"]) == ( + datetime.fromisoformat(burst_call["start_time"]) - timedelta(days=1) + ) + assert datetime.fromisoformat(normal_call["end_time"]) == ( + datetime.fromisoformat(burst_call["end_time"]) - timedelta(days=1) + ) + assert captured["burst_pressure"]["J1"] == pytest.approx(21.0) + assert captured["normal_pressure"]["J1"] == pytest.approx(11.0) + assert captured["burst_flow"]["P1"] == pytest.approx(8.0) + assert captured["normal_flow"]["P1"] == pytest.approx(4.0) def test_run_burst_location_monitoring_aligns_partial_scada_data( From f72b56845f2f792b56d313074dccb7c54ac1941b Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 16 Jul 2026 12:07:44 +0800 Subject: [PATCH 59/93] fix(wndb): refresh closed project connections --- app/native/wndb/connection.py | 65 +++++++++++++- app/native/wndb/database.py | 34 +++---- app/native/wndb/project.py | 21 ++--- app/native/wndb/s0_base.py | 94 +++++++++++--------- app/native/wndb/s24_coordinates.py | 11 ++- app/native/wndb/s41_pipe_risk_probability.py | 65 +++++++------- tests/unit/test_wndb_connection.py | 78 ++++++++++++++++ 7 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 tests/unit/test_wndb_connection.py diff --git a/app/native/wndb/connection.py b/app/native/wndb/connection.py index b42b481..7dd5de2 100644 --- a/app/native/wndb/connection.py +++ b/app/native/wndb/connection.py @@ -1,3 +1,66 @@ +from collections.abc import Iterator +from contextlib import contextmanager +from threading import RLock + import psycopg as pg -g_conn_dict : dict[str, pg.Connection] = {} \ No newline at end of file +from app.core.config import get_pgconn_string + +g_conn_dict: dict[str, pg.Connection] = {} +_registry_lock = RLock() +_project_locks: dict[str, RLock] = {} + + +def _is_closed(connection: pg.Connection) -> bool: + return bool(getattr(connection, "closed", False)) + + +def _close_connection(connection: pg.Connection) -> None: + if not _is_closed(connection): + connection.close() + + +def _get_project_lock(name: str) -> RLock: + with _registry_lock: + lock = _project_locks.get(name) + if lock is None: + lock = RLock() + _project_locks[name] = lock + return lock + + +def open_connection(name: str) -> pg.Connection: + with _get_project_lock(name): + connection = g_conn_dict.get(name) + if connection is None or _is_closed(connection): + if connection is not None: + _close_connection(connection) + connection = pg.connect( + conninfo=get_pgconn_string(db_name=name), autocommit=True + ) + g_conn_dict[name] = connection + return connection + + +def is_connection_open(name: str) -> bool: + with _get_project_lock(name): + connection = g_conn_dict.get(name) + if connection is None: + return False + if _is_closed(connection): + del g_conn_dict[name] + return False + return True + + +def close_connection(name: str) -> None: + with _get_project_lock(name): + connection = g_conn_dict.pop(name, None) + if connection is not None: + _close_connection(connection) + + +@contextmanager +def project_connection(name: str) -> Iterator[pg.Connection]: + with _get_project_lock(name): + yield open_connection(name) diff --git a/app/native/wndb/database.py b/app/native/wndb/database.py index 248b4a3..6d2893b 100644 --- a/app/native/wndb/database.py +++ b/app/native/wndb/database.py @@ -1,6 +1,6 @@ from typing import Any from psycopg.rows import dict_row, Row -from .connection import g_conn_dict as conn +from .connection import project_connection API_ADD = 'add' API_UPDATE = 'update' @@ -83,29 +83,33 @@ class DbChangeSet: def read(name: str, sql: str) -> Row: - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(sql) - row = cur.fetchone() - if row == None: - raise Exception(sql) - return row + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(sql) + row = cur.fetchone() + if row == None: + raise Exception(sql) + return row def read_all(name: str, sql: str) -> list[Row]: - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(sql) - return cur.fetchall() + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(sql) + return cur.fetchall() def try_read(name: str, sql: str) -> Row | None: - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(sql) - return cur.fetchone() + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(sql) + return cur.fetchone() def write(name: str, sql: str) -> None: - with conn[name].cursor() as cur: - cur.execute(sql) + with project_connection(name) as conn: + with conn.cursor() as cur: + cur.execute(sql) def get_current_operation(name: str) -> int: diff --git a/app/native/wndb/project.py b/app/native/wndb/project.py index 25b9328..5403d0f 100644 --- a/app/native/wndb/project.py +++ b/app/native/wndb/project.py @@ -2,7 +2,11 @@ import os import psycopg as pg from psycopg import sql from psycopg.rows import dict_row -from .connection import g_conn_dict as conn +from .connection import ( + close_connection, + is_connection_open, + open_connection, +) from app.core.config import get_pgconn_string, get_pg_config, get_pg_password # no undo/redo @@ -31,9 +35,7 @@ def have_project(name: str) -> bool: def copy_project(source: str, new: str) -> None: - if source in conn: - conn[source].close() - del conn[source] + close_connection(source) with pg.connect( conninfo=get_pgconn_string(db_name="postgres"), autocommit=True @@ -176,17 +178,12 @@ def clean_project(excluded: list[str] = []) -> None: def open_project(name: str) -> None: - if name not in conn: - conn[name] = pg.connect( - conninfo=get_pgconn_string(db_name=name), autocommit=True - ) + open_connection(name) def is_project_open(name: str) -> bool: - return name in conn + return is_connection_open(name) def close_project(name: str) -> None: - if name in conn: - conn[name].close() - del conn[name] + close_connection(name) diff --git a/app/native/wndb/s0_base.py b/app/native/wndb/s0_base.py index 65882ab..2986af2 100644 --- a/app/native/wndb/s0_base.py +++ b/app/native/wndb/s0_base.py @@ -1,5 +1,5 @@ from psycopg.rows import dict_row, Row -from .connection import g_conn_dict as conn +from .connection import project_connection from .database import read from typing import Any @@ -47,9 +47,10 @@ ELEMENT_TYPES : dict[str, int] = { } def _get_from(name: str, id: str, base_type: str) -> Row | None: - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select * from {base_type} where id = '{id}'") - return cur.fetchone() + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select * from {base_type} where id = '{id}'") + return cur.fetchone() def is_node(name: str, id: str) -> bool: @@ -125,10 +126,11 @@ def is_region(name: str, id: str) -> bool: def _get_all(name: str, base_type: str) -> list[str]: ids : list[str] = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from {base_type} order by id") - for record in cur: - ids.append(record['id']) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id from {base_type} order by id") + for record in cur: + ids.append(record['id']) return ids @@ -138,29 +140,32 @@ def get_nodes(name: str) -> list[str]: # DingZQ def _get_nodes_by_type(name: str, type: str) -> list[str]: ids : list[str] = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from {_NODE} where type = '{type}' order by id") - for record in cur: - ids.append(record['id']) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id from {_NODE} where type = '{type}' order by id") + for record in cur: + ids.append(record['id']) return ids # DingZQ def get_nodes_id_and_type(name: str) -> dict[str, str]: nodes_id_and_type: dict[str, str] = {} - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id, type from {_NODE} order by id") - for record in cur: - nodes_id_and_type[record['id']] = record['type'] + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id, type from {_NODE} order by id") + for record in cur: + nodes_id_and_type[record['id']] = record['type'] return nodes_id_and_type # DingZQ 2024-12-31 def get_major_nodes(name: str, diameter: int) -> list[str]: major_nodes_set = set() - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select node1, node2 from pipes where diameter > {diameter}") - for record in cur: - major_nodes_set.add(record['node1']) - major_nodes_set.add(record['node2']) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select node1, node2 from pipes where diameter > {diameter}") + for record in cur: + major_nodes_set.add(record['node1']) + major_nodes_set.add(record['node2']) return list(major_nodes_set) @@ -183,29 +188,32 @@ def get_links(name: str) -> list[str]: # DingZQ def _get_links_by_type(name: str, type: str) -> list[str]: ids : list[str] = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from {_LINK} where type = '{type}' order by id") - for record in cur: - ids.append(record['id']) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id from {_LINK} where type = '{type}' order by id") + for record in cur: + ids.append(record['id']) return ids # DingZQ def get_links_id_and_type(name: str) -> dict[str, str]: links_id_and_type: dict[str, str] = {} - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id, type from {_LINK} order by id") - for record in cur: - links_id_and_type[record['id']] = record['type'] + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id, type from {_LINK} order by id") + for record in cur: + links_id_and_type[record['id']] = record['type'] return links_id_and_type # DingZQ 2024-12-31 # 获取直径大于800的管道 def get_major_pipes(name: str, diameter: int) -> list[str]: major_pipe_ids: list[str] = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from pipes where diameter > {diameter} order by id") - for record in cur: - major_pipe_ids.append(record['id']) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id from pipes where diameter > {diameter} order by id") + for record in cur: + major_pipe_ids.append(record['id']) return major_pipe_ids # DingZQ @@ -232,15 +240,16 @@ def get_regions(name: str) -> list[str]: return _get_all(name, _REGION) def get_node_links(name: str, id: str) -> list[str]: - with conn[name].cursor(row_factory=dict_row) as cur: - links: list[str] = [] - for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall(): - links.append(p['id']) - for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall(): - links.append(p['id']) - for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall(): - links.append(p['id']) - return links + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + links: list[str] = [] + for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall(): + links.append(p['id']) + for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall(): + links.append(p['id']) + for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall(): + links.append(p['id']) + return links def get_link_nodes(name: str, id: str) -> list[str]: @@ -259,4 +268,3 @@ def get_region_type(name: str, id: str)->str: return type - diff --git a/app/native/wndb/s24_coordinates.py b/app/native/wndb/s24_coordinates.py index d038a96..df0fcc6 100644 --- a/app/native/wndb/s24_coordinates.py +++ b/app/native/wndb/s24_coordinates.py @@ -1,5 +1,7 @@ from .database import * +from .connection import project_connection from .s0_base import get_link_nodes +from psycopg.rows import dict_row def sql_update_coord(node: str, x: float, y: float) -> str: coord = f"st_geomfromtext('point({x} {y})')" @@ -49,10 +51,11 @@ def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) - node_ids = set([s.split(':')[0] for s in get_nodes_in_extent(name, x1, y1, x2, y2)]) all_link_ids = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select id from pipes") - for record in cur: - all_link_ids.append(record['id']) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select id from pipes") + for record in cur: + all_link_ids.append(record['id']) links = [] for link_id in all_link_ids: diff --git a/app/native/wndb/s41_pipe_risk_probability.py b/app/native/wndb/s41_pipe_risk_probability.py index 33f0fe9..b441305 100644 --- a/app/native/wndb/s41_pipe_risk_probability.py +++ b/app/native/wndb/s41_pipe_risk_probability.py @@ -1,5 +1,7 @@ from .database import * +from .connection import project_connection from .s0_base import * +from psycopg.rows import dict_row import json def get_pipe_risk_probability_now(name: str, pipe_id: str) -> dict[str, Any]: @@ -28,29 +30,31 @@ def get_pipe_risk_probability(name: str, pipe_id: str) -> dict[str, Any]: def get_network_pipe_risk_probability_now(name: str) -> list[dict[str, Any]]: pipe_risk_probability_list = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select * from pipe_risk_probability") - for record in cur: - #pipe_risk_probability_list.append(record) - t = {} - t['pipeid'] = record['pipeid'] - t['pipeage'] = record['pipeage'] - t['risk_probability_now'] = record['risk_probability_now'] - pipe_risk_probability_list.append(t) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select * from pipe_risk_probability") + for record in cur: + #pipe_risk_probability_list.append(record) + t = {} + t['pipeid'] = record['pipeid'] + t['pipeage'] = record['pipeage'] + t['risk_probability_now'] = record['risk_probability_now'] + pipe_risk_probability_list.append(t) return pipe_risk_probability_list def get_pipes_risk_probability(name: str, pipe_ids: list[str]) -> list[dict[str, Any]]: pipe_risk_probability_list = [] - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select * from pipe_risk_probability") - for record in cur: - if record['pipeid'] in pipe_ids: - t = {} - t['pipeid'] = record['pipeid'] - t['x'] = record['x'] - t['y'] = record['y'] - pipe_risk_probability_list.append(t) + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select * from pipe_risk_probability") + for record in cur: + if record['pipeid'] in pipe_ids: + t = {} + t['pipeid'] = record['pipeid'] + t['x'] = record['x'] + t['y'] = record['y'] + pipe_risk_probability_list.append(t) return pipe_risk_probability_list @@ -67,21 +71,22 @@ def get_pipe_risk_probability_geometries(name: str) -> dict[str, Any]: # key_endnode = '下游节点' key_geometry = 'geometry' - with conn[name].cursor(row_factory=dict_row) as cur: - cur.execute(f"select *, ST_AsGeoJSON(geometry) AS {key_geometry} from gis_pipe") + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f"select *, ST_AsGeoJSON(geometry) AS {key_geometry} from gis_pipe") - for record in cur: - id = record[key_pipeId] - geom = json.loads(record[key_geometry]) + for record in cur: + id = record[key_pipeId] + geom = json.loads(record[key_geometry]) - pipe_risk_probability_geometries[id] = { - 'points': geom['coordinates'] - } + pipe_risk_probability_geometries[id] = { + 'points': geom['coordinates'] + } - for col in record: - if col != key_geometry: - pipe_risk_probability_geometries[id][col] = record[col] + for col in record: + if col != key_geometry: + pipe_risk_probability_geometries[id][col] = record[col] # print(len(pipe_risk_probability_geometries)) - return pipe_risk_probability_geometries \ No newline at end of file + return pipe_risk_probability_geometries diff --git a/tests/unit/test_wndb_connection.py b/tests/unit/test_wndb_connection.py new file mode 100644 index 0000000..ea68d90 --- /dev/null +++ b/tests/unit/test_wndb_connection.py @@ -0,0 +1,78 @@ +import pytest + +from app.native.wndb import connection +from app.native.wndb import database +from app.native.wndb import project + + +class _FakeCursor: + def __init__(self, rows): + self.rows = rows + self.executed = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql): + self.executed.append(sql) + + def fetchall(self): + return self.rows + + +class _FakeConnection: + def __init__(self, rows=None, *, closed=False): + self.rows = list(rows or []) + self.closed = closed + self.close_calls = 0 + + def cursor(self, row_factory=None): + if self.closed: + raise RuntimeError("the connection is closed") + return _FakeCursor(self.rows) + + def close(self): + self.close_calls += 1 + self.closed = True + + +@pytest.fixture(autouse=True) +def clear_native_connections(): + connection.g_conn_dict.clear() + connection._project_locks.clear() + yield + connection.g_conn_dict.clear() + connection._project_locks.clear() + + +def test_is_project_open_drops_closed_cached_connection(): + connection.g_conn_dict["fengyang"] = _FakeConnection(closed=True) + + assert project.is_project_open("fengyang") is False + assert "fengyang" not in connection.g_conn_dict + + +def test_read_all_reopens_closed_cached_connection(monkeypatch): + stale = _FakeConnection(closed=True) + fresh = _FakeConnection(rows=[{"key": "DURATION", "value": "01:00:00"}]) + connection.g_conn_dict["fengyang"] = stale + + opened = [] + + def fake_connect(*, conninfo, autocommit): + opened.append((conninfo, autocommit)) + return fresh + + monkeypatch.setattr(connection.pg, "connect", fake_connect) + monkeypatch.setattr( + connection, "get_pgconn_string", lambda db_name: f"dbname={db_name}" + ) + + rows = database.read_all("fengyang", "select * from times") + + assert rows == [{"key": "DURATION", "value": "01:00:00"}] + assert opened == [("dbname=fengyang", True)] + assert connection.g_conn_dict["fengyang"] is fresh From 775ecb8a5841251adc93284883411770894a76ed Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 16 Jul 2026 14:16:06 +0800 Subject: [PATCH 60/93] fix(simulation): use hydraulic timestep --- app/algorithms/simulation/runner.py | 15 +++-------- app/api/v1/endpoints/simulation.py | 16 ++++++++++-- app/services/simulation.py | 14 ++++++----- app/services/time_api.py | 35 ++++++++++++++++++++++++++ tests/api/test_simulation_endpoints.py | 35 ++++++++++++++++++++++++++ tests/unit/test_time_api.py | 26 +++++++++++++++++++ 6 files changed, 121 insertions(+), 20 deletions(-) diff --git a/app/algorithms/simulation/runner.py b/app/algorithms/simulation/runner.py index 004caba..b59fa55 100644 --- a/app/algorithms/simulation/runner.py +++ b/app/algorithms/simulation/runner.py @@ -29,6 +29,7 @@ import pytz import requests import time import app.services.project_info as project_info +from app.services.time_api import parse_clock_duration_seconds url_path = 'http://10.101.15.16:9000/loong' # 内网 # url_path = 'http://183.64.62.100:9057/loong' # 外网 @@ -551,21 +552,11 @@ def from_clock_to_seconds (clock: str)->int: return hr*3600+mnt*60+seconds def from_clock_to_seconds_2 (clock: str)->int: - str_format="%H:%M:%S" - dt=datetime.strptime(clock,str_format) - hr=dt.hour - mnt=dt.minute - seconds=dt.second - return hr*3600+mnt*60+seconds + return parse_clock_duration_seconds(clock) def from_clock_to_seconds_3 (clock: str)->int: - str_format = "%H:%M" # 更新时间格式以适应 "小时:分钟" 格式 - dt = datetime.strptime(clock,str_format) - hr = dt.hour - mnt = dt.minute - seconds = dt.second - return hr * 3600 + mnt * 60 + return parse_clock_duration_seconds(clock) ###convert datetimestring diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index c8ad1f4..3a758cd 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -35,7 +35,11 @@ from app.services.simulation_ops import ( daily_scheduling_simulation, ) from app.services.valve_isolation import analyze_valve_isolation -from app.services.time_api import parse_aware_time, parse_utc_time +from app.services.time_api import ( + parse_aware_time, + parse_clock_duration_seconds, + parse_utc_time, +) from pydantic import BaseModel, Field, field_validator router = APIRouter() @@ -118,6 +122,14 @@ def run_simulation_manually_by_date( network_name: str, start_time: datetime, duration: int ) -> None: end_datetime = start_time + timedelta(minutes=duration) + time_properties = simulation.get_time(network_name) + hydraulic_step_seconds = parse_clock_duration_seconds( + time_properties["HYDRAULIC TIMESTEP"], + field_name="HYDRAULIC TIMESTEP", + ) + if hydraulic_step_seconds <= 0: + raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") + hydraulic_step = timedelta(seconds=hydraulic_step_seconds) current_time = start_time while current_time < end_datetime: simulation.run_simulation( @@ -125,7 +137,7 @@ def run_simulation_manually_by_date( simulation_type="realtime", modify_pattern_start_time=current_time.isoformat(timespec="seconds"), ) - current_time += timedelta(minutes=15) + current_time += hydraulic_step # 必须用这个PlainTextResponse,不然每个key都有引号 diff --git a/app/services/simulation.py b/app/services/simulation.py index ee49c09..5e910e9 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -34,7 +34,7 @@ import psycopg import logging import app.services.globals as globals import app.services.project_info as project_info -from app.services.time_api import parse_beijing_time +from app.services.time_api import parse_beijing_time, parse_clock_duration_seconds from app.core.config import get_pgconn_string from app.infra.db.timescaledb.internal_queries import ( InternalQueries as TimescaleInternalQueries, @@ -757,11 +757,13 @@ def run_simulation( # 获取水力模拟步长,如’0:15:00‘ globals.hydraulic_timestep = dic_time["HYDRAULIC TIMESTEP"] - # 将时间字符串转换为 timedelta 对象 - time_obj = datetime.strptime(globals.hydraulic_timestep, "%H:%M:%S") - # 转换为分钟浮点数 - globals.PATTERN_TIME_STEP = float( - time_obj.hour * 60 + time_obj.minute + time_obj.second / 60 + # 转换为分钟浮点数,兼容 EPANET 的 H:MM 和 H:MM:SS 写法 + globals.PATTERN_TIME_STEP = ( + parse_clock_duration_seconds( + globals.hydraulic_timestep, + field_name="HYDRAULIC TIMESTEP", + ) + / 60 ) # 对输入的时间参数进行处理 pattern_start_time = convert_time_format(modify_pattern_start_time) diff --git a/app/services/time_api.py b/app/services/time_api.py index 00904e2..569626a 100644 --- a/app/services/time_api.py +++ b/app/services/time_api.py @@ -89,6 +89,41 @@ def to_time_range(dt: datetime, delta: float) -> tuple[datetime, datetime]: return (start_time, end_time) + +def parse_clock_duration_seconds(clock: str, field_name: str = "duration") -> int: + """ + Parse EPANET-style clock durations into seconds. + + Accepted formats include H:MM, HH:MM, H:MM:SS, and HH:MM:SS. + """ + if not isinstance(clock, str): + raise ValueError(f"{field_name} must be a string clock duration.") + + parts = clock.strip().split(":") + if len(parts) not in (2, 3): + raise ValueError( + f"{field_name} must use H:MM or H:MM:SS format, got {clock!r}." + ) + + try: + values = [int(part) for part in parts] + except ValueError as exc: + raise ValueError( + f"{field_name} must contain numeric clock parts, got {clock!r}." + ) from exc + + if any(value < 0 for value in values): + raise ValueError(f"{field_name} must not contain negative values.") + + hours, minutes = values[0], values[1] + seconds = values[2] if len(values) == 3 else 0 + if minutes >= 60 or seconds >= 60: + raise ValueError( + f"{field_name} minutes and seconds must be less than 60, got {clock!r}." + ) + + return hours * 3600 + minutes * 60 + seconds + def parse_beijing_date_range(query_date: str) -> tuple[datetime, datetime]: ''' 将一个日期字符串,转换成 start/end 时间段,传进来的日期被认为是北京时间 diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index d03f96b..e022f92 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -19,11 +19,18 @@ def _load_simulation_module(monkeypatch): timezone.utc ) + def parse_clock_duration_seconds(value, field_name="duration"): + parts = [int(part) for part in value.split(":")] + hours, minutes = parts[0], parts[1] + seconds = parts[2] if len(parts) == 3 else 0 + return hours * 3600 + minutes * 60 + seconds + install_stub( monkeypatch, "app.services.time_api", { "parse_aware_time": parse_aware_time, + "parse_clock_duration_seconds": parse_clock_duration_seconds, "parse_utc_time": parse_utc_time, }, ) @@ -31,6 +38,7 @@ def _load_simulation_module(monkeypatch): monkeypatch, "app.services.simulation", { + "get_time": lambda name: {"HYDRAULIC TIMESTEP": "0:15:00"}, "run_simulation": lambda **kwargs: None, "query_corresponding_element_id_and_query_id": lambda name: None, "query_corresponding_pattern_id_and_query_id": lambda name: None, @@ -227,6 +235,33 @@ def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch): ] +def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured_calls = [] + + monkeypatch.setattr( + module.simulation, + "get_time", + lambda name: {"HYDRAULIC TIMESTEP": "1:00"}, + ) + monkeypatch.setattr( + module.simulation, + "run_simulation", + lambda **kwargs: captured_calls.append(kwargs), + ) + + module.run_simulation_manually_by_date( + "demo", + datetime(2025, 1, 1, 16, 0, 0, tzinfo=timezone.utc), + 120, + ) + + assert [call["modify_pattern_start_time"] for call in captured_calls] == [ + "2025-01-01T16:00:00+00:00", + "2025-01-01T17:00:00+00:00", + ] + + def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch): module = _load_simulation_module(monkeypatch) captured = {} diff --git a/tests/unit/test_time_api.py b/tests/unit/test_time_api.py index ef1bbfb..319c316 100644 --- a/tests/unit/test_time_api.py +++ b/tests/unit/test_time_api.py @@ -43,3 +43,29 @@ def test_utc_now_returns_timezone_aware_utc_datetime(): assert result.tzinfo == timezone.utc assert result.utcoffset() == timedelta(0) + + +@pytest.mark.parametrize( + ("clock", "expected_seconds"), + [ + ("1:00", 3600), + ("01:00", 3600), + ("0:05", 300), + ("0:05:00", 300), + ("24:00", 86400), + ], +) +def test_parse_clock_duration_seconds_accepts_epanet_clock_formats( + clock, expected_seconds +): + module = _load_time_api_module() + + assert module.parse_clock_duration_seconds(clock) == expected_seconds + + +@pytest.mark.parametrize("clock", ["bad", "1:60", "1:00:60", "-1:00"]) +def test_parse_clock_duration_seconds_rejects_invalid_clock_formats(clock): + module = _load_time_api_module() + + with pytest.raises(ValueError): + module.parse_clock_duration_seconds(clock) From ca1579dcc2d7df004c87d9940642b85c0d82f82c Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 16 Jul 2026 14:50:22 +0800 Subject: [PATCH 61/93] fix(api): include simulation burst ids --- app/services/burst_location.py | 31 +++++++++++++++++++++++ tests/unit/test_burst_location_service.py | 17 +++++++++++++ 2 files changed, 48 insertions(+) diff --git a/app/services/burst_location.py b/app/services/burst_location.py index a1bcf73..049637b 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -11,6 +11,7 @@ from app.infra.db.timescaledb.internal_queries import InternalQueries from app.services.scheme_management import ( query_burst_location_scheme_detail, query_burst_location_schemes, + query_scheme_list, scheme_name_exists, store_scheme_info, ) @@ -353,9 +354,15 @@ def run_burst_location_by_network( } ) if normalized_data_source == "simulation": + simulation_burst_ids = _get_simulation_scheme_burst_ids( + network=network, + scheme_name=simulation_scheme_name, + scheme_type=resolved_simulation_scheme_type, + ) payload["simulation_scheme"] = { "name": simulation_scheme_name, "type": resolved_simulation_scheme_type, + "burst_ids": simulation_burst_ids, } if scheme_name: _store_burst_scheme( @@ -464,6 +471,30 @@ def _validate_time_window( return start_dt, end_dt +def _get_simulation_scheme_burst_ids( + *, network: str, scheme_name: str | None, scheme_type: str +) -> list[str]: + if not scheme_name: + return [] + rows = query_scheme_list(network) or [] + for row in rows: + if len(row) < 7: + continue + if row[1] != scheme_name or row[2] != scheme_type: + continue + detail = row[6] if isinstance(row[6], dict) else {} + return _normalize_burst_ids(detail.get("burst_ID")) + return [] + + +def _normalize_burst_ids(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return _dedupe_ids([str(item) for item in value]) + return _dedupe_ids([str(value)]) + + def _align_observed_series_pair( *, ids: list[str], diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index d9e6414..2ac79e1 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -75,6 +75,7 @@ def _load_burst_location_module(): scheme_management_module = types.ModuleType("app.services.scheme_management") scheme_management_module.query_burst_location_scheme_detail = lambda *args, **kwargs: {} scheme_management_module.query_burst_location_schemes = lambda *args, **kwargs: [] + scheme_management_module.query_scheme_list = lambda *args, **kwargs: [] scheme_management_module.scheme_name_exists = lambda *args, **kwargs: False scheme_management_module.store_scheme_info = lambda *args, **kwargs: None sys.modules["app.services.scheme_management"] = scheme_management_module @@ -177,6 +178,21 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey "query_realtime_simulation_by_ids_timerange", staticmethod(fake_realtime_query), ) + monkeypatch.setattr( + module, + "query_scheme_list", + lambda name: [ + ( + 1, + "BurstSchemeA", + "burst_analysis", + "testuser", + None, + None, + {"burst_ID": ["Pipe-009", "Pipe-010"]}, + ) + ], + ) result = module.run_burst_location_by_network( network="tjwater", @@ -194,6 +210,7 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey assert result["simulation_scheme"] == { "name": "BurstSchemeA", "type": "burst_analysis", + "burst_ids": ["Pipe-009", "Pipe-010"], } assert result["pressure_samples"] == {"burst": 4, "normal": 4} assert result["flow_samples"] == {"burst": 4, "normal": 4} From 2b5f9b85146f1924076a1ad1e103b41d71459be1 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 16 Jul 2026 15:47:26 +0800 Subject: [PATCH 62/93] fix(simulation): use report step for schemes --- app/infra/db/timescaledb/internal_queries.py | 2 + .../db/timescaledb/repositories/scheme.py | 52 +++--- app/services/simulation.py | 4 + tests/unit/test_scheme_simulation_timestep.py | 154 ++++++++++++++++++ 4 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 tests/unit/test_scheme_simulation_timestep.py diff --git a/app/infra/db/timescaledb/internal_queries.py b/app/infra/db/timescaledb/internal_queries.py index c4fcfe9..16d45fe 100644 --- a/app/infra/db/timescaledb/internal_queries.py +++ b/app/infra/db/timescaledb/internal_queries.py @@ -50,6 +50,7 @@ class InternalStorage: link_result_list: List[dict], result_start_time: str, num_periods: int = 1, + result_timestep_seconds: int | None = None, db_name: str = None, max_retries: int = 3, ): @@ -70,6 +71,7 @@ class InternalStorage: link_result_list, result_start_time, num_periods, + result_timestep_seconds, ) break # 成功 except Exception as e: diff --git a/app/infra/db/timescaledb/repositories/scheme.py b/app/infra/db/timescaledb/repositories/scheme.py index f0960b3..3903fc0 100644 --- a/app/infra/db/timescaledb/repositories/scheme.py +++ b/app/infra/db/timescaledb/repositories/scheme.py @@ -3,10 +3,24 @@ from datetime import datetime, timedelta from collections import defaultdict from psycopg import AsyncConnection, Connection, sql import app.services.globals as globals -from app.services.time_api import parse_utc_time +from app.services.time_api import parse_clock_duration_seconds, parse_utc_time class SchemeRepository: + @staticmethod + def _get_result_timestep(result_timestep_seconds: int | None) -> timedelta: + if result_timestep_seconds is not None: + if result_timestep_seconds <= 0: + raise ValueError("result_timestep_seconds must be greater than 0.") + return timedelta(seconds=result_timestep_seconds) + + timestep_seconds = parse_clock_duration_seconds( + globals.hydraulic_timestep, + field_name="HYDRAULIC TIMESTEP", + ) + if timestep_seconds <= 0: + raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") + return timedelta(seconds=timestep_seconds) # --- Link Simulation --- @@ -452,6 +466,7 @@ class SchemeRepository: link_result_list: List[Dict[str, any]], result_start_time: str, num_periods: int = 1, + result_timestep_seconds: int | None = None, ): """ Store scheme simulation results to TimescaleDB. @@ -468,20 +483,16 @@ class SchemeRepository: result_start_time, field_name="result_start_time" ) - timestep_parts = globals.hydraulic_timestep.split(":") - timestep = timedelta( - hours=int(timestep_parts[0]), - minutes=int(timestep_parts[1]), - seconds=int(timestep_parts[2]), - ) + timestep = SchemeRepository._get_result_timestep(result_timestep_seconds) # Prepare node data for batch insert node_data = [] for node_result in node_result_list: node_id = node_result.get("node") - for period_index in range(num_periods): + result_rows = node_result.get("result", []) + for period_index in range(min(num_periods, len(result_rows))): current_time = simulation_time + (timestep * period_index) - data = node_result.get("result", [])[period_index] + data = result_rows[period_index] node_data.append( { "time": current_time, @@ -499,9 +510,10 @@ class SchemeRepository: link_data = [] for link_result in link_result_list: link_id = link_result.get("link") - for period_index in range(num_periods): + result_rows = link_result.get("result", []) + for period_index in range(min(num_periods, len(result_rows))): current_time = simulation_time + (timestep * period_index) - data = link_result.get("result", [])[period_index] + data = result_rows[period_index] link_data.append( { "time": current_time, @@ -535,6 +547,7 @@ class SchemeRepository: link_result_list: List[Dict[str, any]], result_start_time: str, num_periods: int = 1, + result_timestep_seconds: int | None = None, ): """ Store scheme simulation results to TimescaleDB (sync version). @@ -551,20 +564,16 @@ class SchemeRepository: result_start_time, field_name="result_start_time" ) - timestep_parts = globals.hydraulic_timestep.split(":") - timestep = timedelta( - hours=int(timestep_parts[0]), - minutes=int(timestep_parts[1]), - seconds=int(timestep_parts[2]), - ) + timestep = SchemeRepository._get_result_timestep(result_timestep_seconds) # Prepare node data for batch insert node_data = [] for node_result in node_result_list: node_id = node_result.get("node") - for period_index in range(num_periods): + result_rows = node_result.get("result", []) + for period_index in range(min(num_periods, len(result_rows))): current_time = simulation_time + (timestep * period_index) - data = node_result.get("result", [])[period_index] + data = result_rows[period_index] node_data.append( { "time": current_time, @@ -582,9 +591,10 @@ class SchemeRepository: link_data = [] for link_result in link_result_list: link_id = link_result.get("link") - for period_index in range(num_periods): + result_rows = link_result.get("result", []) + for period_index in range(min(num_periods, len(result_rows))): current_time = simulation_time + (timestep * period_index) - data = link_result.get("result", [])[period_index] + data = result_rows[period_index] link_data.append( { "time": current_time, diff --git a/app/services/simulation.py b/app/services/simulation.py index 5e910e9..f567930 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -1260,6 +1260,9 @@ def run_simulation( node_result, link_result, modify_pattern_start_time, db_name=db_name ) elif simulation_type.upper() == "EXTENDED": + result_timestep_seconds = times_info.get("report_step") + if result_timestep_seconds is None: + raise RuntimeError("run_project output missing times.report_step") TimescaleInternalStorage.store_scheme_simulation( scheme_type, scheme_name, @@ -1267,6 +1270,7 @@ def run_simulation( link_result, modify_pattern_start_time, num_periods_result, + result_timestep_seconds, db_name=db_name, ) endtime = time.time() diff --git a/tests/unit/test_scheme_simulation_timestep.py b/tests/unit/test_scheme_simulation_timestep.py new file mode 100644 index 0000000..72c37c6 --- /dev/null +++ b/tests/unit/test_scheme_simulation_timestep.py @@ -0,0 +1,154 @@ +import json +from datetime import timedelta + +import pytest + +from app.infra.db.timescaledb.repositories.scheme import SchemeRepository +from app.services.time_api import parse_utc_time + + +def _node_result(periods: int) -> list[dict]: + return [ + { + "node": "J1", + "result": [ + {"demand": index, "head": index, "pressure": index, "quality": index} + for index in range(periods) + ], + } + ] + + +def _link_result(periods: int) -> list[dict]: + return [ + { + "link": "P1", + "result": [ + { + "flow": index, + "friction": index, + "headloss": index, + "quality": index, + "reaction": index, + "setting": index, + "status": index, + "velocity": index, + } + for index in range(periods) + ], + } + ] + + +def test_store_scheme_simulation_uses_15_minute_report_step(monkeypatch): + inserted: dict[str, list[dict]] = {} + + monkeypatch.setattr( + SchemeRepository, + "insert_nodes_batch_sync", + staticmethod(lambda conn, data: inserted.setdefault("nodes", data)), + ) + monkeypatch.setattr( + SchemeRepository, + "insert_links_batch_sync", + staticmethod(lambda conn, data: inserted.setdefault("links", data)), + ) + + SchemeRepository.store_scheme_simulation_result_sync( + conn=object(), + scheme_type="burst_analysis", + scheme_name="five_hour_case", + node_result_list=_node_result(21), + link_result_list=_link_result(21), + result_start_time="2026-07-16T00:00:00Z", + num_periods=21, + result_timestep_seconds=900, + ) + + start_time = parse_utc_time("2026-07-16T00:00:00Z") + assert len(inserted["nodes"]) == 21 + assert inserted["nodes"][0]["time"] == start_time + assert inserted["nodes"][-1]["time"] == start_time + timedelta(hours=5) + assert inserted["links"][-1]["time"] == start_time + timedelta(hours=5) + + +def test_store_scheme_simulation_uses_hourly_report_step(monkeypatch): + inserted: dict[str, list[dict]] = {} + + monkeypatch.setattr( + SchemeRepository, + "insert_nodes_batch_sync", + staticmethod(lambda conn, data: inserted.setdefault("nodes", data)), + ) + monkeypatch.setattr( + SchemeRepository, + "insert_links_batch_sync", + staticmethod(lambda conn, data: inserted.setdefault("links", data)), + ) + + SchemeRepository.store_scheme_simulation_result_sync( + conn=object(), + scheme_type="burst_analysis", + scheme_name="hourly_case", + node_result_list=_node_result(6), + link_result_list=_link_result(6), + result_start_time="2026-07-16T00:00:00Z", + num_periods=6, + result_timestep_seconds=3600, + ) + + start_time = parse_utc_time("2026-07-16T00:00:00Z") + assert [item["time"] for item in inserted["nodes"]] == [ + start_time + timedelta(hours=index) for index in range(6) + ] + + +def test_run_simulation_passes_report_step_for_extended_scheme(monkeypatch): + import app.services.simulation as simulation + + time_updates: list[dict] = [] + storage_calls: list[tuple] = [] + + monkeypatch.setattr(simulation, "open_project", lambda name: None) + monkeypatch.setattr( + simulation, + "get_time", + lambda name: { + "HYDRAULIC TIMESTEP": "00:15:00", + "REPORT TIMESTEP": "1:00", + "DURATION": "0:00", + "PATTERN START": "0:00", + }, + ) + monkeypatch.setattr( + simulation, + "set_time", + lambda name, changeset: time_updates.append(changeset.operations[0]), + ) + monkeypatch.setattr(simulation, "run_project", lambda name: json.dumps({ + "simulation_result": "successful", + "output": { + "times": {"num_periods": 21, "report_step": 900}, + "node_results": _node_result(21), + "link_results": _link_result(21), + }, + })) + monkeypatch.setattr( + simulation.TimescaleInternalStorage, + "store_scheme_simulation", + staticmethod(lambda *args, **kwargs: storage_calls.append((args, kwargs))), + ) + + simulation.run_simulation( + name="fengyang", + simulation_type="extended", + modify_pattern_start_time="2026-07-16T00:00:00+08:00", + modify_total_duration=18000, + scheme_type="burst_analysis", + scheme_name="five_hour_case", + ) + + assert time_updates[0]["DURATION"] == "05:00:00" + assert time_updates[0]["REPORT TIMESTEP"] == "1:00" + assert storage_calls[0][0][5] == 21 + assert storage_calls[0][0][6] == 900 From a204980944e63d29927e863de4566c71a7930de2 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 17 Jul 2026 11:45:58 +0800 Subject: [PATCH 63/93] fix(leakage): accept display flow units --- app/algorithms/leakage/identifier.py | 16 +++++++++------- tests/unit/test_leakage_flow_units.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_leakage_flow_units.py diff --git a/app/algorithms/leakage/identifier.py b/app/algorithms/leakage/identifier.py index c2044b1..c02a705 100644 --- a/app/algorithms/leakage/identifier.py +++ b/app/algorithms/leakage/identifier.py @@ -121,13 +121,15 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float: _cleanup_temp_files(prefix) -class LeakageIdentifier: - FLOW_UNIT_TO_M3S = { - "m3/s": 1.0, - "m3/h": 1.0 / 3600.0, - "L/s": 1.0 / 1000.0, - "L/min": 1.0 / 60000.0, - } +class LeakageIdentifier: + FLOW_UNIT_TO_M3S = { + "m3/s": 1.0, + "m³/s": 1.0, + "m3/h": 1.0 / 3600.0, + "m³/h": 1.0 / 3600.0, + "L/s": 1.0 / 1000.0, + "L/min": 1.0 / 60000.0, + } @classmethod def _flow_to_m3s(cls, value: float, unit: str) -> float: diff --git a/tests/unit/test_leakage_flow_units.py b/tests/unit/test_leakage_flow_units.py new file mode 100644 index 0000000..bbad2c8 --- /dev/null +++ b/tests/unit/test_leakage_flow_units.py @@ -0,0 +1,20 @@ +import pytest + +from app.algorithms.leakage.identifier import LeakageIdentifier + + +@pytest.mark.parametrize( + ("unit", "expected"), + [ + ("m3/s", 1.0), + ("m³/s", 1.0), + ("m3/h", 3600.0), + ("m³/h", 3600.0), + ], +) +def test_leakage_identifier_accepts_display_flow_units(unit, expected): + assert LeakageIdentifier._flow_from_m3s(1.0, unit) == expected + + +def test_leakage_identifier_accepts_display_flow_units_for_input(): + assert LeakageIdentifier._flow_to_m3s(3600.0, "m³/h") == 1.0 From b4ecfbb87a16ab903a8425d5a98bd427adcb344b Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 17 Jul 2026 16:28:40 +0800 Subject: [PATCH 64/93] fix(scada): use project-scoped metadata --- app/algorithms/__init__.py | 79 +++-- app/api/v1/endpoints/project_data.py | 7 +- app/infra/db/postgresql/scada.py | 51 +++ app/infra/db/timescaledb/composite_queries.py | 308 +++++++++--------- tests/conftest.py | 2 +- tests/unit/test_burst_location_service.py | 30 +- tests/unit/test_postgres_scada_repository.py | 62 ++++ tests/unit/test_pressure_cleaning.py | 79 ++--- tests/unit/test_project_scada_metadata.py | 121 +++++++ tests/unit/test_realtime_repository.py | 45 +-- tests/unit/test_scada_cleaning.py | 200 ++++++++++++ 11 files changed, 694 insertions(+), 290 deletions(-) create mode 100644 app/infra/db/postgresql/scada.py create mode 100644 tests/unit/test_postgres_scada_repository.py create mode 100644 tests/unit/test_project_scada_metadata.py create mode 100644 tests/unit/test_scada_cleaning.py diff --git a/app/algorithms/__init__.py b/app/algorithms/__init__.py index 57dc324..a31f4b3 100644 --- a/app/algorithms/__init__.py +++ b/app/algorithms/__init__.py @@ -1,36 +1,45 @@ -from app.algorithms.cleaning import flow_data_clean, pressure_data_clean -from app.algorithms.sensor import ( - pressure_sensor_placement_sensitivity, - pressure_sensor_placement_kmeans, -) -from app.algorithms.isolation.valve import valve_isolation_analysis -from app.algorithms.leakage import LeakageIdentifier -from app.algorithms.health import PipelineHealthAnalyzer -from app.algorithms.burst_location import run_burst_location -from app.algorithms.simulation.scenarios import ( - convert_to_local_unit, - burst_analysis, - valve_close_analysis, - flushing_analysis, - contaminant_simulation, - age_analysis, - pressure_regulation, -) +"""Algorithm package with side-effect-free, lazy compatibility exports.""" -__all__ = [ - "flow_data_clean", - "pressure_data_clean", - "pressure_sensor_placement_sensitivity", - "pressure_sensor_placement_kmeans", - "convert_to_local_unit", - "burst_analysis", - "valve_close_analysis", - "flushing_analysis", - "contaminant_simulation", - "age_analysis", - "pressure_regulation", - "valve_isolation_analysis", - "LeakageIdentifier", - "PipelineHealthAnalyzer", - "run_burst_location", -] +from importlib import import_module +from typing import Any + + +_EXPORT_MODULES = { + "flow_data_clean": "app.algorithms.cleaning", + "pressure_data_clean": "app.algorithms.cleaning", + "pressure_sensor_placement_sensitivity": "app.algorithms.sensor", + "pressure_sensor_placement_kmeans": "app.algorithms.sensor", + "valve_isolation_analysis": "app.algorithms.isolation.valve", + "LeakageIdentifier": "app.algorithms.leakage", + "PipelineHealthAnalyzer": "app.algorithms.health", + "run_burst_location": "app.algorithms.burst_location", + **{ + name: "app.algorithms.simulation.scenarios" + for name in ( + "convert_to_local_unit", + "burst_analysis", + "valve_close_analysis", + "flushing_analysis", + "contaminant_simulation", + "age_analysis", + "pressure_regulation", + ) + }, +} + +__all__ = list(_EXPORT_MODULES) + + +def __getattr__(name: str) -> Any: + try: + module_name = _EXPORT_MODULES[name] + except KeyError as exc: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + + value = getattr(import_module(module_name), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted({*globals(), *__all__}) diff --git a/app/api/v1/endpoints/project_data.py b/app/api/v1/endpoints/project_data.py index 1d9a928..58efa1b 100644 --- a/app/api/v1/endpoints/project_data.py +++ b/app/api/v1/endpoints/project_data.py @@ -1,10 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query from psycopg import AsyncConnection -import app.native.wndb as wndb +from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.postgresql.scheme import SchemeRepository from app.auth.project_dependencies import get_project_pg_connection -from app.services import project_info router = APIRouter() @@ -26,9 +25,7 @@ async def get_scada_info_with_connection( 返回项目中所有的SCADA设备信息 """ try: - _ = conn - network_name = project_info.name - scada_data = wndb.get_all_scada_info(network_name) if network_name else [] + scada_data = await ScadaInfoRepository.get_scadas(conn) return {"success": True, "data": scada_data, "count": len(scada_data)} except Exception as e: raise HTTPException( diff --git a/app/infra/db/postgresql/scada.py b/app/infra/db/postgresql/scada.py new file mode 100644 index 0000000..ef33852 --- /dev/null +++ b/app/infra/db/postgresql/scada.py @@ -0,0 +1,51 @@ +from typing import Any + +from psycopg import AsyncConnection + + +def _optional_text(value: Any) -> str | None: + return str(value).strip() if value is not None else None + + +def _optional_float(value: Any) -> float | None: + return float(value) if value is not None else None + + +class ScadaInfoRepository: + """Read SCADA metadata from the current project's business database.""" + + @staticmethod + async def get_scadas(conn: AsyncConnection) -> list[dict[str, Any]]: + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT id, + type, + associated_element_id, + api_query_id, + transmission_mode, + transmission_frequency, + reliability, + x_coor, + y_coor + FROM public.scada_info + """ + ) + records = await cur.fetchall() + + return [ + { + "id": str(record["id"]).strip(), + "type": str(record["type"]).strip().lower(), + "associated_element_id": _optional_text( + record["associated_element_id"] + ), + "api_query_id": record["api_query_id"], + "transmission_mode": record["transmission_mode"], + "transmission_frequency": record["transmission_frequency"], + "reliability": _optional_float(record["reliability"]), + "x": _optional_float(record["x_coor"]), + "y": _optional_float(record["y_coor"]), + } + for record in records + ] diff --git a/app/infra/db/timescaledb/composite_queries.py b/app/infra/db/timescaledb/composite_queries.py index a57479d..6baf715 100644 --- a/app/infra/db/timescaledb/composite_queries.py +++ b/app/infra/db/timescaledb/composite_queries.py @@ -1,18 +1,19 @@ import time -from typing import List, Optional, Any, Dict, Tuple from datetime import datetime, timedelta -from psycopg import AsyncConnection -import pandas as pd +from typing import Any, Dict, List, Optional, Tuple + import numpy as np +import pandas as pd +from psycopg import AsyncConnection + +import app.native.wndb as wndb from app.algorithms.cleaning.flow import clean_flow_data_df_kf from app.algorithms.cleaning.pressure import clean_pressure_data_df_km from app.algorithms.health.analyzer import PipelineHealthAnalyzer -import app.native.wndb as wndb - +from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository from app.infra.db.timescaledb.repositories.scheme import SchemeRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository -from app.services import project_info class CompositeQueries: @@ -20,6 +21,13 @@ class CompositeQueries: 复合查询类,提供跨表查询功能 """ + @staticmethod + async def _get_project_scada_index( + postgres_conn: AsyncConnection, + ) -> Dict[str, Dict[str, Any]]: + scadas = await ScadaInfoRepository.get_scadas(postgres_conn) + return {scada["id"]: scada for scada in scadas} + @staticmethod async def get_scada_associated_realtime_simulation_data( timescale_conn: AsyncConnection, @@ -48,31 +56,22 @@ class CompositeQueries: ValueError: 当 SCADA 设备未找到或字段无效时 """ result = {} - # 1. 查询所有 SCADA 信息 - network_name = project_info.name - scada_infos = wndb.get_all_scada_info(network_name) if network_name else [] + scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn) for device_id in device_ids: - # 2. 根据 device_id 找到对应的 SCADA 信息 - target_scada = None - for scada in scada_infos: - if scada["id"] == device_id: - target_scada = scada - break - + target_scada = scada_by_id.get(device_id) if not target_scada: raise ValueError(f"SCADA device {device_id} not found") - # 3. 根据 type 和 associated_element_id 查询对应的模拟数据 element_id = target_scada["associated_element_id"] scada_type = target_scada["type"] - if scada_type.lower() == "pipe_flow": + if scada_type == "pipe_flow": # 查询 link 模拟数据 res = await RealtimeRepository.get_link_field_by_time_range( timescale_conn, start_time, end_time, element_id, "flow" ) - elif scada_type.lower() == "pressure": + elif scada_type == "pressure": # 查询 node 模拟数据 res = await RealtimeRepository.get_node_field_by_time_range( timescale_conn, start_time, end_time, element_id, "pressure" @@ -115,26 +114,17 @@ class CompositeQueries: ValueError: 当 SCADA 设备未找到或字段无效时 """ result = {} - # 1. 查询所有 SCADA 信息 - network_name = project_info.name - scada_infos = wndb.get_all_scada_info(network_name) if network_name else [] + scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn) for device_id in device_ids: - # 2. 根据 device_id 找到对应的 SCADA 信息 - target_scada = None - for scada in scada_infos: - if scada["id"] == device_id: - target_scada = scada - break - + target_scada = scada_by_id.get(device_id) if not target_scada: raise ValueError(f"SCADA device {device_id} not found") - # 3. 根据 type 和 associated_element_id 查询对应的模拟数据 element_id = target_scada["associated_element_id"] scada_type = target_scada["type"] - if scada_type.lower() == "pipe_flow": + if scada_type == "pipe_flow": # 查询 link 模拟数据 res = await SchemeRepository.get_link_field_by_scheme_and_time_range( timescale_conn, @@ -145,7 +135,7 @@ class CompositeQueries: element_id, "flow", ) - elif scada_type.lower() == "pressure": + elif scada_type == "pressure": # 查询 node 模拟数据 res = await SchemeRepository.get_node_field_by_scheme_and_time_range( timescale_conn, @@ -167,19 +157,19 @@ class CompositeQueries: @staticmethod async def get_realtime_simulation_data( timescale_conn: AsyncConnection, - featureInfos: List[Tuple[str, str]], + feature_infos: List[Tuple[str, str]], start_time: datetime, end_time: datetime, ) -> Dict[str, List[Dict[str, Any]]]: """ 获取 link/node 模拟值 - 根据传入的 featureInfos,找到关联的 link/node, + 根据传入的 feature_infos,找到关联的 link/node, 并根据对应的 type,查询对应的模拟数据 Args: timescale_conn: TimescaleDB 异步连接 - featureInfos: 传入的 feature 信息列表,包含 (element_id, type) + feature_infos: 传入的 feature 信息列表,包含 (element_id, type) start_time: 开始时间 end_time: 结束时间 @@ -190,20 +180,20 @@ class CompositeQueries: ValueError: 当 SCADA 设备未找到或字段无效时 """ result = {} - for feature_id, type in featureInfos: + for feature_id, feature_type in feature_infos: - if type.lower() == "pipe": + if feature_type.lower() == "pipe": # 查询 link 模拟数据 res = await RealtimeRepository.get_link_field_by_time_range( timescale_conn, start_time, end_time, feature_id, "flow" ) - elif type.lower() == "junction": + elif feature_type.lower() == "junction": # 查询 node 模拟数据 res = await RealtimeRepository.get_node_field_by_time_range( timescale_conn, start_time, end_time, feature_id, "pressure" ) else: - raise ValueError(f"Unknown type: {type}") + raise ValueError(f"Unknown type: {feature_type}") # 添加 scada_id 到每个数据项 for item in res: item["feature_id"] = feature_id @@ -213,7 +203,7 @@ class CompositeQueries: @staticmethod async def get_scheme_simulation_data( timescale_conn: AsyncConnection, - featureInfos: List[Tuple[str, str]], + feature_infos: List[Tuple[str, str]], start_time: datetime, end_time: datetime, scheme_type: str, @@ -222,12 +212,12 @@ class CompositeQueries: """ 获取 link/node scheme 模拟值 - 根据传入的 featureInfos,找到关联的 link/node, + 根据传入的 feature_infos,找到关联的 link/node, 并根据对应的 type,查询对应的模拟数据 Args: timescale_conn: TimescaleDB 异步连接 - featureInfos: 传入的 feature 信息列表,包含 (element_id, type) + feature_infos: 传入的 feature 信息列表,包含 (element_id, type) start_time: 开始时间 end_time: 结束时间 scheme_type: 工况类型 @@ -240,8 +230,8 @@ class CompositeQueries: ValueError: 当类型无效时 """ result = {} - for feature_id, type in featureInfos: - if type.lower() == "pipe": + for feature_id, feature_type in feature_infos: + if feature_type.lower() == "pipe": # 查询 link 模拟数据 res = await SchemeRepository.get_link_field_by_scheme_and_time_range( timescale_conn, @@ -252,7 +242,7 @@ class CompositeQueries: feature_id, "flow", ) - elif type.lower() == "junction": + elif feature_type.lower() == "junction": # 查询 node 模拟数据 res = await SchemeRepository.get_node_field_by_scheme_and_time_range( timescale_conn, @@ -264,7 +254,7 @@ class CompositeQueries: "pressure", ) else: - raise ValueError(f"Unknown type: {type}") + raise ValueError(f"Unknown type: {feature_type}") # 添加 feature_id 到每个数据项 for item in res: item["feature_id"] = feature_id @@ -301,33 +291,27 @@ class CompositeQueries: ValueError: 当元素类型无效时 """ - # 1. 查询所有 SCADA 信息 - network_name = project_info.name - scada_infos = wndb.get_all_scada_info(network_name) if network_name else [] - - # 2. 根据 element_type 和 element_id 找到关联的 SCADA 设备 - associated_scada = None - for scada in scada_infos: - if scada["associated_element_id"] == element_id: - associated_scada = scada - break + scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn) + associated_scada = next( + ( + scada + for scada in scada_by_id.values() + if scada["associated_element_id"] == element_id + ), + None, + ) if not associated_scada: - # 没有找到关联的 SCADA 设备 return None - # 3. 通过 SCADA device_id 获取监测数据 device_id = associated_scada["id"] - # 根据 use_cleaned 参数选择字段 data_field = "cleaned_value" if use_cleaned else "monitored_value" - # 保证 device_id 以列表形式传递 res = await ScadaRepository.get_scada_field_by_id_time_range( timescale_conn, [device_id], start_time, end_time, data_field ) - # 将 device_id 替换为 element_id 返回 return {element_id: res.get(device_id, [])} @staticmethod @@ -351,108 +335,124 @@ class CompositeQueries: end_time: 结束时间 Returns: - "success" 或错误信息 + "success" + + Raises: + ValueError: 当前项目没有可清洗设备或指定时间范围内没有监测数据 """ - try: - # 获取所有 SCADA 信息 - network_name = project_info.name - scada_infos = wndb.get_all_scada_info(network_name) if network_name else [] - # 将列表转换为字典,以 device_id 为键 - scada_device_info_dict = {info["id"]: info for info in scada_infos} + scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn) + supported_types = {"pressure", "pipe_flow", "flow"} - # 如果 device_ids 为空,则处理所有 SCADA 设备 - if not device_ids: - device_ids = list(scada_device_info_dict.keys()) + if device_ids: + device_ids = [str(device_id).strip() for device_id in device_ids] + missing_metadata_ids = [ + device_id + for device_id in device_ids + if device_id not in scada_by_id + ] + if missing_metadata_ids: + raise ValueError( + f"当前项目中有 {len(missing_metadata_ids)} 个 SCADA 设备缺少元数据" + ) - # 批量查询所有设备的数据 - data = await ScadaRepository.get_scada_field_by_id_time_range( - timescale_conn, device_ids, start_time, end_time, "monitored_value" + unsupported_ids = [ + device_id + for device_id in device_ids + if scada_by_id[device_id]["type"] not in supported_types + ] + if unsupported_ids: + raise ValueError( + f"当前项目中有 {len(unsupported_ids)} 个 SCADA 设备类型不支持清洗" + ) + else: + device_ids = [ + device_id + for device_id, info in scada_by_id.items() + if info["type"] in supported_types + ] + + if not device_ids: + raise ValueError("当前项目没有可清洗的 SCADA 设备") + + data = await ScadaRepository.get_scada_field_by_id_time_range( + timescale_conn, device_ids, start_time, end_time, "monitored_value" + ) + if not data: + raise ValueError("指定时间范围内没有 SCADA 监测数据") + + normalized_data = { + str(device_id): records for device_id, records in data.items() + } + missing_data_ids = [ + device_id for device_id in device_ids if not normalized_data.get(device_id) + ] + if missing_data_ids: + raise ValueError( + f"指定时间范围内有 {len(missing_data_ids)} 个 SCADA 设备没有监测数据" ) - if not data: - return "error: fetch none scada data" # 没有数据,直接返回 + all_records = [ + { + "time": record["time"], + "device_id": device_id, + "value": record["value"], + } + for device_id, records in normalized_data.items() + for record in records + ] + if not all_records: + raise ValueError("指定时间范围内没有 SCADA 监测数据") - # 将嵌套字典转换为 DataFrame,使用 time 作为索引 - # data 格式: {device_id: [{"time": "...", "value": ...}, ...]} - all_records = [] - for device_id, records in data.items(): - for record in records: - all_records.append( - { - "time": record["time"], - "device_id": device_id, - "value": record["value"], - } + df_long = pd.DataFrame(all_records) + df = df_long.pivot(index="time", columns="device_id", values="value") + + pressure_ids = [ + device_id + for device_id in df.columns + if scada_by_id[device_id]["type"] == "pressure" + ] + flow_ids = [ + device_id + for device_id in df.columns + if scada_by_id[device_id]["type"] in {"pipe_flow", "flow"} + ] + + updated_rows = 0 + for grouped_ids, cleaning_function in ( + (pressure_ids, clean_pressure_data_df_km), + (flow_ids, clean_flow_data_df_kf), + ): + if not grouped_ids: + continue + + source_df = df[grouped_ids].reset_index() + cleaned_df = cleaning_function(source_df) + time_values = cleaned_df["time"].tolist() + + for device_id in grouped_ids: + if device_id not in cleaned_df.columns: + raise ValueError(f"设备 {device_id} 的清洗结果缺少数据列") + + cleaned_values = cleaned_df[device_id].tolist() + for time_value, value in zip(time_values, cleaned_values): + time_dt = ( + time_value + if isinstance(time_value, datetime) + else datetime.fromisoformat(str(time_value)) ) + await ScadaRepository.update_scada_field( + timescale_conn, + time_dt, + device_id, + "cleaned_value", + value, + ) + updated_rows += 1 - if not all_records: - return "error: fetch none scada data" # 没有数据,直接返回 + if updated_rows == 0: + raise ValueError("SCADA 数据清洗未产生任何数据库更新") - # 创建 DataFrame 并透视,使 device_id 成为列 - df_long = pd.DataFrame(all_records) - df = df_long.pivot(index="time", columns="device_id", values="value") - - # 根据type分类设备 - pressure_ids = [ - id - for id in df.columns - if scada_device_info_dict.get(id, {}).get("type") == "pressure" - ] - flow_ids = [ - id - for id in df.columns - if scada_device_info_dict.get(id, {}).get("type") == "pipe_flow" - ] - - # 处理pressure数据 - if pressure_ids: - pressure_df = df[pressure_ids] - # 重置索引,将 time 变为普通列 - pressure_df = pressure_df.reset_index() - # 调用清洗方法 - cleaned_df = clean_pressure_data_df_km(pressure_df) - # 将清洗后的数据写回数据库 - for device_id in pressure_ids: - if device_id in cleaned_df.columns: - cleaned_values = cleaned_df[device_id].tolist() - time_values = cleaned_df["time"].tolist() - for i, time_str in enumerate(time_values): - time_dt = datetime.fromisoformat(time_str) - value = cleaned_values[i] - await ScadaRepository.update_scada_field( - timescale_conn, - time_dt, - device_id, - "cleaned_value", - value, - ) - - # 处理flow数据 - if flow_ids: - flow_df = df[flow_ids] - # 重置索引,将 time 变为普通列 - flow_df = flow_df.reset_index() - # 调用清洗方法 - cleaned_df = clean_flow_data_df_kf(flow_df) - # 将清洗后的数据写回数据库 - for device_id in flow_ids: - if device_id in cleaned_df.columns: - cleaned_values = cleaned_df[device_id].tolist() - time_values = cleaned_df["time"].tolist() - for i, time_str in enumerate(time_values): - time_dt = datetime.fromisoformat(time_str) - value = cleaned_values[i] - await ScadaRepository.update_scada_field( - timescale_conn, - time_dt, - device_id, - "cleaned_value", - value, - ) - - return "success" - except Exception as e: - return f"error: {str(e)}" + return "success" @staticmethod async def predict_pipeline_health( diff --git a/tests/conftest.py b/tests/conftest.py index 812baa9..93a1645 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def install_stub(monkeypatch, name: str, attrs: dict | None = None, package: boo parent = types.ModuleType(parent_name) parent.__path__ = [] monkeypatch.setitem(sys.modules, parent_name, parent) - setattr(parent, child_name, module) + monkeypatch.setattr(parent, child_name, module, raising=False) return module diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index 2ac79e1..a82bf4b 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -12,12 +12,19 @@ def _load_burst_location_module(): Path(__file__).resolve().parents[2] / "app" / "services" / "burst_location.py" ) + missing = object() + previous_modules = {} + + def install_module(name: str, module: types.ModuleType) -> None: + previous_modules.setdefault(name, sys.modules.get(name, missing)) + sys.modules[name] = module + def ensure_package(name: str) -> types.ModuleType: module = sys.modules.get(name) if module is None: module = types.ModuleType(name) module.__path__ = [] - sys.modules[name] = module + install_module(name, module) return module for package_name in [ @@ -46,11 +53,11 @@ def _load_burst_location_module(): ) ) time_api_module.utc_now = lambda: datetime.now(timezone.utc) - sys.modules["app.services.time_api"] = time_api_module + install_module("app.services.time_api", time_api_module) algorithms_module = types.ModuleType("app.algorithms.burst_location") algorithms_module.run_burst_location = lambda **kwargs: {} - sys.modules["app.algorithms.burst_location"] = algorithms_module + install_module("app.algorithms.burst_location", algorithms_module) internal_queries_module = types.ModuleType( "app.infra.db.timescaledb.internal_queries" @@ -70,7 +77,9 @@ def _load_burst_location_module(): return {} internal_queries_module.InternalQueries = DummyInternalQueries - sys.modules["app.infra.db.timescaledb.internal_queries"] = internal_queries_module + install_module( + "app.infra.db.timescaledb.internal_queries", internal_queries_module + ) scheme_management_module = types.ModuleType("app.services.scheme_management") scheme_management_module.query_burst_location_scheme_detail = lambda *args, **kwargs: {} @@ -78,18 +87,25 @@ def _load_burst_location_module(): scheme_management_module.query_scheme_list = lambda *args, **kwargs: [] scheme_management_module.scheme_name_exists = lambda *args, **kwargs: False scheme_management_module.store_scheme_info = lambda *args, **kwargs: None - sys.modules["app.services.scheme_management"] = scheme_management_module + install_module("app.services.scheme_management", scheme_management_module) tjnetwork_module = types.ModuleType("app.services.tjnetwork") tjnetwork_module.dump_inp = lambda *args, **kwargs: None tjnetwork_module.get_all_scada_info = lambda *args, **kwargs: [] - sys.modules["app.services.tjnetwork"] = tjnetwork_module + install_module("app.services.tjnetwork", tjnetwork_module) module_name = "tests_burst_location_under_test" spec = importlib.util.spec_from_file_location(module_name, module_path) module = importlib.util.module_from_spec(spec) assert spec and spec.loader - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) + finally: + for name, previous in reversed(previous_modules.items()): + if previous is missing: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous return module diff --git a/tests/unit/test_postgres_scada_repository.py b/tests/unit/test_postgres_scada_repository.py new file mode 100644 index 0000000..d1f4cd7 --- /dev/null +++ b/tests/unit/test_postgres_scada_repository.py @@ -0,0 +1,62 @@ +import asyncio + +from app.infra.db.postgresql.scada import ScadaInfoRepository + + +class _FakeCursor: + def __init__(self): + self.query = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query): + self.query = query + + async def fetchall(self): + return [ + { + "id": " 25470001 ", + "type": " PRESSURE ", + "associated_element_id": " J1 ", + "api_query_id": "query-1", + "transmission_mode": "realtime", + "transmission_frequency": None, + "reliability": "0.95", + "x_coor": "117.1", + "y_coor": "32.9", + } + ] + + +class _FakeConnection: + def __init__(self): + self.cursor_instance = _FakeCursor() + + def cursor(self): + return self.cursor_instance + + +def test_get_scadas_normalizes_id_and_type(): + conn = _FakeConnection() + + result = asyncio.run(ScadaInfoRepository.get_scadas(conn)) + + assert result == [ + { + "id": "25470001", + "type": "pressure", + "associated_element_id": "J1", + "api_query_id": "query-1", + "transmission_mode": "realtime", + "transmission_frequency": None, + "reliability": 0.95, + "x": 117.1, + "y": 32.9, + } + ] + assert "associated_element_id" in conn.cursor_instance.query + assert "FROM public.scada_info" in conn.cursor_instance.query diff --git a/tests/unit/test_pressure_cleaning.py b/tests/unit/test_pressure_cleaning.py index 9ccd9d8..8023590 100644 --- a/tests/unit/test_pressure_cleaning.py +++ b/tests/unit/test_pressure_cleaning.py @@ -1,47 +1,25 @@ -import importlib.util -import sys -import types from pathlib import Path import numpy as np import pandas as pd +import pytest + +from app.algorithms.cleaning import pressure as pressure_cleaning -def _load_pressure_cleaning_module(): - project_root = Path(__file__).resolve().parents[2] - utils_path = project_root / "app" / "algorithms" / "_utils.py" - pressure_path = project_root / "app" / "algorithms" / "cleaning" / "pressure.py" - - app_module = sys.modules.setdefault("app", types.ModuleType("app")) - algorithms_module = sys.modules.setdefault( - "app.algorithms", - types.ModuleType("app.algorithms"), - ) - setattr(app_module, "algorithms", algorithms_module) - - utils_spec = importlib.util.spec_from_file_location("app.algorithms._utils", utils_path) - assert utils_spec and utils_spec.loader - utils_module = importlib.util.module_from_spec(utils_spec) - sys.modules["app.algorithms._utils"] = utils_module - utils_spec.loader.exec_module(utils_module) - - pressure_spec = importlib.util.spec_from_file_location( - "tests_pressure_under_test", - pressure_path, - ) - assert pressure_spec and pressure_spec.loader - pressure_module = importlib.util.module_from_spec(pressure_spec) - pressure_spec.loader.exec_module(pressure_module) - return pressure_module - +DATA_DIR = Path(__file__).resolve().parents[3] / "data" +RAW_DATA_PATH = DATA_DIR / "node_simulation.csv" +NOISY_DATA_PATH = DATA_DIR / "node_simulation_noisy.csv" +REQUIRES_PRESSURE_SAMPLES = pytest.mark.skipif( + not RAW_DATA_PATH.exists() or not NOISY_DATA_PATH.exists(), + reason="pressure cleaning sample CSV files are not available", +) +@REQUIRES_PRESSURE_SAMPLES def test_clean_pressure_data_df_km_repairs_long_form_pressure_series(): - module = _load_pressure_cleaning_module() - repo_root = Path(__file__).resolve().parents[3] - - raw_df = pd.read_csv(repo_root / "data" / "node_simulation.csv") - noisy_df = pd.read_csv(repo_root / "data" / "node_simulation_noisy.csv") - cleaned_df = module.clean_pressure_data_df_km(noisy_df) + raw_df = pd.read_csv(RAW_DATA_PATH) + noisy_df = pd.read_csv(NOISY_DATA_PATH) + cleaned_df = pressure_cleaning.clean_pressure_data_df_km(noisy_df) for df in (raw_df, noisy_df, cleaned_df): df["time"] = pd.to_datetime(df["time"]) @@ -50,7 +28,12 @@ def test_clean_pressure_data_df_km_repairs_long_form_pressure_series(): assert set(cleaned_df.columns) == {"time", "id", "pressure"} assert cleaned_df["pressure"].isna().sum() == 0 - noisy_joined = raw_df.merge(noisy_df, on=["time", "id"], how="inner", suffixes=("_raw", "_noisy")) + noisy_joined = raw_df.merge( + noisy_df, + on=["time", "id"], + how="inner", + suffixes=("_raw", "_noisy"), + ) cleaned_joined = raw_df.merge( cleaned_df, on=["time", "id"], @@ -59,10 +42,20 @@ def test_clean_pressure_data_df_km_repairs_long_form_pressure_series(): ) noisy_rmse = float( - np.sqrt(np.mean((noisy_joined["pressure_raw"] - noisy_joined["pressure_noisy"]) ** 2)) + np.sqrt( + np.mean( + (noisy_joined["pressure_raw"] - noisy_joined["pressure_noisy"]) + ** 2 + ) + ) ) cleaned_rmse = float( - np.sqrt(np.mean((cleaned_joined["pressure_raw"] - cleaned_joined["pressure_clean"]) ** 2)) + np.sqrt( + np.mean( + (cleaned_joined["pressure_raw"] - cleaned_joined["pressure_clean"]) + ** 2 + ) + ) ) noisy_mae = float( np.mean(np.abs(noisy_joined["pressure_raw"] - noisy_joined["pressure_noisy"])) @@ -88,11 +81,9 @@ def test_clean_pressure_data_df_km_repairs_long_form_pressure_series(): assert abs(spike_row - 28.018701553344727) < 2.0 +@REQUIRES_PRESSURE_SAMPLES def test_clean_pressure_data_df_km_accepts_single_sensor_wide_frame_with_utc_strings(): - module = _load_pressure_cleaning_module() - repo_root = Path(__file__).resolve().parents[3] - - noisy_df = pd.read_csv(repo_root / "data" / "node_simulation_noisy.csv") + noisy_df = pd.read_csv(NOISY_DATA_PATH) single_sensor = ( noisy_df[noisy_df["id"] == 170490][["time", "pressure"]] .rename(columns={"pressure": "170490"}) @@ -102,7 +93,7 @@ def test_clean_pressure_data_df_km_accepts_single_sensor_wide_frame_with_utc_str pd.to_datetime(single_sensor["time"], utc=True).dt.strftime("%Y-%m-%dT%H:%M:%SZ") ) - cleaned_df = module.clean_pressure_data_df_km(single_sensor) + cleaned_df = pressure_cleaning.clean_pressure_data_df_km(single_sensor) assert len(cleaned_df) == 192 assert cleaned_df["170490"].isna().sum() == 0 diff --git a/tests/unit/test_project_scada_metadata.py b/tests/unit/test_project_scada_metadata.py new file mode 100644 index 0000000..b977838 --- /dev/null +++ b/tests/unit/test_project_scada_metadata.py @@ -0,0 +1,121 @@ +import asyncio +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +from app.api.v1.endpoints import project_data +from app.infra.db.timescaledb import composite_queries + + +PROJECT_SCADA = { + "id": "fengyang-pressure-1", + "type": "pressure", + "associated_element_id": "J1", + "api_query_id": "query-1", + "transmission_mode": "realtime", + "transmission_frequency": None, + "reliability": 1.0, + "x": 117.1, + "y": 32.9, +} +START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc) +END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc) + + +def _patch_project_scadas(monkeypatch): + monkeypatch.setattr( + composite_queries.ScadaInfoRepository, + "get_scadas", + AsyncMock(return_value=[PROJECT_SCADA.copy()]), + ) + + +def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch): + _patch_project_scadas(monkeypatch) + query_mock = AsyncMock(return_value=[{"time": START_TIME, "value": 26.5}]) + monkeypatch.setattr( + composite_queries.RealtimeRepository, + "get_node_field_by_time_range", + query_mock, + ) + + result = asyncio.run( + composite_queries.CompositeQueries.get_scada_associated_realtime_simulation_data( + object(), + object(), + [PROJECT_SCADA["id"]], + START_TIME, + END_TIME, + ) + ) + + assert result[PROJECT_SCADA["id"]][0]["scada_id"] == PROJECT_SCADA["id"] + assert query_mock.await_count == 1 + assert query_mock.await_args.args[1:] == ( + START_TIME, + END_TIME, + "J1", + "pressure", + ) + + +def test_scheme_scada_simulation_uses_current_project_metadata(monkeypatch): + _patch_project_scadas(monkeypatch) + query_mock = AsyncMock(return_value=[{"time": START_TIME, "value": 26.5}]) + monkeypatch.setattr( + composite_queries.SchemeRepository, + "get_node_field_by_scheme_and_time_range", + query_mock, + ) + + result = asyncio.run( + composite_queries.CompositeQueries.get_scada_associated_scheme_simulation_data( + object(), + object(), + [PROJECT_SCADA["id"]], + START_TIME, + END_TIME, + "baseline", + "scheme-1", + ) + ) + + assert result[PROJECT_SCADA["id"]][0]["scada_id"] == PROJECT_SCADA["id"] + assert query_mock.await_args.args[5:] == ("J1", "pressure") + + +def test_element_scada_query_uses_current_project_metadata(monkeypatch): + _patch_project_scadas(monkeypatch) + query_mock = AsyncMock( + return_value={ + PROJECT_SCADA["id"]: [{"time": START_TIME, "value": 26.5}] + } + ) + monkeypatch.setattr( + composite_queries.ScadaRepository, + "get_scada_field_by_id_time_range", + query_mock, + ) + + result = asyncio.run( + composite_queries.CompositeQueries.get_element_associated_scada_data( + object(), + object(), + "J1", + START_TIME, + END_TIME, + ) + ) + + assert result == {"J1": [{"time": START_TIME, "value": 26.5}]} + + +def test_scada_info_endpoint_uses_current_project_connection(monkeypatch): + monkeypatch.setattr( + project_data.ScadaInfoRepository, + "get_scadas", + AsyncMock(return_value=[PROJECT_SCADA.copy()]), + ) + + result = asyncio.run(project_data.get_scada_info_with_connection(object())) + + assert result == {"success": True, "data": [PROJECT_SCADA], "count": 1} diff --git a/tests/unit/test_realtime_repository.py b/tests/unit/test_realtime_repository.py index 31f64f6..9559840 100644 --- a/tests/unit/test_realtime_repository.py +++ b/tests/unit/test_realtime_repository.py @@ -1,48 +1,7 @@ import asyncio from datetime import datetime, timezone -import importlib.util -from pathlib import Path -import sys -from types import ModuleType - -def _load_time_api_module(): - module_path = ( - Path(__file__).resolve().parents[2] / "app" / "services" / "time_api.py" - ) - spec = importlib.util.spec_from_file_location("tests_time_api_under_test", module_path) - module = importlib.util.module_from_spec(spec) - assert spec and spec.loader - spec.loader.exec_module(module) - return module - - -def _load_realtime_repository(): - time_api_module = _load_time_api_module() - app_module = ModuleType("app") - services_module = ModuleType("app.services") - services_module.time_api = time_api_module - app_module.services = services_module - sys.modules["app"] = app_module - sys.modules["app.services"] = services_module - sys.modules["app.services.time_api"] = time_api_module - - module_path = ( - Path(__file__).resolve().parents[2] - / "app" - / "infra" - / "db" - / "timescaledb" - / "repositories" - / "realtime.py" - ) - spec = importlib.util.spec_from_file_location( - "tests_realtime_repo_under_test", module_path - ) - module = importlib.util.module_from_spec(spec) - assert spec and spec.loader - spec.loader.exec_module(module) - return module.RealtimeRepository +from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository class _FakeCursor: @@ -71,7 +30,6 @@ class _FakeConnection: def test_get_links_by_time_range_normalizes_inputs_to_utc(): - RealtimeRepository = _load_realtime_repository() conn = _FakeConnection() asyncio.run( @@ -91,7 +49,6 @@ def test_get_links_by_time_range_normalizes_inputs_to_utc(): def test_get_nodes_by_time_range_normalizes_inputs_to_utc(): - RealtimeRepository = _load_realtime_repository() conn = _FakeConnection() asyncio.run( diff --git a/tests/unit/test_scada_cleaning.py b/tests/unit/test_scada_cleaning.py new file mode 100644 index 0000000..6a37145 --- /dev/null +++ b/tests/unit/test_scada_cleaning.py @@ -0,0 +1,200 @@ +import asyncio +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pandas as pd +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints.timeseries import composite as composite_endpoint +from app.infra.db.timescaledb import composite_queries + + +def test_clean_scada_uses_current_project_metadata(monkeypatch): + """Fengyang data must not be classified with the global tjwater metadata.""" + + monkeypatch.setattr( + composite_queries.ScadaInfoRepository, + "get_scadas", + AsyncMock( + return_value=[{"id": "fengyang-pressure-1", "type": "pressure"}] + ), + ) + monkeypatch.setattr( + composite_queries.ScadaRepository, + "get_scada_field_by_id_time_range", + AsyncMock( + return_value={ + "fengyang-pressure-1": [ + {"time": "2026-06-01T00:00:00+08:00", "value": 26.5} + ] + } + ), + ) + update_mock = AsyncMock() + monkeypatch.setattr( + composite_queries.ScadaRepository, + "update_scada_field", + update_mock, + ) + monkeypatch.setattr( + composite_queries, + "clean_pressure_data_df_km", + lambda frame: pd.DataFrame( + { + "time": frame["time"], + "fengyang-pressure-1": frame["fengyang-pressure-1"], + } + ), + ) + + result = asyncio.run( + composite_queries.CompositeQueries.clean_scada_data( + object(), + object(), + ["fengyang-pressure-1"], + datetime(2026, 6, 1, tzinfo=timezone.utc), + datetime(2026, 6, 2, tzinfo=timezone.utc), + ) + ) + + assert result == "success" + update_mock.assert_awaited_once() + + +def test_clean_scada_rejects_devices_missing_from_project_metadata(monkeypatch): + monkeypatch.setattr( + composite_queries.ScadaInfoRepository, + "get_scadas", + AsyncMock(return_value=[{"id": "other-device", "type": "pressure"}]), + ) + query_mock = AsyncMock() + monkeypatch.setattr( + composite_queries.ScadaRepository, + "get_scada_field_by_id_time_range", + query_mock, + ) + + with pytest.raises(ValueError, match="缺少元数据"): + asyncio.run( + composite_queries.CompositeQueries.clean_scada_data( + object(), + object(), + ["fengyang-pressure-1"], + datetime(2026, 6, 1, tzinfo=timezone.utc), + datetime(2026, 6, 2, tzinfo=timezone.utc), + ) + ) + + query_mock.assert_not_awaited() + + +def test_clean_scada_rejects_zero_database_updates(monkeypatch): + monkeypatch.setattr( + composite_queries.ScadaInfoRepository, + "get_scadas", + AsyncMock( + return_value=[{"id": "fengyang-pressure-1", "type": "pressure"}] + ), + ) + monkeypatch.setattr( + composite_queries.ScadaRepository, + "get_scada_field_by_id_time_range", + AsyncMock( + return_value={ + "fengyang-pressure-1": [ + {"time": "2026-06-01T00:00:00+08:00", "value": 26.5} + ] + } + ), + ) + update_mock = AsyncMock() + monkeypatch.setattr( + composite_queries.ScadaRepository, + "update_scada_field", + update_mock, + ) + monkeypatch.setattr( + composite_queries, + "clean_pressure_data_df_km", + lambda _frame: pd.DataFrame( + {"time": [], "fengyang-pressure-1": []} + ), + ) + + with pytest.raises(ValueError, match="未产生任何数据库更新"): + asyncio.run( + composite_queries.CompositeQueries.clean_scada_data( + object(), + object(), + ["fengyang-pressure-1"], + datetime(2026, 6, 1, tzinfo=timezone.utc), + datetime(2026, 6, 2, tzinfo=timezone.utc), + ) + ) + + update_mock.assert_not_awaited() + + +def test_clean_scada_propagates_write_failures(monkeypatch): + monkeypatch.setattr( + composite_queries.ScadaInfoRepository, + "get_scadas", + AsyncMock( + return_value=[{"id": "fengyang-pressure-1", "type": "pressure"}] + ), + ) + monkeypatch.setattr( + composite_queries.ScadaRepository, + "get_scada_field_by_id_time_range", + AsyncMock( + return_value={ + "fengyang-pressure-1": [ + {"time": "2026-06-01T00:00:00+08:00", "value": 26.5} + ] + } + ), + ) + monkeypatch.setattr( + composite_queries.ScadaRepository, + "update_scada_field", + AsyncMock(side_effect=RuntimeError("database write failed")), + ) + monkeypatch.setattr( + composite_queries, + "clean_pressure_data_df_km", + lambda frame: frame, + ) + + with pytest.raises(RuntimeError, match="database write failed"): + asyncio.run( + composite_queries.CompositeQueries.clean_scada_data( + object(), + object(), + ["fengyang-pressure-1"], + datetime(2026, 6, 1, tzinfo=timezone.utc), + datetime(2026, 6, 2, tzinfo=timezone.utc), + ) + ) + + +def test_clean_scada_endpoint_returns_http_400_for_validation_error(monkeypatch): + monkeypatch.setattr( + composite_endpoint.CompositeQueries, + "clean_scada_data", + AsyncMock(side_effect=ValueError("当前项目没有可清洗的 SCADA 设备")), + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + composite_endpoint.clean_scada_data( + device_ids="all", + start_time=datetime(2026, 6, 1, tzinfo=timezone.utc), + end_time=datetime(2026, 6, 2, tzinfo=timezone.utc), + timescale_conn=object(), + postgres_conn=object(), + ) + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "当前项目没有可清洗的 SCADA 设备" From db6032bd841cc5c2ac83b110e17147c5240aae38 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 17 Jul 2026 16:31:46 +0800 Subject: [PATCH 65/93] feat(burst-detection): update scada analysis flow --- app/api/v1/endpoints/burst_detection.py | 10 + app/infra/db/timescaledb/internal_queries.py | 32 ++ .../db/timescaledb/repositories/scada.py | 21 + app/services/burst_detection.py | 392 ++++++++++++++++-- tests/unit/test_burst_detection_service.py | 160 +++++++ 5 files changed, 586 insertions(+), 29 deletions(-) create mode 100644 tests/unit/test_burst_detection_service.py diff --git a/app/api/v1/endpoints/burst_detection.py b/app/api/v1/endpoints/burst_detection.py index 37d7849..e1cb237 100644 --- a/app/api/v1/endpoints/burst_detection.py +++ b/app/api/v1/endpoints/burst_detection.py @@ -30,6 +30,16 @@ class BurstDetectionRequest(BaseModel): points_per_day: int = Field(1440, description="每天的数据点数") mu: int = Field(100, description="异常值检测的参数") iforest_params: dict[str, Any] | None = Field(None, description="隔离森林算法参数") + target_time: datetime | None = Field( + None, + description="目标侦测时刻;为空时自动使用最近一个完整的监测时刻", + ) + sampling_interval_minutes: int | None = Field( + None, + ge=1, + le=1440, + description="采样间隔(分钟);为空时根据压力 SCADA 传输频率自动推断", + ) scada_start: datetime | None = Field(None, description="SCADA数据起始时间") scada_end: datetime | None = Field(None, description="SCADA数据结束时间") sensor_nodes: list[str] | None = Field(None, description="传感器节点列表") diff --git a/app/infra/db/timescaledb/internal_queries.py b/app/infra/db/timescaledb/internal_queries.py index 16d45fe..5126cf8 100644 --- a/app/infra/db/timescaledb/internal_queries.py +++ b/app/infra/db/timescaledb/internal_queries.py @@ -169,6 +169,38 @@ class InternalQueries: else: raise + @staticmethod + def query_latest_scada_time( + device_ids: List[str], + before_time: str | datetime | None = None, + db_name: str = None, + max_retries: int = 3, + ) -> datetime | None: + """Return the latest SCADA timestamp for the selected devices.""" + before_dt = ( + parse_utc_time(before_time, field_name="before_time") + if before_time is not None + else None + ) + for attempt in range(max_retries): + try: + conn_string = ( + get_timescaledb_pgconn_string(db_name=db_name) + if db_name + else get_timescaledb_pgconn_string() + ) + with psycopg.Connection.connect(conn_string) as conn: + return ScadaRepository.get_latest_scada_time_sync( + conn, + device_ids, + before_dt, + ) + except Exception: + if attempt < max_retries - 1: + time.sleep(1) + else: + raise + @staticmethod def query_realtime_simulation_by_ids_timerange( element_ids: List[str], diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py index d5a6348..b28dfea 100644 --- a/app/infra/db/timescaledb/repositories/scada.py +++ b/app/infra/db/timescaledb/repositories/scada.py @@ -54,6 +54,27 @@ class ScadaRepository: ) return cur.fetchall() + @staticmethod + def get_latest_scada_time_sync( + conn: Connection, + device_ids: List[str], + before_time: datetime | None = None, + ) -> datetime | None: + with conn.cursor(row_factory=dict_row) as cur: + if before_time is None: + cur.execute( + "SELECT max(time) AS time FROM scada.scada_data WHERE device_id = ANY(%s)", + (device_ids,), + ) + else: + cur.execute( + "SELECT max(time) AS time FROM scada.scada_data " + "WHERE device_id = ANY(%s) AND time <= %s", + (device_ids, before_time), + ) + row = cur.fetchone() + return row["time"] if row else None + @staticmethod async def get_scada_field_by_id_time_range( conn: AsyncConnection, diff --git a/app/services/burst_detection.py b/app/services/burst_detection.py index 9665934..e9d35a4 100644 --- a/app/services/burst_detection.py +++ b/app/services/burst_detection.py @@ -1,8 +1,10 @@ from __future__ import annotations -from datetime import datetime +from collections import Counter +from datetime import datetime, timedelta from typing import Any +import numpy as np import pandas as pd from app.algorithms.burst_detection.burst_detector import BurstDetector @@ -17,6 +19,15 @@ from app.services.tjnetwork import get_all_scada_info from app.services.time_api import extract_date, parse_utc_time, utc_now +TARGET_DAY_COUNT = 15 +DEFAULT_SAMPLE_INTERVAL_MINUTES = 15 +TARGET_MU = 1 +TARGET_N_ESTIMATORS = 50 +TARGET_RANDOM_STATE = 42 +TARGET_SCORE_THRESHOLD = -0.04 +MIN_COMPLETE_SENSORS = 5 + + def run_burst_detection( *, network: str, @@ -31,6 +42,8 @@ def run_burst_detection( points_per_day: int = 1440, mu: int = 100, iforest_params: dict[str, Any] | None = None, + target_time: datetime | str | None = None, + sampling_interval_minutes: int | None = None, scada_start: datetime | str | None = None, scada_end: datetime | str | None = None, sensor_nodes: list[str] | None = None, @@ -42,7 +55,8 @@ def run_burst_detection( """ 运行爆管侦测服务入口。 - 调用方式二选一: + 调用方式三选一: + - 不传数据时间窗,自动侦测最近完整时刻;可用 `target_time` 回放历史时刻 - 直接传 `observed_pressure_data` - 或传 `scada_start/scada_end` 让后端自动查询 SCADA 压力数据 @@ -74,8 +88,65 @@ def run_burst_detection( else None ) use_scada_source = scada_start is not None or scada_end is not None + use_target_mode = ( + observed_pressure_data is None + and not use_scada_source + and data_source != "simulation" + ) or target_time is not None - if use_scada_source: + resolved_target_time: datetime | None = None + requested_target_time: datetime | None = None + excluded_sensors: list[dict[str, str]] = [] + daily_times: list[datetime] | None = None + resolved_sampling_interval_minutes: int | None = None + + if use_target_mode: + if observed_pressure_data is not None or use_scada_source: + raise ValueError( + "target_time 不能与 observed_pressure_data 或 scada_start/scada_end 同时使用。" + ) + scada_sensor_nodes = ( + selected_sensor_nodes + if selected_sensor_nodes is not None + else _get_pressure_sensor_nodes(network) + ) + requested_target_time = ( + _to_datetime(target_time) if target_time is not None else None + ) + resolved_sampling_interval_minutes = _resolve_sampling_interval_minutes( + network=network, + sensor_nodes=scada_sensor_nodes, + requested_interval=sampling_interval_minutes, + ) + target_points_per_day = 1440 // resolved_sampling_interval_minutes + ( + observed_input, + resolved_target_time, + excluded_sensors, + ) = _build_target_pressure_from_scada( + network=network, + sensor_nodes=scada_sensor_nodes, + requested_target_time=requested_target_time, + sampling_interval_minutes=resolved_sampling_interval_minutes, + points_per_day=target_points_per_day, + ) + selected_sensor_nodes = list(observed_input.columns) + observed_source = ( + "latest_monitoring" if target_time is None else "historical_monitoring" + ) + points_per_day = target_points_per_day + mu = TARGET_MU + iforest_params = { + "n_estimators": TARGET_N_ESTIMATORS, + "random_state": TARGET_RANDOM_STATE, + "contamination": "auto", + } + daily_times = [ + resolved_target_time - timedelta(days=offset) + for offset in range(TARGET_DAY_COUNT - 1, -1, -1) + ] + + elif use_scada_source: scada_sensor_nodes = ( selected_sensor_nodes if selected_sensor_nodes is not None @@ -121,7 +192,16 @@ def run_burst_detection( sensor_nodes=selected_sensor_nodes, ) resolved_sensor_nodes = list(result_df.attrs.get("sensor_nodes", [])) - rows = _serialize_result_rows(result_df) + rows = _serialize_result_rows( + result_df, + daily_times=daily_times, + target_only=use_target_mode, + ) + summary = _build_detection_summary( + result_df, + daily_times=daily_times, + target_only=use_target_mode, + ) payload: dict[str, Any] = { "network": network, "sensor_nodes": resolved_sensor_nodes, @@ -130,7 +210,17 @@ def run_burst_detection( "points_per_day": int(result_df.attrs.get("points_per_day", points_per_day)), "day_count": int(result_df.attrs.get("day_count", len(result_df))), "rows": rows, - "summary": _build_detection_summary(result_df), + "summary": summary, + "algorithm_params": { + "mu": mu, + "points_per_day": points_per_day, + "iforest_params": detector.iforest_params, + **( + {"score_threshold": TARGET_SCORE_THRESHOLD} + if use_target_mode + else {} + ), + }, } if data_source == "simulation": payload["data_source"] = "simulation" @@ -141,7 +231,50 @@ def run_burst_detection( else: payload["data_source"] = "monitoring" - if use_scada_source: + if ( + use_target_mode + and resolved_target_time is not None + and resolved_sampling_interval_minutes is not None + ): + sample_start = resolved_target_time - timedelta( + days=TARGET_DAY_COUNT, + minutes=-resolved_sampling_interval_minutes, + ) + payload.update( + { + "requested_target_time": ( + requested_target_time.isoformat() + if requested_target_time is not None + else None + ), + "target_time": resolved_target_time.isoformat(), + "reference_window": { + "start": (resolved_target_time - timedelta(days=14)).isoformat(), + "end": (resolved_target_time - timedelta(days=1)).isoformat(), + "day_count": 14, + }, + "sampling_interval_minutes": resolved_sampling_interval_minutes, + "daily_scores": [ + { + "timestamp": row["Timestamp"], + "role": row["Role"], + "score": row["Score"], + "raw_prediction": row["Prediction"], + } + for row in rows + ], + "data_quality": { + "included_sensors": resolved_sensor_nodes, + "excluded_sensors": excluded_sensors, + "minimum_required_sensors": MIN_COMPLETE_SENSORS, + }, + "scada_window": { + "start": sample_start.isoformat(), + "end": resolved_target_time.isoformat(), + }, + } + ) + elif use_scada_source: payload["scada_window"] = { "start": _to_datetime(scada_start).isoformat(), "end": _to_datetime(scada_end).isoformat(), @@ -294,22 +427,49 @@ def _store_burst_detection_scheme( ) -def _serialize_result_rows(result_df: pd.DataFrame) -> list[dict[str, Any]]: +def _serialize_result_rows( + result_df: pd.DataFrame, + *, + daily_times: list[datetime] | None = None, + target_only: bool = False, +) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] - for row in result_df.to_dict(orient="records"): + raw_rows = result_df.to_dict(orient="records") + for index, row in enumerate(raw_rows): + is_target = index == len(raw_rows) - 1 + is_burst = bool(row["IsBurst"]) + if target_only: + is_burst = is_target and float(row["Score"]) <= TARGET_SCORE_THRESHOLD rows.append( { "Day": int(row["Day"]), "Score": float(row["Score"]), "Prediction": int(row["Prediction"]), - "IsBurst": bool(row["IsBurst"]), + "IsBurst": is_burst, + **( + { + "Timestamp": daily_times[index].isoformat(), + "Role": "target" if is_target else "reference", + } + if daily_times is not None + else {} + ), } ) return rows -def _build_detection_summary(result_df: pd.DataFrame) -> dict[str, Any]: - rows = _serialize_result_rows(result_df) +def _build_detection_summary( + result_df: pd.DataFrame, + *, + daily_times: list[datetime] | None = None, + target_only: bool = False, +) -> dict[str, Any]: + rows = _serialize_result_rows( + result_df, + daily_times=daily_times, + target_only=target_only, + ) if not rows: raise ValueError("爆管侦测结果为空。") @@ -318,7 +478,7 @@ def _build_detection_summary(result_df: pd.DataFrame) -> dict[str, Any]: latest_row = rows[-1] anomaly_days = [row["Day"] for row in rows if row["IsBurst"]] - return { + summary = { "burst_detected": bool(latest_row["IsBurst"]), "latest_day": latest_row, "most_anomalous_day": int(result_df.iloc[most_anomalous_index]["Day"]), @@ -326,6 +486,18 @@ def _build_detection_summary(result_df: pd.DataFrame) -> dict[str, Any]: "anomaly_day_count": len(anomaly_days), "latest_sensor_rankings": _build_latest_sensor_rankings(result_df), } + if target_only: + target_score = float(latest_row["Score"]) + summary.update( + { + "target_score": target_score, + "score_threshold": TARGET_SCORE_THRESHOLD, + "target_rank": int(result_df["Score"].rank(method="min").iloc[-1]), + "target_time": latest_row.get("Timestamp"), + "reference_day_count": TARGET_DAY_COUNT - 1, + } + ) + return summary def _build_latest_sensor_rankings(result_df: pd.DataFrame) -> list[dict[str, Any]]: @@ -334,20 +506,194 @@ def _build_latest_sensor_rankings(result_df: pd.DataFrame) -> list[dict[str, Any if feature_matrix is None or len(sensor_nodes) == 0: return [] - latest_values = feature_matrix[-1] + latest_values = np.asarray(feature_matrix[-1], dtype=float) + history = np.asarray(feature_matrix[:-1], dtype=float) + history_means = history.mean(axis=0) + history_stds = history.std(axis=0) + safe_stds = np.where(history_stds > 1e-9, history_stds, 1e-9) + deviations = (latest_values - history_means) / safe_stds ranking = sorted( - zip(sensor_nodes, latest_values, strict=False), - key=lambda item: item[1], + zip( + sensor_nodes, + latest_values, + history_means, + history_stds, + deviations, + strict=False, + ), + key=lambda item: item[4], ) return [ { "sensor_node": sensor_id, "latest_high_frequency_value": float(value), + "historical_mean": float(history_mean), + "historical_std": float(history_std), + "standardized_deviation": float(deviation), } - for sensor_id, value in ranking[: min(10, len(ranking))] + for sensor_id, value, history_mean, history_std, deviation in ranking[ + : min(10, len(ranking)) + ] ] +def _build_target_pressure_from_scada( + *, + network: str, + sensor_nodes: list[str], + requested_target_time: datetime | None, + sampling_interval_minutes: int, + points_per_day: int, +) -> tuple[pd.DataFrame, datetime, list[dict[str, str]]]: + node_query_id = _get_pressure_sensor_mapping(network) + mapped_nodes = [node for node in sensor_nodes if node in node_query_id] + excluded_without_mapping = [ + {"sensor_node": node, "reason": "missing_api_query_id"} + for node in sensor_nodes + if node not in node_query_id + ] + if len(mapped_nodes) < MIN_COMPLETE_SENSORS: + raise ValueError( + f"可查询的压力测点少于 {MIN_COMPLETE_SENSORS} 个,无法执行爆管侦测。" + ) + + query_ids = [node_query_id[node] for node in mapped_nodes] + candidate_before = requested_target_time + last_excluded: list[dict[str, str]] = excluded_without_mapping + + for _ in range(4): + resolved_target = InternalQueries.query_latest_scada_time( + db_name=network, + device_ids=query_ids, + before_time=candidate_before, + ) + if resolved_target is None: + break + + sample_start = resolved_target - timedelta( + days=TARGET_DAY_COUNT, + minutes=-sampling_interval_minutes, + ) + expected_index = pd.date_range( + start=sample_start, + end=resolved_target, + freq=f"{sampling_interval_minutes}min", + ) + scada_data = InternalQueries.query_scada_by_ids_timerange( + db_name=network, + device_ids=query_ids, + start_time=sample_start, + end_time=resolved_target, + ) + + complete_columns: dict[str, pd.Series] = {} + excluded = list(excluded_without_mapping) + for node_id in mapped_nodes: + query_id = node_query_id[node_id] + records = scada_data.get(query_id, []) + if not records: + excluded.append({"sensor_node": node_id, "reason": "no_data"}) + continue + + record_frame = pd.DataFrame.from_records(records) + record_frame["time"] = pd.to_datetime(record_frame["time"], utc=True) + record_frame["value"] = pd.to_numeric( + record_frame["value"], errors="coerce" + ) + series = ( + record_frame.drop_duplicates(subset="time", keep="last") + .set_index("time")["value"] + .reindex(expected_index) + ) + if len(series) != TARGET_DAY_COUNT * points_per_day: + excluded.append( + {"sensor_node": node_id, "reason": "unexpected_sample_count"} + ) + continue + if series.isna().any(): + excluded.append( + {"sensor_node": node_id, "reason": "missing_or_invalid_samples"} + ) + continue + complete_columns[node_id] = series + + if len(complete_columns) >= MIN_COMPLETE_SENSORS: + observation_df = pd.DataFrame(complete_columns, index=expected_index) + return observation_df, resolved_target, excluded + + last_excluded = excluded + candidate_before = resolved_target - timedelta(microseconds=1) + + excluded_preview = ", ".join( + item["sensor_node"] for item in last_excluded[:10] + ) + raise ValueError( + f"最近数据中完整压力测点少于 {MIN_COMPLETE_SENSORS} 个;" + f"请检查 15 天数据完整性。排除测点: {excluded_preview or '无'}" + ) + + +def _resolve_sampling_interval_minutes( + *, + network: str, + sensor_nodes: list[str], + requested_interval: int | None, +) -> int: + if requested_interval is not None: + interval = int(requested_interval) + else: + selected_nodes = set(sensor_nodes) + inferred_intervals = [ + parsed + for item in get_all_scada_info(network) + if str(item.get("type", "")).lower() == "pressure" + and str(item.get("associated_element_id", "")) in selected_nodes + and ( + parsed := _parse_sampling_interval_minutes( + item.get("transmission_frequency") + ) + ) + is not None + ] + interval = ( + Counter(inferred_intervals).most_common(1)[0][0] + if inferred_intervals + else DEFAULT_SAMPLE_INTERVAL_MINUTES + ) + + if interval <= 0 or 1440 % interval != 0: + raise ValueError("采样间隔必须是能整除 1440 分钟的正整数。") + return interval + + +def _parse_sampling_interval_minutes(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, (int, float)): + minutes = float(value) + else: + try: + minutes = pd.to_timedelta(str(value)).total_seconds() / 60 + except (TypeError, ValueError): + return None + rounded = round(minutes) + if minutes <= 0 or abs(minutes - rounded) > 1e-6: + return None + return int(rounded) + + +def _get_pressure_sensor_mapping(network: str) -> dict[str, str]: + node_query_id: dict[str, str] = {} + for item in get_all_scada_info(network): + if str(item.get("type", "")).lower() != "pressure": + continue + node_id = item.get("associated_element_id") + query_id = item.get("api_query_id") + if node_id and query_id is not None: + node_query_id[str(node_id)] = str(query_id) + return node_query_id + + def _get_pressure_sensor_nodes(network: str) -> list[str]: sensor_nodes: list[str] = [] for item in get_all_scada_info(network): @@ -377,19 +723,7 @@ def _build_observed_pressure_from_scada( if start_dt >= end_dt: raise ValueError("SCADA 时间窗非法:scada_start 必须早于 scada_end。") - node_query_id: dict[str, str] = {} - for item in get_all_scada_info(network): - if str(item.get("type", "")).lower() != "pressure": - continue - node_id = item.get("associated_element_id") - query_id = item.get("api_query_id") - if ( - isinstance(node_id, str) - and node_id - and isinstance(query_id, str) - and query_id - ): - node_query_id[node_id] = query_id + node_query_id = _get_pressure_sensor_mapping(network) missing_nodes = [node_id for node_id in sensor_nodes if node_id not in node_query_id] if missing_nodes: diff --git a/tests/unit/test_burst_detection_service.py b/tests/unit/test_burst_detection_service.py new file mode 100644 index 0000000..d901d90 --- /dev/null +++ b/tests/unit/test_burst_detection_service.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import numpy as np +import pandas as pd + +from app.services import burst_detection + + +TARGET = datetime(2026, 6, 20, 5, 30, tzinfo=timezone.utc) + + +def _complete_records(*, target: datetime, offset: float = 0.0) -> list[dict]: + start = target - timedelta(days=15) + timedelta(minutes=15) + return [ + { + "time": (start + timedelta(minutes=15 * index)).isoformat(), + "value": float(index % 96) + offset, + } + for index in range(15 * 96) + ] + + +def test_build_target_pressure_aligns_timestamps_and_excludes_incomplete_sensor( + monkeypatch, +): + nodes = [f"J{index}" for index in range(6)] + mapping = {node: f"D{index}" for index, node in enumerate(nodes)} + scada_data = { + query_id: _complete_records(target=TARGET, offset=float(index)) + for index, query_id in enumerate(mapping.values()) + } + scada_data["D5"] = scada_data["D5"][:-1] + + monkeypatch.setattr( + burst_detection, + "_get_pressure_sensor_mapping", + lambda _network: mapping, + ) + monkeypatch.setattr( + burst_detection.InternalQueries, + "query_latest_scada_time", + lambda **_kwargs: TARGET, + ) + monkeypatch.setattr( + burst_detection.InternalQueries, + "query_scada_by_ids_timerange", + lambda **_kwargs: scada_data, + ) + + frame, resolved_target, excluded = ( + burst_detection._build_target_pressure_from_scada( + network="test", + sensor_nodes=nodes, + requested_target_time=TARGET, + sampling_interval_minutes=15, + points_per_day=96, + ) + ) + + assert resolved_target == TARGET + assert frame.shape == (1440, 5) + assert frame.index[-1].to_pydatetime() == TARGET + assert excluded == [ + {"sensor_node": "J5", "reason": "missing_or_invalid_samples"} + ] + + +def test_target_mode_uses_fixed_parameters_and_only_classifies_target(monkeypatch): + index = pd.date_range( + start=TARGET - timedelta(days=15) + timedelta(minutes=15), + end=TARGET, + freq="15min", + ) + values = np.tile(np.arange(96, dtype=float), 15) + frame = pd.DataFrame( + {f"J{sensor}": values + sensor for sensor in range(5)}, + index=index, + ) + monkeypatch.setattr( + burst_detection, + "_get_pressure_sensor_nodes", + lambda _network: list(frame.columns), + ) + monkeypatch.setattr( + burst_detection, + "_build_target_pressure_from_scada", + lambda **_kwargs: (frame, TARGET, []), + ) + + payload = burst_detection.run_burst_detection( + network="test", + username="tester", + sampling_interval_minutes=15, + ) + + assert payload["target_time"] == TARGET.isoformat() + assert payload["sample_count"] == 1440 + assert payload["points_per_day"] == 96 + assert payload["algorithm_params"]["mu"] == 1 + assert payload["summary"]["score_threshold"] == -0.04 + assert [row["Role"] for row in payload["rows"]].count("target") == 1 + assert all(not row["IsBurst"] for row in payload["rows"][:-1]) + assert payload["reference_window"] == { + "start": (TARGET - timedelta(days=14)).isoformat(), + "end": (TARGET - timedelta(days=1)).isoformat(), + "day_count": 14, + } + + +def test_sampling_interval_uses_scada_frequency_and_can_be_overridden(monkeypatch): + monkeypatch.setattr( + burst_detection, + "get_all_scada_info", + lambda _network: [ + { + "type": "pressure", + "associated_element_id": "J1", + "transmission_frequency": "0:15:00", + }, + { + "type": "pressure", + "associated_element_id": "J2", + "transmission_frequency": "0:15:00", + }, + ], + ) + + assert ( + burst_detection._resolve_sampling_interval_minutes( + network="test", + sensor_nodes=["J1", "J2"], + requested_interval=None, + ) + == 15 + ) + assert ( + burst_detection._resolve_sampling_interval_minutes( + network="test", + sensor_nodes=["J1", "J2"], + requested_interval=30, + ) + == 30 + ) + + +def test_target_threshold_is_applied_only_to_latest_row(): + result = pd.DataFrame( + { + "Day": [1, 2, 3], + "Score": [-0.3, -0.2, -0.04], + "Prediction": [-1, -1, 1], + "IsBurst": [True, True, False], + } + ) + + rows = burst_detection._serialize_result_rows(result, target_only=True) + + assert [row["IsBurst"] for row in rows] == [False, False, True] From 045d6c5b493a3a64395809de5cfcb340746c02c3 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 17 Jul 2026 16:49:20 +0800 Subject: [PATCH 66/93] fix(simulation): use current user for stored schemes --- app/algorithms/simulation/scenarios.py | 18 +++++-- app/api/v1/endpoints/simulation.py | 9 +++- scripts/online_Analysis.py | 6 ++- tests/api/test_simulation_endpoints.py | 66 +++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/app/algorithms/simulation/scenarios.py b/app/algorithms/simulation/scenarios.py index 9890fc8..28f9954 100644 --- a/app/algorithms/simulation/scenarios.py +++ b/app/algorithms/simulation/scenarios.py @@ -72,6 +72,7 @@ def burst_analysis( modify_variable_pump_pattern: dict[str, list] = None, modify_valve_opening: dict[str, float] = None, scheme_name: str = None, + username: str | None = None, ) -> None: """ 爆管模拟 @@ -86,6 +87,9 @@ def burst_analysis( :param scheme_name: 方案名称 :return: """ + if not username: + raise ValueError("username is required when storing burst analysis scheme") + scheme_detail: dict = { "burst_ID": burst_ID, "burst_size": burst_size, @@ -211,7 +215,7 @@ def burst_analysis( name=name, scheme_name=scheme_name, scheme_type="burst_analysis", - username="admin", + username=username, scheme_start_time=modify_pattern_start_time, scheme_detail=scheme_detail, ) @@ -311,6 +315,7 @@ def flushing_analysis( drainage_node_ID: str = None, flushing_flow: float = 0, scheme_name: str = None, + username: str | None = None, ) -> None: """ 管道冲洗模拟 @@ -323,6 +328,9 @@ def flushing_analysis( :param scheme_name: 方案名称 :return: """ + if not username: + raise ValueError("username is required when storing flushing analysis scheme") + scheme_detail: dict = { "duration": modify_total_duration, "valve_opening": modify_valve_opening, @@ -455,7 +463,7 @@ def flushing_analysis( name=name, scheme_name=scheme_name, scheme_type="flushing_analysis", - username="admin", + username=username, scheme_start_time=modify_pattern_start_time, scheme_detail=scheme_detail, ) @@ -473,6 +481,7 @@ def contaminant_simulation( concentration: float, # 污染源浓度,单位mg/L scheme_name: str = None, source_pattern: str = None, # 污染源时间变化模式名称 + username: str | None = None, ) -> None: """ 污染模拟 @@ -486,6 +495,9 @@ def contaminant_simulation( :param scheme_name: 方案名称 :return: """ + if not username: + raise ValueError("username is required when storing contaminant analysis scheme") + scheme_detail: dict = { "source": source, "concentration": concentration, @@ -608,7 +620,7 @@ def contaminant_simulation( name=name, scheme_name=scheme_name, scheme_type="contaminant_analysis", - username="admin", + username=username, scheme_start_time=modify_pattern_start_time, scheme_detail=scheme_detail, ) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 3a758cd..2295aa8 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -4,8 +4,9 @@ import json import os import shutil import threading -from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Body +from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Query, Path, Body from fastapi.responses import PlainTextResponse +from app.auth.keycloak_dependencies import get_current_keycloak_username import app.services.simulation as simulation import app.services.globals as globals from app.services.tjnetwork import ( @@ -209,6 +210,7 @@ async def fastapi_burst_analysis( burst_size: list[float] = Query(..., description="对应各爆管点的爆管流量大小列表(L/s)"), modify_total_duration: int = Query(..., description="模拟总时长(秒)"), scheme_name: str = Query(..., description="分析方案名称"), + username: str = Depends(get_current_keycloak_username), ) -> str: """ 爆管分析(高级版本) @@ -229,6 +231,7 @@ async def fastapi_burst_analysis( burst_size=burst_size, modify_total_duration=modify_total_duration, scheme_name=scheme_name, + username=username, ) return "success" @@ -314,6 +317,7 @@ async def fastapi_flushing_analysis( flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"), duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"), scheme_name: str = Query(..., description="冲洗方案名称"), + username: str = Depends(get_current_keycloak_username), ) -> str: """ 冲洗分析(高级版本) @@ -340,6 +344,7 @@ async def fastapi_flushing_analysis( drainage_node_ID=drainage_node_ID, flushing_flow=flush_flow, scheme_name=scheme_name, + username=username, ) return result or "success" @@ -354,6 +359,7 @@ async def fastapi_contaminant_simulation( duration: int = Query(..., description="模拟持续时间(秒)"), scheme_name: str = Query(..., description="模拟方案名称"), pattern: str | None = Query(None, description="污染源模式ID(可选)"), + username: str = Depends(get_current_keycloak_username), ) -> str: """ 污染物模拟 @@ -376,6 +382,7 @@ async def fastapi_contaminant_simulation( source=source, concentration=concentration, source_pattern=pattern, + username=username, ) return result or "success" diff --git a/scripts/online_Analysis.py b/scripts/online_Analysis.py index 45528ee..e615ac0 100644 --- a/scripts/online_Analysis.py +++ b/scripts/online_Analysis.py @@ -87,6 +87,7 @@ def burst_analysis( modify_variable_pump_pattern: dict[str, list] = None, modify_valve_opening: dict[str, float] = None, scheme_name: str = None, + username: str | None = None, ) -> None: """ 爆管模拟 @@ -101,6 +102,9 @@ def burst_analysis( :param scheme_name: 方案名称 :return: """ + if not username: + raise ValueError("username is required when storing burst analysis scheme") + scheme_detail: dict = { "burst_ID": burst_ID, "burst_size": burst_size, @@ -225,7 +229,7 @@ def burst_analysis( name=name, scheme_name=scheme_name, scheme_type="burst_Analysis", - username="admin", + username=username, scheme_start_time=modify_pattern_start_time, scheme_detail=scheme_detail, ) diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index e022f92..c5c53b3 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -108,6 +108,12 @@ def _load_simulation_module(monkeypatch): ) +def _build_authenticated_client(module) -> TestClient: + app = build_test_app(module.router, "/api/v1") + app.dependency_overrides[module.get_current_keycloak_username] = lambda: "alice" + return TestClient(app) + + def test_run_project_endpoint_returns_plain_text(monkeypatch): module = _load_simulation_module(monkeypatch) monkeypatch.setattr(module, "run_project", lambda network: f"report::{network}") @@ -339,6 +345,33 @@ def test_valve_close_endpoint_passes_scheme_name(monkeypatch): } +def test_burst_endpoint_passes_current_username(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_burst_analysis(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr(module, "burst_analysis", fake_burst_analysis) + client = _build_authenticated_client(module) + + response = client.get( + "/api/v1/burst_analysis/", + params={ + "network": "demo", + "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", + "burst_ID": ["P1"], + "burst_size": [10.0], + "modify_total_duration": 900, + "scheme_name": "burst_case_01", + }, + ) + + assert response.status_code == 200 + assert response.text == '"success"' + assert captured["username"] == "alice" + + def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): module = _load_simulation_module(monkeypatch) captured = {} @@ -348,7 +381,7 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): return "ok" monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) - client = TestClient(build_test_app(module.router, "/api/v1")) + client = _build_authenticated_client(module) response = client.get( "/api/v1/flushing_analysis/", @@ -374,12 +407,41 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): "drainage_node_ID": "N1", "flushing_flow": 100.0, "scheme_name": "flush_case_01", + "username": "alice", } +def test_contaminant_endpoint_passes_current_username(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_contaminant_simulation(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "contaminant_simulation", fake_contaminant_simulation) + client = _build_authenticated_client(module) + + response = client.get( + "/api/v1/contaminant_simulation/", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "source": "N1", + "concentration": 10.0, + "duration": 900, + "scheme_name": "contaminant_case_01", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured["username"] == "alice" + + def test_contaminant_endpoint_requires_scheme_name(monkeypatch): module = _load_simulation_module(monkeypatch) - client = TestClient(build_test_app(module.router, "/api/v1")) + client = _build_authenticated_client(module) response = client.get( "/api/v1/contaminant_simulation/", From b977bf6725dfb286318b4392e9a3dd391c03ff4f Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 21 Jul 2026 11:26:21 +0800 Subject: [PATCH 67/93] fix(db): validate cached project connections --- app/native/wndb/connection.py | 16 +++++++-- tests/unit/test_wndb_connection.py | 57 ++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/native/wndb/connection.py b/app/native/wndb/connection.py index 7dd5de2..c8d1a67 100644 --- a/app/native/wndb/connection.py +++ b/app/native/wndb/connection.py @@ -20,6 +20,17 @@ def _close_connection(connection: pg.Connection) -> None: connection.close() +def _is_healthy(connection: pg.Connection) -> bool: + if _is_closed(connection): + return False + try: + with connection.cursor() as cur: + cur.execute("SELECT 1") + except pg.Error: + return False + return True + + def _get_project_lock(name: str) -> RLock: with _registry_lock: lock = _project_locks.get(name) @@ -32,7 +43,7 @@ def _get_project_lock(name: str) -> RLock: def open_connection(name: str) -> pg.Connection: with _get_project_lock(name): connection = g_conn_dict.get(name) - if connection is None or _is_closed(connection): + if connection is None or not _is_healthy(connection): if connection is not None: _close_connection(connection) connection = pg.connect( @@ -47,8 +58,9 @@ def is_connection_open(name: str) -> bool: connection = g_conn_dict.get(name) if connection is None: return False - if _is_closed(connection): + if not _is_healthy(connection): del g_conn_dict[name] + _close_connection(connection) return False return True diff --git a/tests/unit/test_wndb_connection.py b/tests/unit/test_wndb_connection.py index ea68d90..b8fc6f7 100644 --- a/tests/unit/test_wndb_connection.py +++ b/tests/unit/test_wndb_connection.py @@ -6,9 +6,8 @@ from app.native.wndb import project class _FakeCursor: - def __init__(self, rows): - self.rows = rows - self.executed = [] + def __init__(self, connection): + self.connection = connection def __enter__(self): return self @@ -17,22 +16,26 @@ class _FakeCursor: return False def execute(self, sql): - self.executed.append(sql) + self.connection.executed.append(sql) + if self.connection.fail_ping and sql == "SELECT 1": + raise connection.pg.OperationalError("server closed the connection") def fetchall(self): - return self.rows + return self.connection.rows class _FakeConnection: - def __init__(self, rows=None, *, closed=False): + def __init__(self, rows=None, *, closed=False, fail_ping=False): self.rows = list(rows or []) self.closed = closed + self.fail_ping = fail_ping + self.executed = [] self.close_calls = 0 def cursor(self, row_factory=None): if self.closed: raise RuntimeError("the connection is closed") - return _FakeCursor(self.rows) + return _FakeCursor(self) def close(self): self.close_calls += 1 @@ -55,6 +58,19 @@ def test_is_project_open_drops_closed_cached_connection(): assert "fengyang" not in connection.g_conn_dict +def test_open_connection_reuses_healthy_cached_connection(monkeypatch): + cached = _FakeConnection() + connection.g_conn_dict["fengyang"] = cached + + def fail_connect(*, conninfo, autocommit): + raise AssertionError("cached connection should be reused") + + monkeypatch.setattr(connection.pg, "connect", fail_connect) + + assert connection.open_connection("fengyang") is cached + assert cached.executed == ["SELECT 1"] + + def test_read_all_reopens_closed_cached_connection(monkeypatch): stale = _FakeConnection(closed=True) fresh = _FakeConnection(rows=[{"key": "DURATION", "value": "01:00:00"}]) @@ -76,3 +92,30 @@ def test_read_all_reopens_closed_cached_connection(monkeypatch): assert rows == [{"key": "DURATION", "value": "01:00:00"}] assert opened == [("dbname=fengyang", True)] assert connection.g_conn_dict["fengyang"] is fresh + assert fresh.executed == ["select * from times"] + + +def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch): + stale = _FakeConnection(fail_ping=True) + fresh = _FakeConnection(rows=[{"scheme_name": "base"}]) + connection.g_conn_dict["fengyang"] = stale + + opened = [] + + def fake_connect(*, conninfo, autocommit): + opened.append((conninfo, autocommit)) + return fresh + + monkeypatch.setattr(connection.pg, "connect", fake_connect) + monkeypatch.setattr( + connection, "get_pgconn_string", lambda db_name: f"dbname={db_name}" + ) + + rows = database.read_all("fengyang", "select * from scheme_list") + + assert rows == [{"scheme_name": "base"}] + assert stale.executed == ["SELECT 1"] + assert stale.close_calls == 1 + assert opened == [("dbname=fengyang", True)] + assert connection.g_conn_dict["fengyang"] is fresh + assert fresh.executed == ["select * from scheme_list"] From 03bb2d75c272b5c28f25030ef4f9455675c20893 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 22 Jul 2026 11:26:06 +0800 Subject: [PATCH 68/93] =?UTF-8?q?docs:=20=E7=BC=96=E5=86=99=E4=B8=AD?= =?UTF-8?q?=E6=96=87=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d905590 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# TJWaterServerBinary 内部后端 + +`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务集成和命令行工具能力。该仓库用于内部开发和完整功能维护。 + +## 技术栈 + +- Python 3.12 +- FastAPI / Uvicorn +- Pydantic / SQLAlchemy / psycopg +- Redis、PostgreSQL、PostGIS、TimescaleDB +- WNTR、EPANET、Cython、科学计算与空间分析依赖 +- pytest + +## 目录结构 + +```text +app/main.py FastAPI 入口 +app/api/ HTTP API 路由 +app/auth/ 认证和权限上下文 +app/core/ 配置、日志和基础设施初始化 +app/domain/ 领域模型和 Pydantic schema +app/infra/ 数据库、缓存、EPANET 和外部集成 +app/services/ 业务服务编排 +app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析 +app/native/ 本地管网数据读写与转换 +cli/ tjwater-cli 命令行工具 +tests/ 后端测试 +resources/ SQL、模板和示例资源 +infra/docker/ Docker Compose 编排 +``` + +## 本地开发 + +推荐使用已有 conda 环境: + +```bash +conda run -n server python -m pytest tests/unit tests/auth -q +conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +如需要进入环境: + +```bash +conda activate server +``` + +## 常用命令 + +```bash +conda run -n server python -m pytest tests -q +conda run -n server python scripts/run_server.py +docker build -t tjwater-server:local . +docker compose -f infra/docker/docker-compose.yml config +``` + +- `pytest`:运行自动化测试。 +- `scripts/run_server.py`:使用项目脚本启动服务。 +- `docker build`:构建后端镜像。 +- `docker compose config`:检查 compose 配置和变量展开。 + +## CLI + +CLI 位于 `cli/tjwater_cli`,说明见: + +```text +cli/README.md +``` + +修改 CLI 参数、输出结构或后端接口适配时,应同步更新 CLI 测试和文档。 + +## 开发规范 + +- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。 +- Python 类和 Pydantic 模型使用 `PascalCase`。 +- 新 HTTP 路径使用 `kebab-case`,例如 `/api/v1/pressure-status/analyze`。 +- 优先复用现有 FastAPI/service/repository 边界。 +- 不要把临时数据、数据库 dump、日志或本地运行产物纳入提交。 + +## 测试与发布 + +提交前根据改动范围运行最小有效测试: + +```bash +conda run -n server python -m pytest tests/unit tests/auth -q +``` + +发布镜像前建议运行: + +```bash +docker build -t tjwater-server:local . +``` + +Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。 + +## 安全规则 + +不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/`、`temp/`、`data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。 From 31e2728db128ae92316fa4cc666589fe2af48ea9 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 11:01:45 +0800 Subject: [PATCH 69/93] refactor(api): unify scheme query endpoints --- app/api/v1/endpoints/burst_detection.py | 65 +---------- app/api/v1/endpoints/burst_location.py | 65 +---------- app/api/v1/endpoints/leakage.py | 67 +---------- app/api/v1/endpoints/schemes.py | 36 +++++- app/services/burst_location.py | 2 +- app/services/scheme_management.py | 121 +++++++++++++++++++- app/services/tjnetwork.py | 32 +++++- cli/tjwater_cli/commands_analysis.py | 42 +++++-- cli/tjwater_cli/registry.py | 12 +- cli/tjwater_cli_endpoint_scope.md | 6 +- tests/api/test_leakage_endpoints.py | 33 ------ tests/api/test_schemes_endpoints.py | 102 +++++++++++++++++ tests/unit/test_burst_location_service.py | 2 +- tests/unit/test_scheme_list_filter.py | 133 ++++++++++++++++++++++ 14 files changed, 457 insertions(+), 261 deletions(-) create mode 100644 tests/api/test_schemes_endpoints.py create mode 100644 tests/unit/test_scheme_list_filter.py diff --git a/app/api/v1/endpoints/burst_detection.py b/app/api/v1/endpoints/burst_detection.py index e1cb237..9d188d8 100644 --- a/app/api/v1/endpoints/burst_detection.py +++ b/app/api/v1/endpoints/burst_detection.py @@ -1,13 +1,11 @@ from datetime import datetime from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body +from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel, Field from app.auth.keycloak_dependencies import get_current_keycloak_username from app.services.burst_detection import ( - get_burst_detection_scheme_detail, - list_burst_detection_schemes, run_burst_detection, ) @@ -78,64 +76,3 @@ async def detect_burst( return run_burst_detection(**data.model_dump(), username=username) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) - - -@router.get( - "/schemes/", - summary="查询爆管检测方案列表", - description="获取指定网络的所有爆管检测方案" -) -async def query_burst_detection_schemes( - network: str = Query(..., description="管网名称(或数据库名称)"), - query_date: datetime | None = Query(None, description="查询日期(可选)"), -) -> list[dict[str, Any]]: - """ - 获取爆管检测方案列表。 - - 查询指定网络的所有已配置的爆管检测方案, - 可按日期进行筛选。 - - Args: - network: 管网名称(或数据库名称) - query_date: 查询日期(可选) - - Returns: - 爆管检测方案列表 - - Raises: - HTTPException: 当查询失败时 - """ - try: - return list_burst_detection_schemes(network=network, query_date=query_date) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) - - -@router.get( - "/schemes/{scheme_name}", - summary="获取爆管检测方案详情", - description="获取指定爆管检测方案的详细信息" -) -async def query_burst_detection_scheme_detail( - network: str = Query(..., description="管网名称(或数据库名称)"), - scheme_name: str = Path(..., description="爆管检测方案名称"), -) -> dict[str, Any]: - """ - 获取爆管检测方案详情。 - - 查询指定爆管检测方案的完整配置和参数信息。 - - Args: - network: 管网名称(或数据库名称) - scheme_name: 爆管检测方案名称 - - Returns: - 包含方案详情的字典 - - Raises: - HTTPException: 当查询失败时 - """ - try: - return get_burst_detection_scheme_detail(network=network, scheme_name=scheme_name) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) diff --git a/app/api/v1/endpoints/burst_location.py b/app/api/v1/endpoints/burst_location.py index 0bb36e2..fa5995a 100644 --- a/app/api/v1/endpoints/burst_location.py +++ b/app/api/v1/endpoints/burst_location.py @@ -3,13 +3,11 @@ from datetime import datetime from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body +from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel, Field from app.auth.keycloak_dependencies import get_current_keycloak_username from app.services.burst_location import ( - get_burst_location_scheme_detail, - list_burst_location_schemes, run_burst_location_by_network, ) @@ -68,64 +66,3 @@ async def locate_burst( return run_burst_location_by_network(**data.model_dump(), username=username) except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) - - -@router.get( - "/schemes/", - summary="查询爆管定位方案列表", - description="获取指定网络的所有爆管定位方案" -) -async def query_burst_schemes( - network: str = Query(..., description="管网名称(或数据库名称)"), - query_date: datetime | None = Query(None, description="查询日期(可选)") -) -> list[dict[str, Any]]: - """ - 获取爆管定位方案列表。 - - 查询指定网络的所有已配置的爆管定位方案, - 可按日期进行筛选。 - - Args: - network: 管网名称(或数据库名称) - query_date: 查询日期(可选) - - Returns: - 爆管定位方案列表 - - Raises: - HTTPException: 当查询失败时 - """ - try: - return list_burst_location_schemes(network=network, query_date=query_date) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) - - -@router.get( - "/schemes/{scheme_name}", - summary="获取爆管定位方案详情", - description="获取指定爆管定位方案的详细信息" -) -async def query_burst_scheme_detail( - network: str = Query(..., description="管网名称(或数据库名称)"), - scheme_name: str = Path(..., description="爆管定位方案名称") -) -> dict[str, Any]: - """ - 获取爆管定位方案详情。 - - 查询指定爆管定位方案的完整配置和参数信息。 - - Args: - network: 管网名称(或数据库名称) - scheme_name: 爆管定位方案名称 - - Returns: - 包含方案详情的字典 - - Raises: - HTTPException: 当查询失败时 - """ - try: - return get_burst_location_scheme_detail(network=network, scheme_name=scheme_name) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) diff --git a/app/api/v1/endpoints/leakage.py b/app/api/v1/endpoints/leakage.py index 0261e26..c57f95b 100644 --- a/app/api/v1/endpoints/leakage.py +++ b/app/api/v1/endpoints/leakage.py @@ -2,13 +2,11 @@ import os from typing import Any from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body +from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel, Field from app.auth.keycloak_dependencies import get_current_keycloak_username from app.services.leakage_identifier import ( - get_leakage_identify_scheme_detail, - list_leakage_identify_schemes, run_leakage_identification, ) @@ -68,66 +66,3 @@ async def identify_leakage( return run_leakage_identification(**data.model_dump(), username=username) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) - - -@router.get( - "/schemes/", - summary="查询漏损识别方案列表", - description="获取指定网络的所有漏损识别方案" -) -async def query_leakage_schemes( - network: str = Query(..., description="管网名称(或数据库名称)"), - query_date: datetime | None = Query(None, description="查询日期(可选)") -) -> list[dict[str, Any]]: - """ - 获取漏损识别方案列表。 - - 查询指定网络的所有已配置的漏损识别方案, - 可按日期进行筛选。 - - Args: - network: 管网名称(或数据库名称) - query_date: 查询日期(可选) - - Returns: - 漏损识别方案列表 - - Raises: - HTTPException: 当查询失败时 - """ - try: - return list_leakage_identify_schemes(network=network, query_date=query_date) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) - - -@router.get( - "/schemes/{scheme_name}", - summary="获取漏损识别方案详情", - description="获取指定漏损识别方案的详细信息" -) -async def query_leakage_scheme_detail( - network: str = Query(..., description="管网名称(或数据库名称)"), - scheme_name: str = Path(..., description="漏损识别方案名称") -) -> dict[str, Any]: - """ - 获取漏损识别方案详情。 - - 查询指定漏损识别方案的完整配置和参数信息。 - - Args: - network: 管网名称(或数据库名称) - scheme_name: 漏损识别方案名称 - - Returns: - 包含方案详情的字典 - - Raises: - HTTPException: 当查询失败时 - """ - try: - return get_leakage_identify_scheme_detail( - network=network, scheme_name=scheme_name - ) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) diff --git a/app/api/v1/endpoints/schemes.py b/app/api/v1/endpoints/schemes.py index a08746e..7195ad4 100644 --- a/app/api/v1/endpoints/schemes.py +++ b/app/api/v1/endpoints/schemes.py @@ -1,6 +1,9 @@ -from fastapi import APIRouter, Query -from typing import Any, List, Dict +from datetime import datetime +from fastapi import APIRouter, HTTPException, Path, Query +from typing import Any from app.services.tjnetwork import get_scheme_schema, get_scheme, get_all_schemes +from app.services.scheme_management import query_scheme_detail +from app.services.time_api import extract_date router = APIRouter() @@ -24,10 +27,35 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称 @router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息") @router.get("/getallschemes/", summary="获取所有方案(旧路径)", description="获取指定网络的所有方案信息", deprecated=True) -async def fastapi_get_all_schemes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: +async def fastapi_get_all_schemes( + network: str = Query(..., description="管网名称(或数据库名称)"), + scheme_type: str | None = Query(None, description="方案类型;为空时返回全部类型"), + query_date: datetime | None = Query(None, description="查询日期(可选)"), +) -> list[dict[Any, Any]]: """ 获取所有方案列表 返回指定网络中所有可用的方案 """ - return get_all_schemes(network) + parsed_date = ( + extract_date(query_date, field_name="query_date") + if query_date is not None + else None + ) + return get_all_schemes(network, scheme_type=scheme_type, query_date=parsed_date) + + +@router.get("/schemes/{scheme_name}", summary="获取方案详情", description="按方案类型获取指定方案详情") +async def fastapi_get_scheme_detail( + scheme_name: str = Path(..., description="方案名称"), + network: str = Query(..., description="管网名称(或数据库名称)"), + scheme_type: str | None = Query(None, description="方案类型;为空时返回通用方案详情"), +) -> dict[Any, Any]: + result = query_scheme_detail( + name=network, + scheme_name=scheme_name, + scheme_type=scheme_type, + ) + if not result: + raise HTTPException(status_code=404, detail=f"Scheme {scheme_name} not found") + return result diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 049637b..589d122 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -476,7 +476,7 @@ def _get_simulation_scheme_burst_ids( ) -> list[str]: if not scheme_name: return [] - rows = query_scheme_list(network) or [] + rows = query_scheme_list(network, scheme_type=scheme_type) or [] for row in rows: if len(row) < 7: continue diff --git a/app/services/scheme_management.py b/app/services/scheme_management.py index 0bb1f11..aaa5c04 100644 --- a/app/services/scheme_management.py +++ b/app/services/scheme_management.py @@ -154,10 +154,16 @@ def delete_scheme_info(name: str, scheme_name: str) -> None: # 2025/03/23 -def query_scheme_list(name: str) -> list: +def query_scheme_list( + name: str, + scheme_type: str | None = None, + query_date: date | None = None, +) -> list: """ 查询pg数据库中的scheme_list,按照 create_time 降序排列,离现在时间最近的记录排在最前面 :param name: 项目名称(数据库名称) + :param scheme_type: 方案类型;为空时返回全部类型 + :param query_date: 查询日期;为空时不按日期过滤 :return: 返回查询结果的所有行 """ try: @@ -166,8 +172,38 @@ def query_scheme_list(name: str) -> list: # 连接到 PostgreSQL 数据库(这里是数据库 "bb") with psycopg.connect(conn_string) as conn: with conn.cursor() as cur: - # 按 create_time 降序排列 - cur.execute("SELECT * FROM scheme_list ORDER BY create_time DESC") + if scheme_type and query_date is not None: + cur.execute( + """ + SELECT * + FROM scheme_list + WHERE scheme_type = %s AND DATE(create_time) = %s + ORDER BY create_time DESC + """, + (scheme_type, query_date), + ) + elif scheme_type: + cur.execute( + """ + SELECT * + FROM scheme_list + WHERE scheme_type = %s + ORDER BY create_time DESC + """, + (scheme_type,), + ) + elif query_date is not None: + cur.execute( + """ + SELECT * + FROM scheme_list + WHERE DATE(create_time) = %s + ORDER BY create_time DESC + """, + (query_date,), + ) + else: + cur.execute("SELECT * FROM scheme_list ORDER BY create_time DESC") rows = cur.fetchall() return rows @@ -175,6 +211,85 @@ def query_scheme_list(name: str) -> list: print(f"查询错误:{e}") +def _filter_scheme_detail_scope( + result: dict, + name: str, + scheme_type: str | None = None, +) -> dict: + if not result: + return {} + if scheme_type and result.get("scheme_type") != scheme_type: + return {} + network = result.get("network") + if network not in (None, name): + return {} + return result + + +def query_scheme_detail( + name: str, + scheme_name: str, + scheme_type: str | None = None, +) -> dict: + if scheme_type == "dma_leak_identification": + return _filter_scheme_detail_scope( + query_leakage_identify_scheme_detail(name, scheme_name), + name, + scheme_type, + ) + if scheme_type == "burst_detection": + return _filter_scheme_detail_scope( + query_burst_detection_scheme_detail(name, scheme_name), + name, + scheme_type, + ) + if scheme_type == "burst_location": + return _filter_scheme_detail_scope( + query_burst_location_scheme_detail(name, scheme_name), + name, + scheme_type, + ) + + conn_string = get_pgconn_string(db_name=name) + with psycopg.connect(conn_string) as conn: + with conn.cursor() as cur: + if scheme_type: + cur.execute( + """ + SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail + FROM public.scheme_list + WHERE scheme_name = %s AND scheme_type = %s + LIMIT 1 + """, + (scheme_name, scheme_type), + ) + else: + cur.execute( + """ + SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail + FROM public.scheme_list + WHERE scheme_name = %s + LIMIT 1 + """, + (scheme_name,), + ) + row = cur.fetchone() + if row is None: + return {} + detail = row[6] if isinstance(row[6], dict) else {} + return _filter_scheme_detail_scope({ + "scheme_id": row[0], + "scheme_name": row[1], + "scheme_type": row[2], + "username": row[3], + "create_time": row[4], + "scheme_start_time": row[5], + "scheme_detail": detail, + "network": detail.get("network"), + "result_payload": detail.get("result_payload", {}), + }, name, scheme_type) + + def store_leakage_identify_result( name: str, scheme_name: str, diff --git a/app/services/tjnetwork.py b/app/services/tjnetwork.py index a8447e0..25f4a0f 100644 --- a/app/services/tjnetwork.py +++ b/app/services/tjnetwork.py @@ -1312,8 +1312,34 @@ def get_scheme_schema(name: str) -> dict[str, dict[str, Any]]: def get_scheme(name: str, schema_name: str) -> dict[str, Any]: return api.get_scheme(name, schema_name) -def get_all_schemes(name: str) -> list[dict[str, Any]]: - return api.get_all_schemes(name) +def get_all_schemes( + name: str, + scheme_type: str | None = None, + query_date: Any | None = None, +) -> list[dict[str, Any]]: + if scheme_type is None and query_date is None: + return api.get_all_schemes(name) + + from app.services.scheme_management import query_scheme_list + + rows = query_scheme_list(name, scheme_type=scheme_type, query_date=query_date) or [] + columns = [ + "scheme_id", + "scheme_name", + "scheme_type", + "username", + "create_time", + "scheme_start_time", + "scheme_detail", + ] + result = [] + for row in rows: + item = dict(zip(columns, row, strict=False)) + detail = item.get("scheme_detail") + if isinstance(detail, dict) and detail.get("network") not in (None, name): + continue + result.append(item) + return result ############################################################ # pipe_risk_probability 41 @@ -1345,5 +1371,3 @@ def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]: def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]: return api.get_all_burst_locate_results(name) - - diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index c4b673a..d43b3eb 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -306,8 +306,11 @@ def analysis_leakage_schemes_list(ctx: typer.Context) -> None: ctx, summary="读取漏损方案列表成功", method="GET", - path="/leakage/schemes/", - params={"network": require_network(runtime)}, + path="/schemes", + params={ + "network": require_network(runtime), + "scheme_type": "dma_leak_identification", + }, require_auth=True, require_network_ctx=True, ) @@ -323,8 +326,11 @@ def analysis_leakage_schemes_get( ctx, summary="读取漏损方案详情成功", method="GET", - path=f"/leakage/schemes/{scheme_name}", - params={"network": require_network(runtime)}, + path=f"/schemes/{scheme_name}", + params={ + "network": require_network(runtime), + "scheme_type": "dma_leak_identification", + }, require_auth=True, require_network_ctx=True, ) @@ -362,8 +368,11 @@ def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None: ctx, summary="读取爆管检测方案列表成功", method="GET", - path="/burst-detection/schemes/", - params={"network": require_network(runtime)}, + path="/schemes", + params={ + "network": require_network(runtime), + "scheme_type": "burst_detection", + }, require_auth=True, require_network_ctx=True, ) @@ -379,8 +388,11 @@ def analysis_burst_detection_schemes_get( ctx, summary="读取爆管检测方案详情成功", method="GET", - path=f"/burst-detection/schemes/{scheme_name}", - params={"network": require_network(runtime)}, + path=f"/schemes/{scheme_name}", + params={ + "network": require_network(runtime), + "scheme_type": "burst_detection", + }, require_auth=True, require_network_ctx=True, ) @@ -438,8 +450,11 @@ def analysis_burst_location_schemes_list(ctx: typer.Context) -> None: ctx, summary="读取爆管定位方案列表成功", method="GET", - path="/burst-location/schemes/", - params={"network": require_network(runtime)}, + path="/schemes", + params={ + "network": require_network(runtime), + "scheme_type": "burst_location", + }, require_auth=True, require_network_ctx=True, ) @@ -455,8 +470,11 @@ def analysis_burst_location_schemes_get( ctx, summary="读取爆管定位方案详情成功", method="GET", - path=f"/burst-location/schemes/{scheme_name}", - params={"network": require_network(runtime)}, + path=f"/schemes/{scheme_name}", + params={ + "network": require_network(runtime), + "scheme_type": "burst_location", + }, require_auth=True, require_network_ctx=True, ) diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 540f84e..14a9e37 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -247,13 +247,13 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "leakage", "schemes", "list"): CommandDoc( path=("analysis", "leakage", "schemes", "list"), summary="列出漏损方案", - description="调用 /leakage/schemes/。", + description="调用 /schemes,并传入 scheme_type=dma_leak_identification。", examples=("tjwater-cli analysis leakage schemes list",), ), ("analysis", "leakage", "schemes", "get"): CommandDoc( path=("analysis", "leakage", "schemes", "get"), summary="读取漏损方案详情", - description="调用 /leakage/schemes/{scheme_name}。", + description="调用 /schemes/{scheme_name},并传入 scheme_type=dma_leak_identification。", examples=("tjwater-cli analysis leakage schemes get my_scheme",), ), ("analysis", "burst-detection", "detect"): CommandDoc( @@ -270,13 +270,13 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst-detection", "schemes", "list"): CommandDoc( path=("analysis", "burst-detection", "schemes", "list"), summary="列出爆管检测方案", - description="调用 /burst-detection/schemes/。", + description="调用 /schemes,并传入 scheme_type=burst_detection。", examples=("tjwater-cli analysis burst-detection schemes list",), ), ("analysis", "burst-detection", "schemes", "get"): CommandDoc( path=("analysis", "burst-detection", "schemes", "get"), summary="读取爆管检测方案详情", - description="调用 /burst-detection/schemes/{scheme_name}。", + description="调用 /schemes/{scheme_name},并传入 scheme_type=burst_detection。", examples=("tjwater-cli analysis burst-detection schemes get my_scheme",), ), ("analysis", "burst-location", "locate"): CommandDoc( @@ -303,13 +303,13 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst-location", "schemes", "list"): CommandDoc( path=("analysis", "burst-location", "schemes", "list"), summary="列出爆管定位方案", - description="调用 /burst-location/schemes/。", + description="调用 /schemes,并传入 scheme_type=burst_location。", examples=("tjwater-cli analysis burst-location schemes list",), ), ("analysis", "burst-location", "schemes", "get"): CommandDoc( path=("analysis", "burst-location", "schemes", "get"), summary="读取爆管定位方案详情", - description="调用 /burst-location/schemes/{scheme_name}。", + description="调用 /schemes/{scheme_name},并传入 scheme_type=burst_location。", examples=("tjwater-cli analysis burst-location schemes get my_scheme",), ), ("analysis", "risk", "pipe-now"): CommandDoc( diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 5af650f..86da44a 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -202,11 +202,11 @@ app/api/v1/endpoints/risk.py | `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant-simulation` | 污染物模拟 | | `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | | `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | -| `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | +| `tjwater-cli analysis leakage schemes list\|get` | `GET /schemes?scheme_type=dma_leak_identification`、`GET /schemes/{scheme_name}?scheme_type=dma_leak_identification` | 漏损方案查询 | | `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | -| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | +| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /schemes?scheme_type=burst_detection`、`GET /schemes/{scheme_name}?scheme_type=burst_detection` | 爆管检测方案查询 | | `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | -| `tjwater-cli analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | +| `tjwater-cli analysis burst-location schemes list\|get` | `GET /schemes?scheme_type=burst_location`、`GET /schemes/{scheme_name}?scheme_type=burst_location` | 爆管定位方案查询 | | `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | | `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | | `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | diff --git a/tests/api/test_leakage_endpoints.py b/tests/api/test_leakage_endpoints.py index db8ec69..8be295b 100644 --- a/tests/api/test_leakage_endpoints.py +++ b/tests/api/test_leakage_endpoints.py @@ -1,7 +1,5 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from types import SimpleNamespace - from app.api.v1.endpoints import leakage as leakage_endpoint @@ -35,34 +33,3 @@ def test_identify_leakage_success(monkeypatch): ) assert response.status_code == 200 assert response.json()["area_count"] == 0 - - -def test_query_leakage_schemes_success(monkeypatch): - monkeypatch.setattr( - leakage_endpoint, - "list_leakage_identify_schemes", - lambda network, query_date=None: [ - {"scheme_name": "dma_001", "scheme_type": "dma_leak_identification"} - ], - ) - client = _build_client() - response = client.get("/api/v1/leakage/schemes/", params={"network": "demo"}) - assert response.status_code == 200 - assert response.json()[0]["scheme_name"] == "dma_001" - - -def test_query_leakage_scheme_detail_success(monkeypatch): - monkeypatch.setattr( - leakage_endpoint, - "get_leakage_identify_scheme_detail", - lambda network, scheme_name: { - "scheme_name": scheme_name, - "rows": [{"Area": "1", "LeakageFlow_m3_per_s": 0.1}], - }, - ) - client = _build_client() - response = client.get( - "/api/v1/leakage/schemes/dma_001", params={"network": "demo"} - ) - assert response.status_code == 200 - assert response.json()["scheme_name"] == "dma_001" diff --git a/tests/api/test_schemes_endpoints.py b/tests/api/test_schemes_endpoints.py new file mode 100644 index 0000000..319a5ad --- /dev/null +++ b/tests/api/test_schemes_endpoints.py @@ -0,0 +1,102 @@ +from datetime import date + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import schemes as schemes_endpoint + + +def _build_client() -> TestClient: + app = FastAPI() + app.include_router(schemes_endpoint.router, prefix="/api/v1") + return TestClient(app) + + +def test_get_schemes_forwards_optional_scheme_type(monkeypatch): + captured = {} + + def fake_get_all_schemes(network, scheme_type=None, query_date=None): + captured["network"] = network + captured["scheme_type"] = scheme_type + captured["query_date"] = query_date + return [ + { + "scheme_id": 1, + "scheme_name": "burst_case", + "scheme_type": scheme_type, + } + ] + + monkeypatch.setattr(schemes_endpoint, "get_all_schemes", fake_get_all_schemes) + + response = _build_client().get( + "/api/v1/schemes", + params={"network": "demo", "scheme_type": "burst_analysis"}, + ) + + assert response.status_code == 200 + assert captured == { + "network": "demo", + "scheme_type": "burst_analysis", + "query_date": None, + } + assert response.json()[0]["scheme_type"] == "burst_analysis" + + +def test_get_schemes_forwards_query_date(monkeypatch): + captured = {} + + def fake_get_all_schemes(network, scheme_type=None, query_date=None): + captured["network"] = network + captured["scheme_type"] = scheme_type + captured["query_date"] = query_date + return [] + + monkeypatch.setattr(schemes_endpoint, "get_all_schemes", fake_get_all_schemes) + + response = _build_client().get( + "/api/v1/schemes", + params={ + "network": "demo", + "scheme_type": "dma_leak_identification", + "query_date": "2026-01-02T00:00:00+08:00", + }, + ) + + assert response.status_code == 200 + assert captured == { + "network": "demo", + "scheme_type": "dma_leak_identification", + "query_date": date(2026, 1, 2), + } + + +def test_get_scheme_detail_forwards_scheme_type(monkeypatch): + captured = {} + + def fake_query_scheme_detail(name, scheme_name, scheme_type=None): + captured["name"] = name + captured["scheme_name"] = scheme_name + captured["scheme_type"] = scheme_type + return { + "scheme_name": scheme_name, + "scheme_type": scheme_type, + "rows": [{"Area": "1", "LeakageFlow_m3_per_s": 0.1}], + } + + monkeypatch.setattr( + schemes_endpoint, "query_scheme_detail", fake_query_scheme_detail + ) + + response = _build_client().get( + "/api/v1/schemes/dma_001", + params={"network": "demo", "scheme_type": "dma_leak_identification"}, + ) + + assert response.status_code == 200 + assert captured == { + "name": "demo", + "scheme_name": "dma_001", + "scheme_type": "dma_leak_identification", + } + assert response.json()["scheme_name"] == "dma_001" diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index a82bf4b..c80f813 100644 --- a/tests/unit/test_burst_location_service.py +++ b/tests/unit/test_burst_location_service.py @@ -197,7 +197,7 @@ def test_run_burst_location_uses_single_timerange_with_burst_source_split(monkey monkeypatch.setattr( module, "query_scheme_list", - lambda name: [ + lambda name, scheme_type=None: [ ( 1, "BurstSchemeA", diff --git a/tests/unit/test_scheme_list_filter.py b/tests/unit/test_scheme_list_filter.py new file mode 100644 index 0000000..567c1fb --- /dev/null +++ b/tests/unit/test_scheme_list_filter.py @@ -0,0 +1,133 @@ +from app.services import scheme_management, tjnetwork + + +class _FakeCursor: + def __init__(self): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *_exc_info): + return False + + def execute(self, statement, params=None): + self.calls.append((str(statement), params)) + + def fetchall(self): + return [] + + +class _FakeConnection: + def __init__(self, cursor): + self._cursor = cursor + + def __enter__(self): + return self + + def __exit__(self, *_exc_info): + return False + + def cursor(self): + return self._cursor + + +def test_query_scheme_list_pushes_scheme_type_into_sql(monkeypatch): + cursor = _FakeCursor() + monkeypatch.setattr( + scheme_management, "get_pgconn_string", lambda db_name=None: "postgres://test" + ) + monkeypatch.setattr( + scheme_management.psycopg, "connect", lambda _conn_string: _FakeConnection(cursor) + ) + + assert scheme_management.query_scheme_list("demo", scheme_type="burst_analysis") == [] + + statement, params = cursor.calls[0] + assert "WHERE scheme_type = %s" in statement + assert params == ("burst_analysis",) + + +def test_get_all_schemes_filters_central_scheme_list_by_type(monkeypatch): + captured = {} + + def fake_query_scheme_list(name, scheme_type=None, query_date=None): + captured["name"] = name + captured["scheme_type"] = scheme_type + captured["query_date"] = query_date + return [ + ( + 7, + "burst_case", + "burst_analysis", + "alice", + "2026-01-01T00:00:00+08:00", + "2026-01-01T01:00:00+08:00", + {"burst_ID": ["P1"]}, + ) + ] + + monkeypatch.setattr( + scheme_management, "query_scheme_list", fake_query_scheme_list + ) + + result = tjnetwork.get_all_schemes("demo", scheme_type="burst_analysis") + + assert captured == { + "name": "demo", + "scheme_type": "burst_analysis", + "query_date": None, + } + assert result == [ + { + "scheme_id": 7, + "scheme_name": "burst_case", + "scheme_type": "burst_analysis", + "username": "alice", + "create_time": "2026-01-01T00:00:00+08:00", + "scheme_start_time": "2026-01-01T01:00:00+08:00", + "scheme_detail": {"burst_ID": ["P1"]}, + } + ] + + +def test_query_scheme_detail_rejects_wrong_specialized_type(monkeypatch): + monkeypatch.setattr( + scheme_management, + "query_burst_detection_scheme_detail", + lambda name, scheme_name: { + "scheme_name": scheme_name, + "scheme_type": "burst_analysis", + "network": name, + }, + ) + + assert ( + scheme_management.query_scheme_detail( + "demo", + "same_name", + scheme_type="burst_detection", + ) + == {} + ) + + +def test_query_scheme_detail_rejects_wrong_network(monkeypatch): + monkeypatch.setattr( + scheme_management, + "query_burst_location_scheme_detail", + lambda name, scheme_name: { + "scheme_name": scheme_name, + "scheme_type": "burst_location", + "network": "other_network", + }, + ) + + assert ( + scheme_management.query_scheme_detail( + "demo", + "same_name", + scheme_type="burst_location", + ) + == {} + ) From 437eb5a19a7fbe911caf9801789af76572942bf0 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 14:21:21 +0800 Subject: [PATCH 70/93] fix(auth): require preferred username claim --- AUTHENTICATION_AND_USER_MANAGEMENT.md | 2 +- app/auth/keycloak_dependencies.py | 14 +++++++---- app/auth/metadata_dependencies.py | 13 +++++----- tests/auth/test_keycloak_dependencies.py | 30 ++++++++++++++++++++++++ tests/auth/test_metadata_dependencies.py | 23 ++++++++++++++++++ 5 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 tests/auth/test_keycloak_dependencies.py diff --git a/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md index 5cad69f..7a6906e 100644 --- a/AUTHENTICATION_AND_USER_MANAGEMENT.md +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -16,7 +16,7 @@ trust frontend-supplied user IDs. ## Login Snapshot Refresh Every authenticated metadata-user resolution validates the Keycloak access token -and reads `sub`, `preferred_username` or `username`, and `email` claims. The +and reads `sub`, `preferred_username`, and `email` claims. The backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users, then refreshes `username`, `email`, and `last_login_at`. diff --git a/app/auth/keycloak_dependencies.py b/app/auth/keycloak_dependencies.py index ac43799..f99a358 100644 --- a/app/auth/keycloak_dependencies.py +++ b/app/auth/keycloak_dependencies.py @@ -73,14 +73,18 @@ async def get_current_keycloak_sub( ) from exc -async def get_current_keycloak_username( - payload: dict = Depends(get_current_keycloak_payload), -) -> str: - username = payload.get("preferred_username") or payload.get("username") +def get_keycloak_preferred_username(payload: dict) -> str: + username = payload.get("preferred_username") if not username: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing username claim", + detail="Missing preferred_username claim", headers={"WWW-Authenticate": "Bearer"}, ) return str(username) + + +async def get_current_keycloak_username( + payload: dict = Depends(get_current_keycloak_payload), +) -> str: + return get_keycloak_preferred_username(payload) diff --git a/app/auth/metadata_dependencies.py b/app/auth/metadata_dependencies.py index 021063e..5d3b6cf 100644 --- a/app/auth/metadata_dependencies.py +++ b/app/auth/metadata_dependencies.py @@ -6,7 +6,10 @@ from fastapi import Depends, HTTPException, status from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.keycloak_dependencies import get_current_keycloak_payload +from app.auth.keycloak_dependencies import ( + get_current_keycloak_payload, + get_keycloak_preferred_username, +) from app.infra.db.metadb.database import get_metadata_session from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository @@ -38,11 +41,6 @@ def _keycloak_sub_from_payload(payload: dict) -> UUID: ) from exc -def _username_from_payload(payload: dict) -> str | None: - username = payload.get("preferred_username") or payload.get("username") - return str(username) if username else None - - def _email_from_payload(payload: dict) -> str | None: email = payload.get("email") return str(email) if email else None @@ -53,6 +51,7 @@ async def get_current_metadata_user( metadata_repo: MetadataRepository = Depends(get_metadata_repository), ): keycloak_sub = _keycloak_sub_from_payload(keycloak_payload) + username = get_keycloak_preferred_username(keycloak_payload) try: user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub) except SQLAlchemyError as exc: @@ -71,7 +70,7 @@ async def get_current_metadata_user( try: user = await metadata_repo.refresh_user_keycloak_snapshot( user, - username=_username_from_payload(keycloak_payload), + username=username, email=_email_from_payload(keycloak_payload), ) except SQLAlchemyError as exc: diff --git a/tests/auth/test_keycloak_dependencies.py b/tests/auth/test_keycloak_dependencies.py new file mode 100644 index 0000000..b7c6a24 --- /dev/null +++ b/tests/auth/test_keycloak_dependencies.py @@ -0,0 +1,30 @@ +import pytest +from fastapi import HTTPException + +from app.auth.keycloak_dependencies import get_current_keycloak_username + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_current_username_uses_preferred_username_only(): + username = await get_current_keycloak_username( + { + "preferred_username": "tjwater", + "username": "legacy-name", + } + ) + + assert username == "tjwater" + + +@pytest.mark.anyio +async def test_current_username_rejects_username_fallback(): + with pytest.raises(HTTPException) as exc: + await get_current_keycloak_username({"username": "legacy-name"}) + + assert exc.value.status_code == 401 + assert exc.value.detail == "Missing preferred_username claim" diff --git a/tests/auth/test_metadata_dependencies.py b/tests/auth/test_metadata_dependencies.py index 3abfbd8..8a99ed5 100644 --- a/tests/auth/test_metadata_dependencies.py +++ b/tests/auth/test_metadata_dependencies.py @@ -81,3 +81,26 @@ async def test_current_metadata_user_rejects_invalid_keycloak_sub(): assert exc.value.status_code == 401 repo.get_user_by_keycloak_id.assert_not_called() repo.refresh_user_keycloak_snapshot.assert_not_called() + + +@pytest.mark.anyio +async def test_current_metadata_user_rejects_username_claim_fallback(): + keycloak_id = uuid4() + repo = SimpleNamespace( + get_user_by_keycloak_id=AsyncMock(), + refresh_user_keycloak_snapshot=AsyncMock(), + ) + + with pytest.raises(HTTPException) as exc: + await metadata_dependencies.get_current_metadata_user( + { + "sub": str(keycloak_id), + "username": "legacy-name", + }, + metadata_repo=repo, + ) + + assert exc.value.status_code == 401 + assert exc.value.detail == "Missing preferred_username claim" + repo.get_user_by_keycloak_id.assert_not_called() + repo.refresh_user_keycloak_snapshot.assert_not_called() From ddbb50173c06ba9ae5b383cfe43f7cc9f74c5e66 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 16:16:51 +0800 Subject: [PATCH 71/93] feat(sensor-placement): add editable scheme APIs --- app/algorithms/sensor/__init__.py | 151 +++++---- app/api/v1/endpoints/network/geometry.py | 39 +-- app/api/v1/endpoints/sensor_placement.py | 208 ++++++++++++ app/api/v1/router.py | 2 + app/domain/schemas/sensor_placement.py | 81 +++++ app/infra/audit/middleware.py | 20 +- app/native/wndb/__init__.py | 13 +- app/native/wndb/s42_sensor_placement.py | 114 ++++++- app/services/sensor_placement.py | 257 +++++++++++++++ app/services/tjnetwork.py | 1 - tests/api/test_audit_middleware.py | 36 ++ tests/api/test_sensor_placement_endpoints.py | 325 +++++++++++++++++++ tests/unit/test_sensor_placement_service.py | 188 +++++++++++ 13 files changed, 1313 insertions(+), 122 deletions(-) create mode 100644 app/api/v1/endpoints/sensor_placement.py create mode 100644 app/domain/schemas/sensor_placement.py create mode 100644 app/services/sensor_placement.py create mode 100644 tests/api/test_audit_middleware.py create mode 100644 tests/api/test_sensor_placement_endpoints.py create mode 100644 tests/unit/test_sensor_placement_service.py diff --git a/app/algorithms/sensor/__init__.py b/app/algorithms/sensor/__init__.py index d6c1a48..998016b 100644 --- a/app/algorithms/sensor/__init__.py +++ b/app/algorithms/sensor/__init__.py @@ -1,14 +1,68 @@ -import psycopg +from contextlib import contextmanager +import fcntl +from pathlib import Path +from typing import Any from app.algorithms.sensor import kmeans as kmeans_sensor from app.algorithms.sensor import sensitivity -from app.core.config import get_pgconn_string +from app.native.wndb.s42_sensor_placement import create_sensor_placement +from app.services.sensor_placement import ( + SensorPlacementValidationError, + validate_sensor_placement_nodes, +) from app.services.tjnetwork import dump_inp +def _sensor_inp_path(name: str) -> Path: + if ( + not name + or name in {".", ".."} + or "/" in name + or "\\" in name + or "\x00" in name + ): + raise SensorPlacementValidationError("管网名称不是有效的项目标识") + return Path("db_inp") / f"{name}.db.inp" + + +@contextmanager +def _sensor_inp_lock(name: str): + inp_path = _sensor_inp_path(name) + inp_path.parent.mkdir(parents=True, exist_ok=True) + lock_path = inp_path.with_suffix(".sensor.lock") + with lock_path.open("w", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield inp_path + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _create_validated_placement( + name: str, + *, + scheme_name: str, + min_diameter: int, + username: str, + sensor_location: list[str], +) -> dict[str, Any]: + validate_sensor_placement_nodes(name, sensor_location) + return create_sensor_placement( + name, + scheme_name=scheme_name, + min_diameter=min_diameter, + username=username, + sensor_location=sensor_location, + ) + + def pressure_sensor_placement_sensitivity( - name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str -) -> None: + name: str, + scheme_name: str, + sensor_number: int, + min_diameter: int, + username: str, +) -> dict[str, Any]: """ 基于改进灵敏度法进行压力监测点优化布置 :param name: 数据库名称 @@ -16,41 +70,32 @@ def pressure_sensor_placement_sensitivity( :param sensor_number: 传感器数目 :param min_diameter: 最小管径 :param username: 用户名 - :return: + :return: 新建的监测点方案 """ - sensor_location = sensitivity.get_ID( - name=name, sensor_num=sensor_number, min_diameter=min_diameter + with _sensor_inp_lock(name): + sensor_location = sensitivity.get_ID( + name=name, + sensor_num=sensor_number, + min_diameter=min_diameter, + ) + return _create_validated_placement( + name, + scheme_name=scheme_name, + min_diameter=min_diameter, + username=username, + sensor_location=sensor_location, ) - try: - conn_string = get_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - sql = """ - INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location) - VALUES (%s, %s, %s, %s, %s) - """ - - cur.execute( - sql, - ( - scheme_name, - sensor_number, - min_diameter, - username, - sensor_location, - ), - ) - conn.commit() - print("方案信息存储成功!") - except Exception as e: - print(f"存储方案信息时出错:{e}") # 2025/08/21 # 基于kmeans聚类法进行压力监测点优化布置 def pressure_sensor_placement_kmeans( - name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str -) -> None: + name: str, + scheme_name: str, + sensor_number: int, + min_diameter: int, + username: str, +) -> dict[str, Any]: """ 基于聚类法进行压力监测点优化布置 :param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样) @@ -58,34 +103,20 @@ def pressure_sensor_placement_kmeans( :param sensor_number: 传感器数目 :param min_diameter: 最小管径 :param username: 用户名 - :return: + :return: 新建的监测点方案 """ # dump_inp - inp_name = f"./db_inp/{name}.db.inp" - dump_inp(name, inp_name, "2") - sensor_location = kmeans_sensor.kmeans_sensor_placement( - name=name, sensor_num=sensor_number, min_diameter=min_diameter + with _sensor_inp_lock(name) as inp_path: + dump_inp(name, str(inp_path), "2") + sensor_location = kmeans_sensor.kmeans_sensor_placement( + name=name, + sensor_num=sensor_number, + min_diameter=min_diameter, + ) + return _create_validated_placement( + name, + scheme_name=scheme_name, + min_diameter=min_diameter, + username=username, + sensor_location=sensor_location, ) - try: - conn_string = get_pgconn_string(db_name=name) - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - sql = """ - INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location) - VALUES (%s, %s, %s, %s, %s) - """ - - cur.execute( - sql, - ( - scheme_name, - sensor_number, - min_diameter, - username, - sensor_location, - ), - ) - conn.commit() - print("方案信息存储成功!") - except Exception as e: - print(f"存储方案信息时出错:{e}") diff --git a/app/api/v1/endpoints/network/geometry.py b/app/api/v1/endpoints/network/geometry.py index 5adb575..8acf2f4 100644 --- a/app/api/v1/endpoints/network/geometry.py +++ b/app/api/v1/endpoints/network/geometry.py @@ -1,18 +1,14 @@ -from fastapi import APIRouter, Request, Depends, Query, Path, Body -from typing import Any, List, Dict, Union +from typing import Any + +from fastapi import APIRouter, Query + from app.services.tjnetwork import ( - Any, - get_all_scada_info, get_major_node_coords, get_major_pipe_nodes, get_network_in_extent, get_network_link_nodes, - get_network_node_coords, get_node_coord, ) -from app.auth.metadata_dependencies import get_current_metadata_user -from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime -import msgpack router = APIRouter() @@ -62,33 +58,6 @@ async def fastapi_get_network_in_extent( """获取地理范围内的网络几何信息。""" return get_network_in_extent(network, x1, y1, x2, y2) -@router.get( - "/getnetworkgeometries/", - dependencies=[Depends(get_current_metadata_user)], - summary="获取完整网络几何信息", - description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)" -) -async def fastapi_get_network_geometries( - network: str = Query(..., description="管网名称(或数据库名称)") -) -> dict[str, Any] | None: - """获取完整的网络几何信息,包括所有节点、管线和SCADA点。结果从缓存返回。""" - cache_key = f"getnetworkgeometries_{network}" - data = redis_client.get(cache_key) - if data: - loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime) - return loaded_dict - - coords = get_network_node_coords(network) - nodes = [] - for node_id, coord in coords.items(): - nodes.append(f"{node_id}:{coord['type']}:{coord['x']}:{coord['y']}") - links = get_network_link_nodes(network) - scadas = get_all_scada_info(network) - - results = {"nodes": nodes, "links": links, "scadas": scadas} - redis_client.set(cache_key, msgpack.packb(results, default=encode_datetime)) - return results - @router.get( "/getmajornodecoords/", summary="获取主要节点坐标", diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py new file mode 100644 index 0000000..454da49 --- /dev/null +++ b/app/api/v1/endpoints/sensor_placement.py @@ -0,0 +1,208 @@ +import logging +from typing import Any +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse +from starlette.concurrency import run_in_threadpool + +from app.algorithms.sensor import ( + pressure_sensor_placement_kmeans, + pressure_sensor_placement_sensitivity, +) +from app.auth.metadata_dependencies import get_current_metadata_user +from app.auth.project_dependencies import ProjectContext, get_project_context +from app.domain.schemas.sensor_placement import ( + SensorPlacementExportRequest, + SensorPlacementOptimizeRequest, + SensorPlacementSchemeResponse, + SensorPlacementUpdateRequest, +) +from app.services.sensor_placement import ( + SensorPlacementConflictError, + SensorPlacementNotFoundError, + SensorPlacementValidationError, + build_sensor_placement_workbook, + can_edit_sensor_placement, + get_sensor_placement_scheme, + update_sensor_placement_scheme, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _project_network(network: str, project_context: ProjectContext) -> str: + if network != project_context.project_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="请求的管网不属于当前项目", + ) + return project_context.project_code + + +def _service_http_error(exc: Exception) -> HTTPException: + if isinstance(exc, SensorPlacementNotFoundError): + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) + if isinstance(exc, SensorPlacementConflictError): + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) + + +def _get_scheme_response( + network: str, + scheme_id: int, + current_user: Any, +) -> dict[str, Any]: + try: + scheme = get_sensor_placement_scheme(network, scheme_id) + return { + **scheme, + "can_edit": can_edit_sensor_placement(current_user, scheme), + } + except ( + SensorPlacementNotFoundError, + SensorPlacementValidationError, + ) as exc: + raise _service_http_error(exc) from exc + + +@router.post( + "/sensor-placement-schemes/optimize", + response_model=SensorPlacementSchemeResponse, + summary="创建并返回监测点优化方案", +) +async def optimize_sensor_placement_scheme( + payload: SensorPlacementOptimizeRequest, + project_context: ProjectContext = Depends(get_project_context), + current_user=Depends(get_current_metadata_user), +) -> dict[str, Any]: + network = _project_network(payload.network, project_context) + optimizer = ( + pressure_sensor_placement_sensitivity + if payload.method == "sensitivity" + else pressure_sensor_placement_kmeans + ) + try: + created = await run_in_threadpool( + optimizer, + name=network, + scheme_name=payload.scheme_name, + sensor_number=payload.sensor_count, + min_diameter=payload.min_diameter, + username=current_user.username, + ) + scheme = get_sensor_placement_scheme(network, int(created["id"])) + return {**scheme, "can_edit": True} + except (SensorPlacementValidationError, ValueError) as exc: + raise _service_http_error(exc) from exc + except Exception as exc: + logger.exception("Sensor placement optimization failed") + raise HTTPException( + status_code=500, + detail="监测点优化失败,请稍后重试", + ) from exc + + +@router.get( + "/sensor-placement-schemes/{scheme_id}", + response_model=SensorPlacementSchemeResponse, + summary="获取监测点方案详情", +) +async def get_sensor_placement_scheme_detail( + scheme_id: int, + network: str = Query(..., min_length=1), + project_context: ProjectContext = Depends(get_project_context), + current_user=Depends(get_current_metadata_user), +) -> dict[str, Any]: + return _get_scheme_response( + _project_network(network, project_context), + scheme_id, + current_user, + ) + + +@router.put( + "/sensor-placement-schemes/{scheme_id}", + response_model=SensorPlacementSchemeResponse, + summary="覆盖保存监测点方案", +) +async def overwrite_sensor_placement_scheme( + scheme_id: int, + payload: SensorPlacementUpdateRequest, + network: str = Query(..., min_length=1), + project_context: ProjectContext = Depends(get_project_context), + current_user=Depends(get_current_metadata_user), +) -> dict[str, Any]: + network = _project_network(network, project_context) + scheme = _get_scheme_response(network, scheme_id, current_user) + if not scheme["can_edit"]: + raise HTTPException(status_code=403, detail="无权修改该监测点方案") + + try: + updated = update_sensor_placement_scheme( + network, + scheme_id, + expected_sensor_location=payload.expected_sensor_location, + sensor_location=payload.sensor_location, + ) + return {**updated, "can_edit": True} + except ( + SensorPlacementConflictError, + SensorPlacementNotFoundError, + SensorPlacementValidationError, + ) as exc: + raise _service_http_error(exc) from exc + + +@router.post( + "/sensor-placement-schemes/{scheme_id}/exports/excel", + summary="导出监测点工程清单", +) +async def export_sensor_placement_excel( + scheme_id: int, + payload: SensorPlacementExportRequest, + network: str = Query(..., min_length=1), + project_context: ProjectContext = Depends(get_project_context), + current_user=Depends(get_current_metadata_user), +) -> StreamingResponse: + network = _project_network(network, project_context) + scheme = _get_scheme_response(network, scheme_id, current_user) + if ( + payload.sensor_location != scheme["sensor_location"] + and not scheme["can_edit"] + ): + raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿") + + try: + workbook = build_sensor_placement_workbook( + network=network, + scheme=scheme, + sensor_location=payload.sensor_location, + adjustment_status=payload.adjustment_status, + ) + except SensorPlacementValidationError as exc: + raise _service_http_error(exc) from exc + + filename = f"{scheme['scheme_name']}_监测点清单.xlsx" + encoded_filename = quote(filename) + return StreamingResponse( + workbook, + media_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + headers={ + "Content-Disposition": ( + f"attachment; filename*=UTF-8''{encoded_filename}" + ) + }, + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 99da712..df73930 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -5,6 +5,7 @@ from app.api.v1.endpoints import ( project, simulation, scada, + sensor_placement, extension, snapshots, # data_query, @@ -89,6 +90,7 @@ api_router.include_router(visuals.router, tags=["Visuals"]) api_router.include_router(simulation.router, tags=["Simulation Control"]) # api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"]) api_router.include_router(scada.router) +api_router.include_router(sensor_placement.router, tags=["Sensor Placement"]) api_router.include_router(snapshots.router, tags=["Snapshots"]) api_router.include_router(users.router, tags=["Users"]) api_router.include_router(schemes.router, tags=["Schemes"]) diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py new file mode 100644 index 0000000..8dcfe69 --- /dev/null +++ b/app/domain/schemas/sensor_placement.py @@ -0,0 +1,81 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + + +AdjustmentStatus = Literal["current", "original", "added", "replaced"] + + +def _normalize_location_ids(value: list[str]) -> list[str]: + normalized = [str(item).strip() for item in value] + if any(not item for item in normalized): + raise ValueError("sensor locations cannot contain blank node IDs") + if len(set(normalized)) != len(normalized): + raise ValueError("sensor locations cannot contain duplicate node IDs") + return normalized + + +class SensorPlacementOptimizeRequest(BaseModel): + network: str = Field( + ..., + min_length=1, + max_length=63, + pattern=r"^[^/\\\x00]+$", + ) + scheme_name: str = Field(..., min_length=1, max_length=32) + sensor_type: Literal["pressure"] + method: Literal["sensitivity", "kmeans"] + sensor_count: int = Field(..., gt=0, le=200) + min_diameter: int = Field(default=0, ge=0) + + @field_validator("network") + @classmethod + def validate_network(cls, value: str) -> str: + normalized = value.strip() + if normalized in {".", ".."}: + raise ValueError("network must be a project identifier") + return normalized + + +class SensorPlacementUpdateRequest(BaseModel): + expected_sensor_location: list[str] = Field(..., min_length=1) + sensor_location: list[str] = Field(..., min_length=1) + + @field_validator("expected_sensor_location", "sensor_location") + @classmethod + def validate_locations(cls, value: list[str]) -> list[str]: + return _normalize_location_ids(value) + + +class SensorPlacementExportRequest(BaseModel): + sensor_location: list[str] = Field(..., min_length=1) + adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict) + + @field_validator("sensor_location") + @classmethod + def validate_locations(cls, value: list[str]) -> list[str]: + return _normalize_location_ids(value) + + +class SensorPointResponse(BaseModel): + node_id: str + project_x: float + project_y: float + map_x: float + map_y: float + longitude: float + latitude: float + elevation: float + + +class SensorPlacementSchemeResponse(BaseModel): + id: int + scheme_name: str + sensor_number: int + min_diameter: int + username: str + create_time: datetime + sensor_location: list[str] + sensor_points: list[SensorPointResponse] + can_edit: bool = False diff --git a/app/infra/audit/middleware.py b/app/infra/audit/middleware.py index d0774d4..fbb3f02 100644 --- a/app/infra/audit/middleware.py +++ b/app/infra/audit/middleware.py @@ -58,6 +58,8 @@ class AuditMiddleware(BaseHTTPMiddleware): "/meta/projects", "/api/v1/openproject/", "/openproject/", + "/api/v1/audit/session-events", + "/audit/session-events", } EXCLUDED_PATH_PREFIXES = ( ) @@ -80,27 +82,9 @@ class AuditMiddleware(BaseHTTPMiddleware): request_data = None if should_capture_body: try: - # 注意:读取 body 后需要重新设置,避免影响后续处理 - original_receive = request._receive body = await request.body() if body: request_data = json.loads(body.decode()) - - # 重新构造请求以供后续使用:仅回放一次,后续回落原始 receive - body_sent = False - - async def receive(): - nonlocal body_sent - if not body_sent: - body_sent = True - return { - "type": "http.request", - "body": body, - "more_body": False, - } - return await original_receive() - - request._receive = receive except Exception as e: logger.warning(f"Failed to read request body for audit: {e}") diff --git a/app/native/wndb/__init__.py b/app/native/wndb/__init__.py index 57d230b..4765495 100644 --- a/app/native/wndb/__init__.py +++ b/app/native/wndb/__init__.py @@ -320,7 +320,11 @@ from .s23_options_util import ( from .s23_options_util import get_option_v3_schema, get_option_v3 from .batch_api import set_option_v3_ex -from .s24_coordinates import get_node_coord, get_nodes_in_extent, get_links_in_extent +from .s24_coordinates import ( + get_links_in_extent, + get_node_coord, + get_nodes_in_extent, +) from .s25_vertices import ( get_vertex_schema, @@ -468,6 +472,11 @@ from .s41_pipe_risk_probability import ( get_pipe_risk_probability_geometries, ) -from .s42_sensor_placement import get_all_sensor_placements +from .s42_sensor_placement import ( + get_all_sensor_placements, + get_sensor_placement, + get_sensor_placement_nodes, + update_sensor_placement, +) from .s43_burst_locate_result import get_all_burst_locate_results diff --git a/app/native/wndb/s42_sensor_placement.py b/app/native/wndb/s42_sensor_placement.py index 5ad8b2a..c65dc66 100644 --- a/app/native/wndb/s42_sensor_placement.py +++ b/app/native/wndb/s42_sensor_placement.py @@ -1,7 +1,109 @@ -from .database import * -from .s0_base import * -from .s42_sensor_placement import * -import json +from typing import Any -def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]: - return read_all(name, "select * from sensor_placement") \ No newline at end of file +from psycopg.rows import dict_row + +from .connection import project_connection +from .database import read_all + + +def get_all_sensor_placements(name: str) -> list[dict[str, Any]]: + return read_all(name, "select * from sensor_placement") + + +def create_sensor_placement( + name: str, + *, + scheme_name: str, + min_diameter: int, + username: str, + sensor_location: list[str], +) -> dict[str, Any]: + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO sensor_placement ( + scheme_name, + sensor_number, + min_diameter, + username, + sensor_location + ) + VALUES (%s, %s, %s, %s, %s) + RETURNING * + """, + ( + scheme_name, + len(sensor_location), + min_diameter, + username, + sensor_location, + ), + ) + created = cur.fetchone() + if created is None: + raise RuntimeError("监测点方案写入失败") + return dict(created) + + +def get_sensor_placement(name: str, scheme_id: int) -> dict[str, Any] | None: + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + "SELECT * FROM sensor_placement WHERE id = %s", + (scheme_id,), + ) + return cur.fetchone() + + +def get_sensor_placement_nodes( + name: str, + node_ids: list[str], +) -> list[dict[str, Any]]: + if not node_ids: + return [] + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT DISTINCT ON (gj.id) + gj.id AS node_id, + gj.elevation, + ST_X(c.coord) AS project_x, + ST_Y(c.coord) AS project_y, + ST_X(gj.geom) AS map_x, + ST_Y(gj.geom) AS map_y + FROM geo_junctions_mat AS gj + JOIN coordinates AS c ON c.node = gj.id + WHERE gj.id = ANY(%s) + ORDER BY gj.id + """, + (node_ids,), + ) + return list(cur.fetchall()) + + +def update_sensor_placement( + name: str, + scheme_id: int, + *, + expected_sensor_location: list[str], + sensor_location: list[str], +) -> dict[str, Any] | None: + with project_connection(name) as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + UPDATE sensor_placement + SET sensor_location = %s, sensor_number = %s + WHERE id = %s AND sensor_location = %s + RETURNING * + """, + ( + sensor_location, + len(sensor_location), + scheme_id, + expected_sensor_location, + ), + ) + return cur.fetchone() diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py new file mode 100644 index 0000000..3d44686 --- /dev/null +++ b/app/services/sensor_placement.py @@ -0,0 +1,257 @@ +from datetime import datetime +from io import BytesIO +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.worksheet.worksheet import Worksheet +from openpyxl.utils import get_column_letter +from pyproj import Transformer + +from app.native import wndb + + +class SensorPlacementNotFoundError(LookupError): + pass + + +class SensorPlacementValidationError(ValueError): + pass + + +class SensorPlacementConflictError(RuntimeError): + pass + + +_to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True) +_STATUS_LABELS = { + "current": "当前方案", + "original": "原方案", + "added": "新增", + "replaced": "替换", +} +_COORDINATE_DESCRIPTION = ( + "工程 X/Y: 项目地方坐标系;地图 X/Y: EPSG:3857;经纬度: WGS84" +) +_LIST_HEADERS = ( + "序号", + "节点 ID", + "经度", + "纬度", + "工程 X", + "工程 Y", + "地图 X", + "地图 Y", + "高程", + "调整状态", +) +_LIST_COLUMN_WIDTHS = (8, 20, 16, 16, 18, 18, 18, 18, 14, 14) + + +def _normalize_locations(sensor_location: list[str]) -> list[str]: + normalized = [str(node_id).strip() for node_id in sensor_location] + if not normalized or any(not node_id for node_id in normalized): + raise SensorPlacementValidationError("监测点列表不能为空") + if len(set(normalized)) != len(normalized): + raise SensorPlacementValidationError("监测点列表不能包含重复节点") + return normalized + + +def _sensor_points( + network: str, + sensor_location: list[str], +) -> list[dict[str, Any]]: + nodes = wndb.get_sensor_placement_nodes(network, sensor_location) + by_id = {str(node["node_id"]): node for node in nodes} + missing = [node_id for node_id in sensor_location if node_id not in by_id] + if missing: + raise SensorPlacementValidationError( + f"以下节点不存在或不是 junction: {', '.join(missing)}" + ) + + points: list[dict[str, Any]] = [] + for node_id in sensor_location: + node = by_id[node_id] + project_x = float(node["project_x"]) + project_y = float(node["project_y"]) + map_x = float(node["map_x"]) + map_y = float(node["map_y"]) + longitude, latitude = _to_wgs84.transform(map_x, map_y) + points.append( + { + "node_id": node_id, + "project_x": project_x, + "project_y": project_y, + "map_x": map_x, + "map_y": map_y, + "longitude": float(longitude), + "latitude": float(latitude), + "elevation": float(node["elevation"]), + } + ) + return points + + +def validate_sensor_placement_nodes( + network: str, + sensor_location: list[str], +) -> None: + _sensor_points(network, _normalize_locations(sensor_location)) + + +def get_sensor_placement_scheme(network: str, scheme_id: int) -> dict[str, Any]: + scheme = wndb.get_sensor_placement(network, scheme_id) + if scheme is None: + raise SensorPlacementNotFoundError("监测点方案不存在") + + locations = [str(item) for item in (scheme.get("sensor_location") or [])] + return { + **scheme, + "sensor_number": len(locations), + "sensor_location": locations, + "sensor_points": _sensor_points(network, locations), + } + + +def update_sensor_placement_scheme( + network: str, + scheme_id: int, + *, + expected_sensor_location: list[str], + sensor_location: list[str], +) -> dict[str, Any]: + expected = _normalize_locations(expected_sensor_location) + next_locations = _normalize_locations(sensor_location) + _sensor_points(network, next_locations) + + updated = wndb.update_sensor_placement( + network, + scheme_id, + expected_sensor_location=expected, + sensor_location=next_locations, + ) + if updated is None: + if wndb.get_sensor_placement(network, scheme_id) is None: + raise SensorPlacementNotFoundError("监测点方案不存在") + raise SensorPlacementConflictError("方案已被其他用户修改,请重新加载") + return get_sensor_placement_scheme(network, scheme_id) + + +def can_edit_sensor_placement(user: Any, scheme: dict[str, Any]) -> bool: + return bool( + getattr(user, "is_superuser", False) + or getattr(user, "role", None) == "admin" + or getattr(user, "username", None) == scheme.get("username") + ) + + +def _safe_excel_text(value: Any) -> str: + text = "" if value is None else str(value) + if text.startswith(("=", "+", "-", "@")): + return f"'{text}" + return text + + +def _populate_info_sheet( + sheet: Worksheet, + *, + network: str, + scheme: dict[str, Any], + location_count: int, + is_draft: bool, +) -> None: + created_at = scheme["create_time"] + if isinstance(created_at, datetime): + created_at = created_at.isoformat(timespec="minutes") + + rows = [ + ("项目", network), + ("方案名称", scheme["scheme_name"]), + ("监测点数量", location_count), + ("最小管径", scheme["min_diameter"]), + ("创建人", scheme["username"]), + ("创建时间", created_at), + ("导出时间", datetime.now().astimezone().isoformat(timespec="minutes")), + ("文档状态", "未保存草稿" if is_draft else "当前方案"), + ("坐标说明", _COORDINATE_DESCRIPTION), + ] + for row_index, (label, value) in enumerate(rows, start=1): + sheet.cell(row=row_index, column=1, value=label) + safe_value = _safe_excel_text(value) if isinstance(value, str) else value + sheet.cell(row=row_index, column=2, value=safe_value) + sheet.column_dimensions["A"].width = 18 + sheet.column_dimensions["B"].width = 64 + + +def _populate_list_sheet( + sheet: Worksheet, + *, + points: list[dict[str, Any]], + adjustment_status: dict[str, str], +) -> None: + sheet.append(_LIST_HEADERS) + for index, point in enumerate(points, start=1): + status = adjustment_status.get(point["node_id"], "current") + sheet.append( + [ + index, + _safe_excel_text(point["node_id"]), + point["longitude"], + point["latitude"], + point["project_x"], + point["project_y"], + point["map_x"], + point["map_y"], + point["elevation"], + _STATUS_LABELS.get(status, "当前方案"), + ] + ) + + header_fill = PatternFill("solid", fgColor="257DD4") + for cell in sheet[1]: + cell.fill = header_fill + cell.font = Font(color="FFFFFF", bold=True) + cell.alignment = Alignment(horizontal="center", vertical="center") + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = sheet.dimensions + for index, width in enumerate(_LIST_COLUMN_WIDTHS, start=1): + sheet.column_dimensions[get_column_letter(index)].width = width + for row in sheet.iter_rows(min_row=2): + row[0].alignment = Alignment(horizontal="center") + for cell in row[2:9]: + cell.number_format = "0.000000" + + +def build_sensor_placement_workbook( + *, + network: str, + scheme: dict[str, Any], + sensor_location: list[str], + adjustment_status: dict[str, str], +) -> BytesIO: + locations = _normalize_locations(sensor_location) + points = _sensor_points(network, locations) + is_draft = locations != list(scheme["sensor_location"]) + + workbook = Workbook() + info_sheet = workbook.active + info_sheet.title = "方案信息" + _populate_info_sheet( + info_sheet, + network=network, + scheme=scheme, + location_count=len(locations), + is_draft=is_draft, + ) + + list_sheet = workbook.create_sheet("监测点清单") + _populate_list_sheet( + list_sheet, + points=points, + adjustment_status=adjustment_status, + ) + + output = BytesIO() + workbook.save(output) + output.seek(0) + return output diff --git a/app/services/tjnetwork.py b/app/services/tjnetwork.py index 25f4a0f..fa36d76 100644 --- a/app/services/tjnetwork.py +++ b/app/services/tjnetwork.py @@ -1370,4 +1370,3 @@ def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]: ############################################################ def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]: return api.get_all_burst_locate_results(name) - diff --git a/tests/api/test_audit_middleware.py b/tests/api/test_audit_middleware.py new file mode 100644 index 0000000..96f53ac --- /dev/null +++ b/tests/api/test_audit_middleware.py @@ -0,0 +1,36 @@ +from io import BytesIO +from unittest.mock import AsyncMock + +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from fastapi.testclient import TestClient + +from app.infra.audit import middleware as audit_middleware +from app.infra.audit.middleware import AuditMiddleware + + +def test_post_streaming_response_survives_audit_body_capture(monkeypatch): + log_audit_event = AsyncMock() + monkeypatch.setattr(audit_middleware, "log_audit_event", log_audit_event) + + app = FastAPI() + app.add_middleware(AuditMiddleware) + + @app.post("/exports") + async def export(payload: dict[str, str]) -> StreamingResponse: + assert payload == {"format": "xlsx"} + return StreamingResponse( + BytesIO(b"xlsx"), + media_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + + response = TestClient(app, raise_server_exceptions=False).post( + "/exports", + json={"format": "xlsx"}, + ) + + assert response.status_code == 200 + assert response.content == b"xlsx" + log_audit_event.assert_awaited_once() diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py new file mode 100644 index 0000000..1b67ef0 --- /dev/null +++ b/tests/api/test_sensor_placement_endpoints.py @@ -0,0 +1,325 @@ +from datetime import datetime, timezone +from io import BytesIO +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from tests.conftest import build_test_app, install_stub, load_module_from_path + + +class NotFoundError(LookupError): + pass + + +class ValidationError(ValueError): + pass + + +class ConflictError(RuntimeError): + pass + + +def _scheme(**overrides): + value = { + "id": 7, + "scheme_name": "北区测压点", + "sensor_number": 2, + "min_diameter": 300, + "username": "alice", + "create_time": datetime(2026, 7, 30, 8, 0, tzinfo=timezone.utc), + "sensor_location": ["J1", "J2"], + "sensor_points": [ + { + "node_id": "J1", + "project_x": 13500000.0, + "project_y": 3600000.0, + "map_x": 13500000.0, + "map_y": 3600000.0, + "longitude": 121.0, + "latitude": 31.0, + "elevation": 4.5, + }, + { + "node_id": "J2", + "project_x": 13500100.0, + "project_y": 3600100.0, + "map_x": 13500100.0, + "map_y": 3600100.0, + "longitude": 121.001, + "latitude": 31.001, + "elevation": 5.0, + }, + ], + } + value.update(overrides) + return value + + +def _load_module(monkeypatch): + install_stub(monkeypatch, "app.algorithms", package=True) + install_stub( + monkeypatch, + "app.algorithms.sensor", + { + "pressure_sensor_placement_kmeans": lambda **kwargs: {"id": 7}, + "pressure_sensor_placement_sensitivity": lambda **kwargs: {"id": 7}, + }, + ) + install_stub(monkeypatch, "app.auth", package=True) + + async def current_user(): + return SimpleNamespace( + username="alice", + role="user", + is_superuser=False, + ) + + install_stub( + monkeypatch, + "app.auth.metadata_dependencies", + {"get_current_metadata_user": current_user}, + ) + + class ProjectContext: + def __init__(self, project_code: str): + self.project_code = project_code + + async def project_context(): + return ProjectContext("tjwater") + + install_stub( + monkeypatch, + "app.auth.project_dependencies", + { + "ProjectContext": ProjectContext, + "get_project_context": project_context, + }, + ) + install_stub(monkeypatch, "app.services", package=True) + install_stub( + monkeypatch, + "app.services.sensor_placement", + { + "SensorPlacementConflictError": ConflictError, + "SensorPlacementNotFoundError": NotFoundError, + "SensorPlacementValidationError": ValidationError, + "build_sensor_placement_workbook": lambda **kwargs: BytesIO(b"xlsx"), + "can_edit_sensor_placement": ( + lambda user, scheme: user.username == scheme["username"] + or user.role == "admin" + or user.is_superuser + ), + "get_sensor_placement_scheme": lambda network, scheme_id: _scheme( + id=scheme_id + ), + "update_sensor_placement_scheme": ( + lambda network, scheme_id, **kwargs: _scheme( + id=scheme_id, + sensor_location=kwargs["sensor_location"], + sensor_number=len(kwargs["sensor_location"]), + ) + ), + }, + ) + return load_module_from_path( + "tests_sensor_placement_endpoints_module", + "app/api/v1/endpoints/sensor_placement.py", + ) + + +def _client(module, user=None): + app = build_test_app(module.router, "/api/v1") + if user is None: + user = SimpleNamespace( + username="alice", + role="user", + is_superuser=False, + ) + app.dependency_overrides[module.get_current_metadata_user] = lambda: user + app.dependency_overrides[module.get_project_context] = lambda: ( + module.ProjectContext("tjwater") + ) + return TestClient(app) + + +def test_optimize_returns_created_scheme(monkeypatch): + module = _load_module(monkeypatch) + captured = {} + + def optimize(**kwargs): + captured.update(kwargs) + return {"id": 7} + + monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": "北区测压点", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 200 + assert response.json()["sensor_location"] == ["J1", "J2"] + assert captured["username"] == "alice" + + +def test_optimize_rejects_unsupported_sensor_type(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": "北区测流点", + "sensor_type": "flow", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 422 + + +def test_optimize_rejects_network_outside_project_context(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "other_project", + "scheme_name": "越权方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 403 + + +def test_optimize_rejects_network_path_traversal(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "../other_project", + "scheme_name": "非法路径", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 422 + + +def test_optimize_rejects_unbounded_sensor_count(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": "超大方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 201, + "min_diameter": 300, + }, + ) + + assert response.status_code == 422 + + +def test_update_rejects_non_owner(monkeypatch): + module = _load_module(monkeypatch) + response = _client( + module, + SimpleNamespace(username="bob", role="user", is_superuser=False), + ).put( + "/api/v1/sensor-placement-schemes/7", + params={"network": "tjwater"}, + json={ + "expected_sensor_location": ["J1", "J2"], + "sensor_location": ["J1", "J3"], + }, + ) + + assert response.status_code == 403 + + +def test_admin_can_overwrite_scheme(monkeypatch): + module = _load_module(monkeypatch) + response = _client( + module, + SimpleNamespace(username="ops", role="admin", is_superuser=False), + ).put( + "/api/v1/sensor-placement-schemes/7", + params={"network": "tjwater"}, + json={ + "expected_sensor_location": ["J1", "J2"], + "sensor_location": ["J1", "J3"], + }, + ) + + assert response.status_code == 200 + assert response.json()["sensor_number"] == 2 + assert response.json()["sensor_location"] == ["J1", "J3"] + + +def test_update_maps_concurrent_change_to_409(monkeypatch): + module = _load_module(monkeypatch) + + def conflict(*args, **kwargs): + raise ConflictError("方案已被其他用户修改,请重新加载") + + monkeypatch.setattr(module, "update_sensor_placement_scheme", conflict) + response = _client(module).put( + "/api/v1/sensor-placement-schemes/7", + params={"network": "tjwater"}, + json={ + "expected_sensor_location": ["J1", "J2"], + "sensor_location": ["J1", "J3"], + }, + ) + + assert response.status_code == 409 + assert "重新加载" in response.json()["detail"] + + +def test_update_rejects_duplicate_nodes_before_service(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module).put( + "/api/v1/sensor-placement-schemes/7", + params={"network": "tjwater"}, + json={ + "expected_sensor_location": ["J1", "J2"], + "sensor_location": ["J1", "J1"], + }, + ) + + assert response.status_code == 422 + + +def test_export_returns_xlsx_download(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/7/exports/excel", + params={"network": "tjwater"}, + json={ + "sensor_location": ["J1", "J2"], + "adjustment_status": {"J1": "original", "J2": "replaced"}, + }, + ) + + assert response.status_code == 200 + assert response.content == b"xlsx" + assert response.headers["content-type"].startswith( + "application/vnd.openxmlformats-officedocument" + ) + assert "filename*=UTF-8" in response.headers["content-disposition"] diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py new file mode 100644 index 0000000..b4131aa --- /dev/null +++ b/tests/unit/test_sensor_placement_service.py @@ -0,0 +1,188 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +from openpyxl import load_workbook + +from app.native.wndb import s42_sensor_placement +from app.services import sensor_placement + + +def _mock_project_cursor(monkeypatch): + cursor = MagicMock() + connection = MagicMock() + connection.cursor.return_value.__enter__.return_value = cursor + connection_context = MagicMock() + connection_context.__enter__.return_value = connection + monkeypatch.setattr( + s42_sensor_placement, + "project_connection", + lambda _network: connection_context, + ) + return cursor + + +def test_build_workbook_contains_engineering_columns(monkeypatch): + monkeypatch.setattr( + sensor_placement, + "_sensor_points", + lambda network, locations: [ + { + "node_id": "J1", + "project_x": 13500000.0, + "project_y": 3600000.0, + "map_x": 13500000.0, + "map_y": 3600000.0, + "longitude": 121.0, + "latitude": 31.0, + "elevation": 4.5, + } + ], + ) + scheme = { + "id": 7, + "scheme_name": "北区测压点", + "sensor_number": 2, + "min_diameter": 300, + "username": "alice", + "create_time": datetime(2026, 7, 30, tzinfo=timezone.utc), + "sensor_location": ["J1", "J2"], + } + + output = sensor_placement.build_sensor_placement_workbook( + network="tjwater", + scheme=scheme, + sensor_location=["J1"], + adjustment_status={"J1": "replaced"}, + ) + workbook = load_workbook(output) + + assert workbook.sheetnames == ["方案信息", "监测点清单"] + headers = [cell.value for cell in workbook["监测点清单"][1]] + assert headers == [ + "序号", + "节点 ID", + "经度", + "纬度", + "工程 X", + "工程 Y", + "地图 X", + "地图 Y", + "高程", + "调整状态", + ] + assert workbook["监测点清单"]["J2"].value == "替换" + assert workbook["方案信息"]["B8"].value == "未保存草稿" + + +def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinates( + monkeypatch, +): + monkeypatch.setattr( + sensor_placement.wndb, + "get_sensor_placement_nodes", + lambda network, node_ids: [ + { + "node_id": "J1", + "project_x": 3038.94, + "project_y": -34446.59, + "map_x": 13525191.530279, + "map_y": 3622984.760237, + "elevation": 4.5, + } + ], + ) + + point = sensor_placement._sensor_points("tjwater", ["J1"])[0] + + assert point["project_x"] == 3038.94 + assert point["project_y"] == -34446.59 + assert point["longitude"] == pytest.approx(121.498863, abs=1e-6) + assert point["latitude"] == pytest.approx(30.924784, abs=1e-6) + + +def test_update_validates_nodes_before_write(monkeypatch): + monkeypatch.setattr( + sensor_placement.wndb, + "get_sensor_placement_nodes", + lambda network, node_ids: [], + ) + + try: + sensor_placement.update_sensor_placement_scheme( + "tjwater", + 7, + expected_sensor_location=["J1"], + sensor_location=["missing"], + ) + except sensor_placement.SensorPlacementValidationError as exc: + assert "missing" in str(exc) + else: + raise AssertionError("expected invalid node to be rejected") + + +def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch): + cursor = _mock_project_cursor(monkeypatch) + cursor.fetchall.return_value = [] + + s42_sensor_placement.get_sensor_placement_nodes("tjwater", ["J1"]) + + query = cursor.execute.call_args.args[0] + assert "geo_junctions_mat" in query + assert "ST_X(c.coord)" in query + assert "ST_Y(c.coord)" in query + assert "ST_X(gj.geom)" in query + assert "ST_Y(gj.geom)" in query + + +def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch): + monkeypatch.setattr( + sensor_placement, + "_sensor_points", + lambda network, locations: [ + { + "node_id": "J1", + "project_x": 3038.94, + "project_y": -34446.59, + "map_x": 13525191.53, + "map_y": 3622984.76, + "longitude": 121.49, + "latitude": 30.92, + "elevation": 4.5, + } + ], + ) + output = sensor_placement.build_sensor_placement_workbook( + network="tjwater", + scheme={ + "scheme_name": "=1+1", + "sensor_location": ["J1"], + "min_diameter": 300, + "username": "alice", + "create_time": datetime(2026, 7, 30, tzinfo=timezone.utc), + }, + sensor_location=["J1"], + adjustment_status={}, + ) + + workbook = load_workbook(output, data_only=False) + assert workbook["方案信息"]["B2"].value == "'=1+1" + assert workbook["方案信息"]["B2"].data_type == "s" + + +def test_create_sensor_placement_returns_inserted_record(monkeypatch): + cursor = _mock_project_cursor(monkeypatch) + cursor.fetchone.return_value = {"id": 7, "sensor_location": ["J1", "J2"]} + + created = s42_sensor_placement.create_sensor_placement( + "tjwater", + scheme_name="北区测压点", + min_diameter=300, + username="alice", + sensor_location=["J1", "J2"], + ) + + assert created["id"] == 7 + query, parameters = cursor.execute.call_args.args + assert "INSERT INTO sensor_placement" in query + assert parameters == ("北区测压点", 2, 300, "alice", ["J1", "J2"]) From 3fbb17bb30e88e36c5f6d904ee04ac73e5f81ae9 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 16:21:38 +0800 Subject: [PATCH 72/93] fix(sensor-placement): enforce project write boundaries Bind every scheme request to ProjectContext, keep viewer access read-only, reject concurrent optimization jobs without blocking worker threads, and cap export/update payload sizes. Run optimization and workbook work off the event loop. --- app/algorithms/sensor/__init__.py | 11 ++- app/api/v1/endpoints/sensor_placement.py | 51 ++++++++++-- app/domain/schemas/sensor_placement.py | 15 +++- tests/api/test_sensor_placement_endpoints.py | 88 +++++++++++++++++++- 4 files changed, 152 insertions(+), 13 deletions(-) diff --git a/app/algorithms/sensor/__init__.py b/app/algorithms/sensor/__init__.py index 998016b..b500fc4 100644 --- a/app/algorithms/sensor/__init__.py +++ b/app/algorithms/sensor/__init__.py @@ -7,6 +7,7 @@ from app.algorithms.sensor import kmeans as kmeans_sensor from app.algorithms.sensor import sensitivity from app.native.wndb.s42_sensor_placement import create_sensor_placement from app.services.sensor_placement import ( + SensorPlacementConflictError, SensorPlacementValidationError, validate_sensor_placement_nodes, ) @@ -31,7 +32,15 @@ def _sensor_inp_lock(name: str): inp_path.parent.mkdir(parents=True, exist_ok=True) lock_path = inp_path.with_suffix(".sensor.lock") with lock_path.open("w", encoding="utf-8") as lock_file: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + fcntl.flock( + lock_file.fileno(), + fcntl.LOCK_EX | fcntl.LOCK_NB, + ) + except BlockingIOError as exc: + raise SensorPlacementConflictError( + "当前项目已有监测点优化任务正在运行,请稍后重试" + ) from exc try: yield inp_path finally: diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 454da49..0f0afc3 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -41,6 +41,25 @@ def _project_network(network: str, project_context: ProjectContext) -> str: return project_context.project_code +def _can_modify_project(project_context: ProjectContext, current_user: Any) -> bool: + return bool( + project_context.project_role in {"owner", "admin", "member"} + or getattr(current_user, "role", None) == "admin" + or getattr(current_user, "is_superuser", False) + ) + + +def _require_project_write( + project_context: ProjectContext, + current_user: Any, +) -> None: + if not _can_modify_project(project_context, current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="当前项目角色为只读,不能修改监测点方案", + ) + + def _service_http_error(exc: Exception) -> HTTPException: if isinstance(exc, SensorPlacementNotFoundError): return HTTPException( @@ -62,12 +81,16 @@ def _get_scheme_response( network: str, scheme_id: int, current_user: Any, + project_context: ProjectContext, ) -> dict[str, Any]: try: scheme = get_sensor_placement_scheme(network, scheme_id) return { **scheme, - "can_edit": can_edit_sensor_placement(current_user, scheme), + "can_edit": ( + _can_modify_project(project_context, current_user) + and can_edit_sensor_placement(current_user, scheme) + ), } except ( SensorPlacementNotFoundError, @@ -87,6 +110,7 @@ async def optimize_sensor_placement_scheme( current_user=Depends(get_current_metadata_user), ) -> dict[str, Any]: network = _project_network(payload.network, project_context) + _require_project_write(project_context, current_user) optimizer = ( pressure_sensor_placement_sensitivity if payload.method == "sensitivity" @@ -103,7 +127,11 @@ async def optimize_sensor_placement_scheme( ) scheme = get_sensor_placement_scheme(network, int(created["id"])) return {**scheme, "can_edit": True} - except (SensorPlacementValidationError, ValueError) as exc: + except ( + SensorPlacementConflictError, + SensorPlacementValidationError, + ValueError, + ) as exc: raise _service_http_error(exc) from exc except Exception as exc: logger.exception("Sensor placement optimization failed") @@ -128,6 +156,7 @@ async def get_sensor_placement_scheme_detail( _project_network(network, project_context), scheme_id, current_user, + project_context, ) @@ -144,7 +173,13 @@ async def overwrite_sensor_placement_scheme( current_user=Depends(get_current_metadata_user), ) -> dict[str, Any]: network = _project_network(network, project_context) - scheme = _get_scheme_response(network, scheme_id, current_user) + _require_project_write(project_context, current_user) + scheme = _get_scheme_response( + network, + scheme_id, + current_user, + project_context, + ) if not scheme["can_edit"]: raise HTTPException(status_code=403, detail="无权修改该监测点方案") @@ -176,7 +211,12 @@ async def export_sensor_placement_excel( current_user=Depends(get_current_metadata_user), ) -> StreamingResponse: network = _project_network(network, project_context) - scheme = _get_scheme_response(network, scheme_id, current_user) + scheme = _get_scheme_response( + network, + scheme_id, + current_user, + project_context, + ) if ( payload.sensor_location != scheme["sensor_location"] and not scheme["can_edit"] @@ -184,7 +224,8 @@ async def export_sensor_placement_excel( raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿") try: - workbook = build_sensor_placement_workbook( + workbook = await run_in_threadpool( + build_sensor_placement_workbook, network=network, scheme=scheme, sensor_location=payload.sensor_location, diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py index 8dcfe69..71f6680 100644 --- a/app/domain/schemas/sensor_placement.py +++ b/app/domain/schemas/sensor_placement.py @@ -39,8 +39,12 @@ class SensorPlacementOptimizeRequest(BaseModel): class SensorPlacementUpdateRequest(BaseModel): - expected_sensor_location: list[str] = Field(..., min_length=1) - sensor_location: list[str] = Field(..., min_length=1) + expected_sensor_location: list[str] = Field( + ..., + min_length=1, + max_length=200, + ) + sensor_location: list[str] = Field(..., min_length=1, max_length=200) @field_validator("expected_sensor_location", "sensor_location") @classmethod @@ -49,8 +53,11 @@ class SensorPlacementUpdateRequest(BaseModel): class SensorPlacementExportRequest(BaseModel): - sensor_location: list[str] = Field(..., min_length=1) - adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict) + sensor_location: list[str] = Field(..., min_length=1, max_length=200) + adjustment_status: dict[str, AdjustmentStatus] = Field( + default_factory=dict, + max_length=200, + ) @field_validator("sensor_location") @classmethod diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index 1b67ef0..34756da 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -81,8 +81,9 @@ def _load_module(monkeypatch): ) class ProjectContext: - def __init__(self, project_code: str): + def __init__(self, project_code: str, project_role: str = "member"): self.project_code = project_code + self.project_role = project_role async def project_context(): return ProjectContext("tjwater") @@ -127,7 +128,7 @@ def _load_module(monkeypatch): ) -def _client(module, user=None): +def _client(module, user=None, project_role="member"): app = build_test_app(module.router, "/api/v1") if user is None: user = SimpleNamespace( @@ -137,7 +138,7 @@ def _client(module, user=None): ) app.dependency_overrides[module.get_current_metadata_user] = lambda: user app.dependency_overrides[module.get_project_context] = lambda: ( - module.ProjectContext("tjwater") + module.ProjectContext("tjwater", project_role) ) return TestClient(app) @@ -236,6 +237,73 @@ def test_optimize_rejects_unbounded_sensor_count(monkeypatch): assert response.status_code == 422 +def test_optimize_rejects_viewer_project_role(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module, project_role="viewer").post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": "只读成员方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 403 + + +def test_project_owner_and_admin_can_optimize(monkeypatch): + module = _load_module(monkeypatch) + for project_role in ("owner", "admin"): + response = _client(module, project_role=project_role).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": f"{project_role}方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + assert response.status_code == 200 + + +def test_optimize_maps_running_project_job_to_409(monkeypatch): + module = _load_module(monkeypatch) + + def conflict(**kwargs): + raise ConflictError("当前项目已有监测点优化任务正在运行,请稍后重试") + + monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", conflict) + response = _client(module).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": "并发方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 409 + + +def test_viewer_reads_scheme_as_non_editable(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module, project_role="viewer").get( + "/api/v1/sensor-placement-schemes/7", + params={"network": "tjwater"}, + ) + + assert response.status_code == 200 + assert response.json()["can_edit"] is False + + def test_update_rejects_non_owner(monkeypatch): module = _load_module(monkeypatch) response = _client( @@ -253,6 +321,20 @@ def test_update_rejects_non_owner(monkeypatch): assert response.status_code == 403 +def test_update_rejects_owner_with_viewer_project_role(monkeypatch): + module = _load_module(monkeypatch) + response = _client(module, project_role="viewer").put( + "/api/v1/sensor-placement-schemes/7", + params={"network": "tjwater"}, + json={ + "expected_sensor_location": ["J1", "J2"], + "sensor_location": ["J1", "J3"], + }, + ) + + assert response.status_code == 403 + + def test_admin_can_overwrite_scheme(monkeypatch): module = _load_module(monkeypatch) response = _client( From ae1a657554db843b2cec971e04c6eee4a62af699 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 16:45:09 +0800 Subject: [PATCH 73/93] feat(server): add project RBAC and guarded workflows --- app/algorithms/isolation/valve.py | 6 +- app/api/v1/endpoints/access.py | 39 +++ app/api/v1/endpoints/admin_metadata.py | 1 + app/api/v1/endpoints/agent_auth.py | 3 + app/api/v1/endpoints/audit.py | 110 ++++--- app/api/v1/endpoints/model_import.py | 297 +++++++++++++++++ app/api/v1/endpoints/project.py | 74 +---- app/api/v1/endpoints/sensor_placement.py | 17 +- app/api/v1/endpoints/simulation.py | 48 +-- app/api/v1/endpoints/snapshots.py | 9 +- app/api/v1/router.py | 304 ++++++++++++------ app/auth/permissions.py | 162 ++++++++++ app/auth/project_dependencies.py | 170 +++++----- app/domain/schemas/access.py | 13 + app/domain/schemas/admin_metadata.py | 4 +- .../repositories/metadata_repository.py | 12 +- .../sql/004_metadata_auth_management.sql | 10 +- resources/sql/006_metadata_rbac_roles.sql | 32 ++ tests/api/test_access_endpoints.py | 77 +++++ tests/api/test_admin_metadata_endpoints.py | 29 +- tests/api/test_agent_auth_endpoints.py | 18 +- tests/api/test_audit_endpoints.py | 8 +- tests/api/test_meta_endpoints.py | 2 +- tests/api/test_model_import_endpoints.py | 100 ++++++ tests/api/test_sensor_placement_endpoints.py | 33 +- tests/api/test_simulation_endpoints.py | 21 -- tests/auth/test_permissions.py | 159 +++++++++ tests/auth/test_rbac_migration.py | 14 + tests/unit/test_valve_isolation.py | 71 ++++ 29 files changed, 1431 insertions(+), 412 deletions(-) create mode 100644 app/api/v1/endpoints/access.py create mode 100644 app/api/v1/endpoints/model_import.py create mode 100644 app/auth/permissions.py create mode 100644 app/domain/schemas/access.py create mode 100644 resources/sql/006_metadata_rbac_roles.sql create mode 100644 tests/api/test_access_endpoints.py create mode 100644 tests/api/test_model_import_endpoints.py create mode 100644 tests/auth/test_permissions.py create mode 100644 tests/auth/test_rbac_migration.py create mode 100644 tests/unit/test_valve_isolation.py diff --git a/app/algorithms/isolation/valve.py b/app/algorithms/isolation/valve.py index 57cd1e1..c53c0f4 100644 --- a/app/algorithms/isolation/valve.py +++ b/app/algorithms/isolation/valve.py @@ -149,14 +149,16 @@ def valve_isolation_analysis( must_close_valves.sort() optional_valves.sort() + isolatable = bool(must_close_valves) result = { "accident_elements": target_elements, "disabled_valves": disabled_valves, - "affected_nodes": sorted(affected_nodes), + "affected_nodes": sorted(affected_nodes) if isolatable else [], + "affected_node_count": len(affected_nodes), "must_close_valves": must_close_valves, "optional_valves": optional_valves, - "isolatable": len(must_close_valves) > 0, + "isolatable": isolatable, } if len(target_elements) == 1: diff --git a/app/api/v1/endpoints/access.py b/app/api/v1/endpoints/access.py new file mode 100644 index 0000000..fcf6a56 --- /dev/null +++ b/app/api/v1/endpoints/access.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, Header + +from app.auth.metadata_dependencies import ( + get_current_metadata_user, + get_metadata_repository, +) +from app.auth.permissions import resolve_permissions +from app.auth.project_dependencies import resolve_project_context +from app.domain.schemas.access import AccessContextResponse +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + +router = APIRouter() + + +@router.get("/access/context", response_model=AccessContextResponse) +async def get_access_context( + x_project_id: str | None = Header(default=None, alias="X-Project-Id"), + current_user=Depends(get_current_metadata_user), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> AccessContextResponse: + project_context = ( + await resolve_project_context(x_project_id, current_user, metadata_repo) + if x_project_id + else None + ) + permissions = resolve_permissions( + project_role=project_context.project_role if project_context else None, + system_role=current_user.role, + is_superuser=current_user.is_superuser, + ) + return AccessContextResponse( + user_id=current_user.id, + username=current_user.username, + system_role=current_user.role, + is_system_admin=current_user.is_superuser or current_user.role == "admin", + project_id=project_context.project_id if project_context else None, + project_role=project_context.project_role if project_context else None, + permissions=sorted(permissions), + ) diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py index 9ecd0ee..930a3d8 100644 --- a/app/api/v1/endpoints/admin_metadata.py +++ b/app/api/v1/endpoints/admin_metadata.py @@ -266,6 +266,7 @@ async def create_admin_project( gs_workspace=payload.gs_workspace, map_extent=payload.map_extent, status=payload.status, + creator_user_id=current_user.id, ) except IntegrityError as exc: raise HTTPException( diff --git a/app/api/v1/endpoints/agent_auth.py b/app/api/v1/endpoints/agent_auth.py index c2637de..3aed53b 100644 --- a/app/api/v1/endpoints/agent_auth.py +++ b/app/api/v1/endpoints/agent_auth.py @@ -9,6 +9,7 @@ from app.auth.project_dependencies import ( ProjectContext, get_project_context, ) +from app.auth.permissions import permissions_for_context router = APIRouter() @@ -22,6 +23,7 @@ class AgentAuthContextResponse(BaseModel): project_id: str network: str project_role: str + permissions: list[str] token_expires_at: str | None = None @@ -46,5 +48,6 @@ async def get_agent_auth_context( project_id=str(ctx.project_id), network=ctx.project_code, project_role=ctx.project_role, + permissions=sorted(permissions_for_context(ctx)), token_expires_at=token_expires_at, ) diff --git a/app/api/v1/endpoints/audit.py b/app/api/v1/endpoints/audit.py index dd0d7d6..871ff68 100644 --- a/app/api/v1/endpoints/audit.py +++ b/app/api/v1/endpoints/audit.py @@ -1,29 +1,30 @@ -""" -审计日志 API 接口 - -仅管理员可访问 -""" - -from typing import List, Optional -from uuid import UUID from datetime import datetime -from fastapi import APIRouter, Depends, Query, Path -from app.domain.schemas.audit import AuditLogResponse -from app.infra.db.metadb.repositories.audit_repository import AuditRepository +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + from app.auth.metadata_dependencies import ( get_current_metadata_admin, get_current_metadata_user, ) +from app.core.audit import AuditAction, log_audit_event +from app.domain.schemas.audit import AuditLogResponse from app.infra.db.metadb.database import get_metadata_session -from sqlalchemy.ext.asyncio import AsyncSession +from app.infra.db.metadb.repositories.audit_repository import AuditRepository router = APIRouter() +class SessionAuditEventRequest(BaseModel): + event: Literal["login", "logout"] + + async def get_audit_repository( session: AsyncSession = Depends(get_metadata_session), ) -> AuditRepository: - """获取审计日志仓储""" return AuditRepository(session) @@ -31,26 +32,21 @@ async def get_audit_repository( "/logs", summary="查询审计日志", description="查询审计日志(仅管理员)", - response_model=List[AuditLogResponse], + response_model=list[AuditLogResponse], ) async def get_audit_logs( - user_id: Optional[UUID] = Query(None, description="按用户ID过滤"), - project_id: Optional[UUID] = Query(None, description="按项目ID过滤"), - action: Optional[str] = Query(None, description="按操作类型过滤"), - resource_type: Optional[str] = Query(None, description="按资源类型过滤"), - start_time: Optional[datetime] = Query(None, description="开始时间"), - end_time: Optional[datetime] = Query(None, description="结束时间"), + user_id: UUID | None = Query(None, description="按用户ID过滤"), + project_id: UUID | None = Query(None, description="按项目ID过滤"), + action: str | None = Query(None, description="按操作类型过滤"), + resource_type: str | None = Query(None, description="按资源类型过滤"), + start_time: datetime | None = Query(None, description="开始时间"), + end_time: datetime | None = Query(None, description="结束时间"), skip: int = Query(0, ge=0, description="跳过记录数"), limit: int = Query(100, ge=1, le=1000, description="限制记录数"), - current_user=Depends(get_current_metadata_admin), + _current_user=Depends(get_current_metadata_admin), audit_repo: AuditRepository = Depends(get_audit_repository), -) -> List[AuditLogResponse]: - """ - 查询审计日志 - - 支持按用户、时间、操作类型等条件过滤,仅管理员可访问 - """ - logs = await audit_repo.get_logs( +) -> list[AuditLogResponse]: + return await audit_repo.get_logs( user_id=user_id, project_id=project_id, action=action, @@ -60,7 +56,6 @@ async def get_audit_logs( skip=skip, limit=limit, ) - return logs @router.get( @@ -69,20 +64,15 @@ async def get_audit_logs( description="获取审计日志总数(仅管理员)", ) async def get_audit_logs_count( - user_id: Optional[UUID] = Query(None, description="按用户ID过滤"), - project_id: Optional[UUID] = Query(None, description="按项目ID过滤"), - action: Optional[str] = Query(None, description="按操作类型过滤"), - resource_type: Optional[str] = Query(None, description="按资源类型过滤"), - start_time: Optional[datetime] = Query(None, description="开始时间"), - end_time: Optional[datetime] = Query(None, description="结束时间"), - current_user=Depends(get_current_metadata_admin), + user_id: UUID | None = Query(None, description="按用户ID过滤"), + project_id: UUID | None = Query(None, description="按项目ID过滤"), + action: str | None = Query(None, description="按操作类型过滤"), + resource_type: str | None = Query(None, description="按资源类型过滤"), + start_time: datetime | None = Query(None, description="开始时间"), + end_time: datetime | None = Query(None, description="结束时间"), + _current_user=Depends(get_current_metadata_admin), audit_repo: AuditRepository = Depends(get_audit_repository), ) -> dict: - """ - 获取审计日志总数 - - 获取符合条件的审计日志的总数,仅管理员可访问 - """ count = await audit_repo.get_log_count( user_id=user_id, project_id=project_id, @@ -94,27 +84,42 @@ async def get_audit_logs_count( return {"count": count} +@router.post("/session-events", status_code=status.HTTP_204_NO_CONTENT) +async def record_session_event( + payload: SessionAuditEventRequest, + request: Request, + current_user=Depends(get_current_metadata_user), + session: AsyncSession = Depends(get_metadata_session), +) -> None: + await log_audit_event( + action=AuditAction.LOGIN if payload.event == "login" else AuditAction.LOGOUT, + user_id=current_user.id, + resource_type="session", + resource_id=str(current_user.keycloak_id), + ip_address=request.client.host if request.client else None, + request_method=request.method, + request_path=request.url.path, + response_status=status.HTTP_204_NO_CONTENT, + session=session, + ) + + @router.get( "/logs/my", summary="查询我的审计日志", description="查询当前用户的审计日志", - response_model=List[AuditLogResponse], + response_model=list[AuditLogResponse], ) async def get_my_audit_logs( - action: Optional[str] = Query(None, description="按操作类型过滤"), - start_time: Optional[datetime] = Query(None, description="开始时间"), - end_time: Optional[datetime] = Query(None, description="结束时间"), + action: str | None = Query(None, description="按操作类型过滤"), + start_time: datetime | None = Query(None, description="开始时间"), + end_time: datetime | None = Query(None, description="结束时间"), skip: int = Query(0, ge=0, description="跳过记录数"), limit: int = Query(100, ge=1, le=1000, description="限制记录数"), current_user=Depends(get_current_metadata_user), audit_repo: AuditRepository = Depends(get_audit_repository), -) -> List[AuditLogResponse]: - """ - 查询当前用户的审计日志 - - 普通用户只能查看自己的操作记录 - """ - logs = await audit_repo.get_logs( +) -> list[AuditLogResponse]: + return await audit_repo.get_logs( user_id=current_user.id, action=action, start_time=start_time, @@ -122,4 +127,3 @@ async def get_my_audit_logs( skip=skip, limit=limit, ) - return logs diff --git a/app/api/v1/endpoints/model_import.py b/app/api/v1/endpoints/model_import.py new file mode 100644 index 0000000..35fc9fc --- /dev/null +++ b/app/api/v1/endpoints/model_import.py @@ -0,0 +1,297 @@ +import json +from pathlib import Path +from tempfile import NamedTemporaryFile +from uuid import UUID, uuid4 + +from fastapi import ( + APIRouter, + Body, + Depends, + File, + Header, + HTTPException, + Path as ApiPath, + Query, + Request, + UploadFile, + status, +) + +from app.auth.metadata_dependencies import ( + get_current_metadata_admin, + get_metadata_repository, +) +from app.core.audit import AuditAction, log_audit_event +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository +from app.services.network_import import network_update +from app.services.tjnetwork import ChangeSet, import_inp, run_inp + +router = APIRouter() + +MAX_INP_FILE_BYTES = 50 * 1024 * 1024 +INP_SECTIONS = ("[TITLE]", "[JUNCTIONS]", "[RESERVOIRS]", "[TANKS]", "[PIPES]") + + +async def _get_active_project(project_id: UUID, metadata_repo: MetadataRepository): + project = await metadata_repo.get_project_by_id(project_id) + if project is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + if project.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Project is not active", + ) + return project + + +def _validate_inp_bytes(content: bytes, filename: str) -> str: + if Path(filename).suffix.lower() != ".inp": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only .inp model files are accepted", + ) + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="INP file is empty", + ) + if len(content) > MAX_INP_FILE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="INP file exceeds the 50 MiB limit", + ) + for encoding in ("utf-8-sig", "gb18030"): + try: + text = content.decode(encoding) + break + except UnicodeDecodeError: + continue + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="INP file encoding is not supported", + ) + upper_text = text.upper() + if not any(section in upper_text for section in INP_SECTIONS): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid INP file structure", + ) + return text + + +async def _read_upload(file: UploadFile) -> tuple[bytes, str]: + filename = Path(file.filename or "").name + content = await file.read(MAX_INP_FILE_BYTES + 1) + _validate_inp_bytes(content, filename) + return content, filename + + +async def _audit_model_change( + *, + request: Request, + current_user, + metadata_repo: MetadataRepository, + project_id: UUID, + action: str, +) -> None: + await log_audit_event( + action=AuditAction.UPDATE, + user_id=current_user.id, + project_id=project_id, + resource_type="hydraulic_model", + resource_id=action, + request_data={"operation": action}, + ip_address=request.client.host if request.client else None, + request_method=request.method, + request_path=request.url.path, + response_status=status.HTTP_200_OK, + session=metadata_repo.session, + ) + + +async def _run_uploaded_inp(content: bytes) -> str: + target_dir = Path("inp") + target_dir.mkdir(parents=True, exist_ok=True) + model_name = f"admin_model_{uuid4().hex}" + target_path = target_dir / f"{model_name}.inp" + target_path.write_bytes(content) + return run_inp(model_name) + + +async def _update_from_inp(content: bytes) -> None: + temp_path: Path | None = None + try: + with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file: + temp_file.write(content) + temp_path = Path(temp_file.name) + network_update(str(temp_path)) + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +async def _apply_model_update(content: bytes) -> None: + try: + await _update_from_inp(content) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"数据库操作失败: {exc}", + ) from exc + + +@router.post( + "/admin/projects/{project_id}/model/import", + summary="导入桌面端水力模型", +) +async def import_project_model( + request: Request, + project_id: UUID = ApiPath(...), + file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> dict: + project = await _get_active_project(project_id, metadata_repo) + content, filename = await _read_upload(file) + result = await _run_uploaded_inp(content) + await _audit_model_change( + request=request, + current_user=current_user, + metadata_repo=metadata_repo, + project_id=project.id, + action="import", + ) + return {"project_id": str(project.id), "filename": filename, "result": result} + + +@router.post( + "/admin/projects/{project_id}/model/update", + summary="更新桌面端水力模型", +) +async def update_project_model( + request: Request, + project_id: UUID = ApiPath(...), + file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> dict: + project = await _get_active_project(project_id, metadata_repo) + content, filename = await _read_upload(file) + await _apply_model_update(content) + await _audit_model_change( + request=request, + current_user=current_user, + metadata_repo=metadata_repo, + project_id=project.id, + action="update", + ) + return {"project_id": str(project.id), "filename": filename, "updated": True} + + +@router.post("/importinp/", deprecated=True) +async def legacy_import_inp( + request: Request, + network: str = Query(...), + x_project_id: UUID = Header(..., alias="X-Project-Id"), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +): + project = await _get_active_project(x_project_id, metadata_repo) + if network != project.code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Project scope denied", + ) + payload = await request.json() + inp_text = payload.get("inp") if isinstance(payload, dict) else None + if not isinstance(inp_text, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing INP content", + ) + _validate_inp_bytes(inp_text.encode("utf-8"), "model.inp") + result = import_inp(network, ChangeSet({"inp": inp_text})) + await _audit_model_change( + request=request, + current_user=current_user, + metadata_repo=metadata_repo, + project_id=project.id, + action="import", + ) + return result + + +@router.post("/uploadinp/", deprecated=True) +async def legacy_upload_inp( + request: Request, + content: bytes = Body(...), + name: str = Query(...), + x_project_id: UUID = Header(..., alias="X-Project-Id"), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> bool: + project = await _get_active_project(x_project_id, metadata_repo) + safe_name = Path(name).name + if safe_name != name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid INP file name", + ) + _validate_inp_bytes(content, safe_name) + target_dir = Path("data") + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / safe_name).write_bytes(content) + await _audit_model_change( + request=request, + current_user=current_user, + metadata_repo=metadata_repo, + project_id=project.id, + action="upload", + ) + return True + + +@router.post("/network_project/", deprecated=True) +async def legacy_network_project( + request: Request, + file: UploadFile = File(...), + x_project_id: UUID = Header(..., alias="X-Project-Id"), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +): + project = await _get_active_project(x_project_id, metadata_repo) + content, _ = await _read_upload(file) + result = await _run_uploaded_inp(content) + await _audit_model_change( + request=request, + current_user=current_user, + metadata_repo=metadata_repo, + project_id=project.id, + action="import", + ) + return result + + +@router.post("/network_update/", deprecated=True) +async def legacy_network_update( + request: Request, + file: UploadFile = File(...), + x_project_id: UUID = Header(..., alias="X-Project-Id"), + current_user=Depends(get_current_metadata_admin), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> str: + project = await _get_active_project(x_project_id, metadata_repo) + content, _ = await _read_upload(file) + await _apply_model_update(content) + await _audit_model_change( + request=request, + current_user=current_user, + metadata_repo=metadata_repo, + project_id=project.id, + action="update", + ) + return json.dumps({"message": "管网更新成功"}) diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index c4424ba..b0afe84 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -1,9 +1,13 @@ import json -from fastapi import APIRouter, Request, HTTPException, Query, Path, Body, Depends +from fastapi import APIRouter, Request, HTTPException, Query, Path, Depends from fastapi.responses import PlainTextResponse from typing import Any, Dict, List from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository from app.auth.project_dependencies import get_metadata_repository +from app.auth.permissions import ( + ENVIRONMENT_MANAGE, + require_permission, +) from app.domain.schemas.metadata import ProjectMetaResponse import app.services.project_info as project_info from app.infra.db.postgresql.database import get_database_instance as get_pg_db @@ -18,7 +22,6 @@ from app.services.tjnetwork import ( open_project, close_project, copy_project, - import_inp, export_inp, read_inp, dump_inp, @@ -89,7 +92,8 @@ async def have_project_endpoint( @router.post("/createproject/", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。") async def create_project_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)") + network: str = Query(..., description="管网名称(或数据库名称)"), + _=Depends(require_permission(ENVIRONMENT_MANAGE)), ): """ 创建新项目 @@ -101,7 +105,8 @@ async def create_project_endpoint( @router.post("/deleteproject/", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。") async def delete_project_endpoint( - network: str = Query(..., description="管网名称(或数据库名称)") + network: str = Query(..., description="管网名称(或数据库名称)"), + _=Depends(require_permission(ENVIRONMENT_MANAGE)), ): """ 删除项目 @@ -172,7 +177,8 @@ async def close_project_endpoint( @router.post("/copyproject/", summary="复制项目", description="将现有项目复制为新项目。") async def copy_project_endpoint( source: str = Query(..., description="管网名称(或数据库名称)"), - target: str = Query(..., description="管网名称(或数据库名称)") + target: str = Query(..., description="管网名称(或数据库名称)"), + _=Depends(require_permission(ENVIRONMENT_MANAGE)), ): """ 复制项目 @@ -183,24 +189,6 @@ async def copy_project_endpoint( copy_project(source, target) return True -@router.post("/importinp/", summary="导入 INP 文件内容", description="将 INP 格式的文本内容导入到指定项目中。") -async def import_inp_endpoint( - req: Request, - network: str = Query(..., description="管网名称(或数据库名称)") -): - """ - 导入 INP 文件内容 - - - **network**: 管网名称(或数据库名称) - - **req**: 请求体,需包含 `{"inp": "..."}` 结构 - """ - jo_root = await req.json() - inp_text = jo_root["inp"] - ps = {"inp": inp_text} - ret = import_inp(network, ChangeSet(ps)) - print(ret) - return ret - @router.get("/exportinp/", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。") async def export_inp_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), @@ -331,26 +319,6 @@ def unlock_project_endpoint( return False -# inp file operations -@router.post("/uploadinp/", status_code=status.HTTP_200_OK, summary="上传 INP 文件", description="上传 INP 文件到服务器数据目录。") -async def fastapi_upload_inp( - afile: bytes = Body(..., description="文件二进制内容"), - name: str = Query(..., description="保存的文件名") -): - """ - 上传 INP 文件 - - - **afile**: 文件内容 - - **name**: 文件名 - """ - if not os.path.exists(inpDir): - os.makedirs(inpDir, exist_ok=True) - - filePath = inpDir + str(name) - with open(filePath, "wb") as f: - f.write(afile) - return True - @router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。") async def fastapi_download_inp( name: str = Query(..., description="文件名"), @@ -502,26 +470,6 @@ def unlock_project_endpoint( return False -# inp file operations -@router.post("/uploadinp/", status_code=status.HTTP_200_OK, summary="上传 INP 文件", description="上传 INP 文件到服务器数据目录。") -async def fastapi_upload_inp( - afile: bytes = Body(..., description="文件二进制内容"), - name: str = Query(..., description="保存的文件名") -): - """ - 上传 INP 文件 - - - **afile**: 文件内容 - - **name**: 文件名 - """ - if not os.path.exists(inpDir): - os.makedirs(inpDir, exist_ok=True) - - filePath = inpDir + str(name) - with open(filePath, "wb") as f: - f.write(afile) - return True - @router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。") async def fastapi_download_inp( name: str = Query(..., description="文件名"), diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 0f0afc3..6fd87c6 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -41,19 +41,14 @@ def _project_network(network: str, project_context: ProjectContext) -> str: return project_context.project_code -def _can_modify_project(project_context: ProjectContext, current_user: Any) -> bool: - return bool( - project_context.project_role in {"owner", "admin", "member"} - or getattr(current_user, "role", None) == "admin" - or getattr(current_user, "is_superuser", False) - ) +def _can_modify_project(project_context: ProjectContext) -> bool: + return project_context.project_role == "member" def _require_project_write( project_context: ProjectContext, - current_user: Any, ) -> None: - if not _can_modify_project(project_context, current_user): + if not _can_modify_project(project_context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="当前项目角色为只读,不能修改监测点方案", @@ -88,7 +83,7 @@ def _get_scheme_response( return { **scheme, "can_edit": ( - _can_modify_project(project_context, current_user) + _can_modify_project(project_context) and can_edit_sensor_placement(current_user, scheme) ), } @@ -110,7 +105,7 @@ async def optimize_sensor_placement_scheme( current_user=Depends(get_current_metadata_user), ) -> dict[str, Any]: network = _project_network(payload.network, project_context) - _require_project_write(project_context, current_user) + _require_project_write(project_context) optimizer = ( pressure_sensor_placement_sensitivity if payload.method == "sensitivity" @@ -173,7 +168,7 @@ async def overwrite_sensor_placement_scheme( current_user=Depends(get_current_metadata_user), ) -> dict[str, Any]: network = _project_network(network, project_context) - _require_project_write(project_context, current_user) + _require_project_write(project_context) scheme = _get_scheme_response( network, scheme_id, diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 2295aa8..b4133ea 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -1,10 +1,8 @@ from typing import Any, List, Optional from datetime import datetime, timedelta import json -import os -import shutil import threading -from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Query, Path, Body +from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body from fastapi.responses import PlainTextResponse from app.auth.keycloak_dependencies import get_current_keycloak_username import app.services.simulation as simulation @@ -29,7 +27,6 @@ from app.algorithms.sensor import ( pressure_sensor_placement_kmeans, ) -from app.services.network_import import network_update from app.services.simulation_ops import ( project_management, scheduling_simulation, @@ -282,7 +279,8 @@ async def valve_isolation_endpoint( 返回隔离方案,包括: - must_close_valves: 必须关闭的阀门列表 - optional_valves: 可选关闭的阀门列表 - - affected_nodes: 受影响的节点列表 + - affected_nodes: 受影响的节点列表;不可隔离时为空列表 + - affected_node_count: 受影响的节点总数 - isolatable: 是否可以有效隔离 """ # result = { @@ -549,46 +547,6 @@ async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body ) -@router.post("/network_project/", summary="导入网络项目", description="通过上传INP格式的管网文件导入新的网络项目。系统将自动处理文件并执行模拟。") -async def fastapi_network_project(file: UploadFile = File(..., description="INP格式的管网文件")) -> str: - """ - 导入网络项目 - - - **file**: 上传的INP格式管网文件 - - 系统将上传的文件保存到inp文件夹并执行模拟。 - """ - temp_file_dir = "./inp/" - if not os.path.exists(temp_file_dir): - os.mkdir(temp_file_dir) - temp_file_name = f'network_project_{datetime.now().strftime("%Y%m%d")}' - temp_file_path = f"{temp_file_dir}{temp_file_name}.inp" - with open(temp_file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - return run_inp(temp_file_name) - - -@router.post("/network_update/", summary="管网更新(高级)", description="通过上传更新文件对管网进行高级的更新操作。系统将处理更新文件并应用到数据库。") -async def fastapi_network_update(file: UploadFile = File(..., description="包含管网更新信息的文件")) -> str: - """ - 管网更新(高级版本) - - - **file**: 包含管网更新信息的文件 - - 系统将处理上传的文件并应用管网更新。 - """ - default_folder = "./" - temp_file_name = f'network_update_{datetime.now().strftime("%Y%m%d")}' - temp_file_path = os.path.join(default_folder, temp_file_name) - try: - with open(temp_file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - network_update(temp_file_path) - return json.dumps({"message": "管网更新成功"}) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"数据库操作失败: {exc}") - - # @router.get("/pumpfailure/") # async def pump_failure_endpoint(network: str, pump_id: str, time: str): # return pump_failure(network, pump_id, time) diff --git a/app/api/v1/endpoints/snapshots.py b/app/api/v1/endpoints/snapshots.py index 210f58e..e3690be 100644 --- a/app/api/v1/endpoints/snapshots.py +++ b/app/api/v1/endpoints/snapshots.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Request, Query +from fastapi import APIRouter, Depends, Request, Query +from app.auth.permissions import SIMULATION_RUN, require_permission from app.services.tjnetwork import ( ChangeSet, get_current_operation, @@ -149,7 +150,11 @@ async def pick_operation_endpoint( return pick_operation(network, operation, discard) @router.get("/syncwithserver/", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None) -async def sync_with_server_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="目标操作ID")) -> ChangeSet: +async def sync_with_server_endpoint( + network: str = Query(..., description="管网名称(或数据库名称)"), + operation: int = Query(..., description="目标操作ID"), + _=Depends(require_permission(SIMULATION_RUN)), +) -> ChangeSet: """ 与服务器同步 diff --git a/app/api/v1/router.py b/app/api/v1/router.py index df73930..887be17 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,120 +1,240 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends + from app.api.v1.endpoints import ( + access, admin_metadata, agent_auth, - project, - simulation, - scada, - sensor_placement, - extension, - snapshots, - # data_query, - users, - schemes, - misc, - risk, - cache, - leakage, + audit, burst_detection, burst_location, - audit, # 新增:审计日志 - meta, - web_search, + cache, + extension, geocoding, -) -from app.api.v1.endpoints.network import ( - general, - junctions, - reservoirs, - tanks, - pipes, - pumps, - valves, - tags, - demands, - geometry, - regions, + leakage, + meta, + misc, + model_import, + project, + project_data, + risk, + scada, + schemes, + sensor_placement, + simulation, + snapshots, + users, + web_search, ) from app.api.v1.endpoints.components import ( - curves, - patterns, controls, + curves, options, + patterns, quality, visuals, ) - -from app.api.v1.endpoints import project_data +from app.api.v1.endpoints.network import ( + demands, + general, + geometry, + junctions, + pipes, + pumps, + regions, + reservoirs, + tags, + tanks, + valves, +) from app.api.v1.endpoints.timeseries import ( - realtime as ts_realtime, - scheme as ts_scheme, - scada as ts_scada, composite as ts_composite, + realtime as ts_realtime, + scada as ts_scada, + scheme as ts_scheme, +) +from app.auth.permissions import ( + BURST_RUN, + OPTIMIZATION_RUN, + RISK_RUN, + SCADA_CLEAN, + SCADA_VIEW, + SIMULATION_RUN, + SIMULATION_VIEW, + WEBGIS_EDIT, + WEBGIS_VIEW, + require_method_permission, + require_permission, ) api_router = APIRouter() -# Core Services +webgis_access = Depends( + require_method_permission( + read_permission=WEBGIS_VIEW, + write_permission=WEBGIS_EDIT, + ) +) +scada_access = Depends( + require_method_permission( + read_permission=SCADA_VIEW, + write_permission=SCADA_CLEAN, + ) +) +simulation_access = Depends( + require_method_permission( + read_permission=SIMULATION_VIEW, + write_permission=SIMULATION_RUN, + ) +) + +webgis_view_access = Depends(require_permission(WEBGIS_VIEW)) +simulation_run_access = Depends(require_permission(SIMULATION_RUN)) +burst_run_access = Depends(require_permission(BURST_RUN)) +risk_run_access = Depends(require_permission(RISK_RUN)) +optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN)) + +# Core services +api_router.include_router(access.router, tags=["Access Control"]) api_router.include_router(agent_auth.router, tags=["Agent Auth"]) api_router.include_router( - admin_metadata.router, prefix="/admin", tags=["Metadata Admin"] + admin_metadata.router, + prefix="/admin", + tags=["Metadata Admin"], ) -api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增 +api_router.include_router(model_import.router, tags=["Model Administration"]) +api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) api_router.include_router(meta.router, tags=["Metadata"]) -api_router.include_router(project.router, tags=["Project"]) - -# Network Elements (Node/Link Types) -api_router.include_router(general.router, tags=["Network General"]) -api_router.include_router(junctions.router, tags=["Junctions"]) -api_router.include_router(reservoirs.router, tags=["Reservoirs"]) -api_router.include_router(tanks.router, tags=["Tanks"]) -api_router.include_router(pipes.router, tags=["Pipes"]) -api_router.include_router(pumps.router, tags=["Pumps"]) -api_router.include_router(valves.router, tags=["Valves"]) - -# Network Features -api_router.include_router(tags.router, tags=["Tags"]) -api_router.include_router(demands.router, tags=["Demands"]) -api_router.include_router(geometry.router, tags=["Geometry & Coordinates"]) -api_router.include_router(regions.router, tags=["Regions & DMAs"]) - -# Components & Controls -api_router.include_router(curves.router, tags=["Curves"]) -api_router.include_router(patterns.router, tags=["Patterns"]) -api_router.include_router(controls.router, tags=["Controls & Rules"]) -api_router.include_router(options.router, tags=["Options"]) -api_router.include_router(quality.router, tags=["Quality"]) -api_router.include_router(visuals.router, tags=["Visuals"]) - -# Simulation & Data -api_router.include_router(simulation.router, tags=["Simulation Control"]) -# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"]) -api_router.include_router(scada.router) -api_router.include_router(sensor_placement.router, tags=["Sensor Placement"]) -api_router.include_router(snapshots.router, tags=["Snapshots"]) -api_router.include_router(users.router, tags=["Users"]) -api_router.include_router(schemes.router, tags=["Schemes"]) -api_router.include_router(misc.router, tags=["Misc"]) -api_router.include_router(risk.router, tags=["Risk"]) -api_router.include_router(cache.router, tags=["Cache"]) -api_router.include_router(web_search.router, tags=["Web Search"]) -api_router.include_router(geocoding.router, tags=["Geocoding"]) -api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"]) api_router.include_router( - burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"] -) -api_router.include_router( - burst_location.router, prefix="/burst-location", tags=["Burst Location"] + project.router, + tags=["Project"], + dependencies=[webgis_access], ) -# TimescaleDB Data Access -api_router.include_router(ts_realtime.router, tags=["TimescaleDB - Realtime"]) -api_router.include_router(ts_scheme.router, tags=["TimescaleDB - Scheme"]) -api_router.include_router(ts_scada.router, tags=["TimescaleDB - SCADA"]) -api_router.include_router(ts_composite.router, tags=["TimescaleDB - Composite"]) +# WebGIS data +for endpoint_router, tag in ( + (general.router, "Network General"), + (junctions.router, "Junctions"), + (reservoirs.router, "Reservoirs"), + (tanks.router, "Tanks"), + (pipes.router, "Pipes"), + (pumps.router, "Pumps"), + (valves.router, "Valves"), + (tags.router, "Tags"), + (demands.router, "Demands"), + (geometry.router, "Geometry & Coordinates"), + (regions.router, "Regions & DMAs"), + (curves.router, "Curves"), + (patterns.router, "Patterns"), + (controls.router, "Controls & Rules"), + (options.router, "Options"), + (quality.router, "Quality"), + (visuals.router, "Visuals"), +): + api_router.include_router( + endpoint_router, + tags=[tag], + dependencies=[webgis_access], + ) -# Project Data (PostgreSQL) -api_router.include_router(project_data.router, tags=["Project Data"]) +# Simulation and analysis +api_router.include_router( + simulation.router, + tags=["Simulation Control"], + dependencies=[simulation_run_access], +) +api_router.include_router(scada.router, dependencies=[scada_access]) +api_router.include_router( + sensor_placement.router, + tags=["Sensor Placement"], + dependencies=[optimization_run_access], +) +api_router.include_router( + snapshots.router, + tags=["Snapshots"], + dependencies=[simulation_access], +) +api_router.include_router( + users.router, + tags=["Users"], + dependencies=[webgis_view_access], +) +api_router.include_router( + schemes.router, + tags=["Schemes"], + dependencies=[simulation_access], +) +api_router.include_router( + misc.router, + tags=["Misc"], + dependencies=[webgis_view_access], +) +api_router.include_router( + risk.router, + tags=["Risk"], + dependencies=[risk_run_access], +) +api_router.include_router( + cache.router, + tags=["Cache"], + dependencies=[simulation_run_access], +) +api_router.include_router( + web_search.router, + tags=["Web Search"], + dependencies=[webgis_view_access], +) +api_router.include_router( + geocoding.router, + tags=["Geocoding"], + dependencies=[webgis_view_access], +) +api_router.include_router( + leakage.router, + prefix="/leakage", + tags=["Leakage"], + dependencies=[burst_run_access], +) +api_router.include_router( + burst_detection.router, + prefix="/burst-detection", + tags=["Burst Detection"], + dependencies=[burst_run_access], +) +api_router.include_router( + burst_location.router, + prefix="/burst-location", + tags=["Burst Location"], + dependencies=[burst_run_access], +) -# Extension -api_router.include_router(extension.router, tags=["Extension"]) +# TimescaleDB data +for endpoint_router, tag in ( + (ts_realtime.router, "TimescaleDB - Realtime"), + (ts_scheme.router, "TimescaleDB - Scheme"), +): + api_router.include_router( + endpoint_router, + tags=[tag], + dependencies=[simulation_access], + ) + +for endpoint_router, tag in ( + (ts_scada.router, "TimescaleDB - SCADA"), + (ts_composite.router, "TimescaleDB - Composite"), +): + api_router.include_router( + endpoint_router, + tags=[tag], + dependencies=[scada_access], + ) + +api_router.include_router( + project_data.router, + tags=["Project Data"], + dependencies=[webgis_view_access], +) +api_router.include_router( + extension.router, + tags=["Extension"], + dependencies=[webgis_access], +) diff --git a/app/auth/permissions.py b/app/auth/permissions.py new file mode 100644 index 0000000..380ea5e --- /dev/null +++ b/app/auth/permissions.py @@ -0,0 +1,162 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from fastapi import Depends, HTTPException, Request, status + +from app.auth.project_dependencies import ProjectContext, get_project_context + +WEBGIS_VIEW = "webgis.view" +WEBGIS_EDIT = "webgis.edit" +SCADA_VIEW = "scada.view" +SCADA_CLEAN = "scada.clean" +SIMULATION_VIEW = "simulation.view" +SIMULATION_RUN = "simulation.run" +BURST_VIEW = "burst.view" +BURST_RUN = "burst.run" +RISK_VIEW = "risk.view" +RISK_RUN = "risk.run" +OPTIMIZATION_VIEW = "optimization.view" +OPTIMIZATION_RUN = "optimization.run" +MODEL_IMPORT = "model.import" +AUDIT_VIEW = "audit.view" +ENVIRONMENT_MANAGE = "environment.manage" +MEMBERSHIP_MANAGE = "membership.manage" + +PROJECT_MEMBER_PERMISSIONS = frozenset( + { + WEBGIS_VIEW, + WEBGIS_EDIT, + SCADA_VIEW, + SCADA_CLEAN, + SIMULATION_VIEW, + SIMULATION_RUN, + BURST_VIEW, + BURST_RUN, + RISK_VIEW, + RISK_RUN, + OPTIMIZATION_VIEW, + OPTIMIZATION_RUN, + } +) + +PROJECT_VIEWER_PERMISSIONS = frozenset( + { + WEBGIS_VIEW, + SCADA_VIEW, + SIMULATION_VIEW, + } +) + +SYSTEM_ADMIN_PERMISSIONS = frozenset( + { + MODEL_IMPORT, + AUDIT_VIEW, + ENVIRONMENT_MANAGE, + MEMBERSHIP_MANAGE, + } +) + +PROJECT_ROLE_PERMISSIONS: dict[str, frozenset[str]] = { + "member": PROJECT_MEMBER_PERMISSIONS, + "viewer": PROJECT_VIEWER_PERMISSIONS, +} + + +def resolve_permissions( + *, + project_role: str | None, + system_role: str, + is_superuser: bool, +) -> frozenset[str]: + permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset())) + if is_superuser or system_role == "admin": + permissions.update(SYSTEM_ADMIN_PERMISSIONS) + return frozenset(permissions) + + +def permissions_for_context(ctx: ProjectContext) -> frozenset[str]: + return resolve_permissions( + project_role=ctx.project_role, + system_role=ctx.system_role, + is_superuser=ctx.is_superuser, + ) + + +def _permission_denied(permission: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "permission_denied", + "permission": permission, + }, + ) + + +async def _enforce_project_scope(request: Request, ctx: ProjectContext) -> None: + requested_network = ( + request.path_params.get("network") + or request.query_params.get("network") + ) + if not requested_network: + content_type = request.headers.get("content-type", "") + if content_type.startswith("application/json"): + try: + payload = await request.json() + except (ValueError, RuntimeError): + payload = None + if isinstance(payload, dict): + requested_network = payload.get("network") + + if requested_network and str(requested_network) != ctx.project_code: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "project_scope_denied", + "project_id": str(ctx.project_id), + }, + ) + + +def require_permission( + permission: str, +) -> Callable[..., Awaitable[ProjectContext]]: + async def dependency( + request: Request, + ctx: ProjectContext = Depends(get_project_context), + ) -> ProjectContext: + if permission not in permissions_for_context(ctx): + raise _permission_denied(permission) + await _enforce_project_scope(request, ctx) + return ctx + + return dependency + + +def require_method_permission( + *, + read_permission: str, + write_permission: str, +) -> Callable[..., Awaitable[ProjectContext]]: + async def dependency( + request: Request, + ctx: ProjectContext = Depends(get_project_context), + ) -> ProjectContext: + permission = ( + read_permission + if request.method.upper() in {"GET", "HEAD", "OPTIONS"} + else write_permission + ) + if permission not in permissions_for_context(ctx): + raise _permission_denied(permission) + await _enforce_project_scope(request, ctx) + return ctx + + return dependency + + +def has_permission(user: Any, project_role: str | None, permission: str) -> bool: + return permission in resolve_permissions( + project_role=project_role, + system_role=str(getattr(user, "role", "user")), + is_superuser=bool(getattr(user, "is_superuser", False)), + ) diff --git a/app/auth/project_dependencies.py b/app/auth/project_dependencies.py index 0bcaa43..362a186 100644 --- a/app/auth/project_dependencies.py +++ b/app/auth/project_dependencies.py @@ -1,18 +1,21 @@ +import logging +from collections.abc import AsyncGenerator from dataclasses import dataclass -from typing import AsyncGenerator from uuid import UUID -import logging from fastapi import Depends, Header, HTTPException, status from psycopg import AsyncConnection from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.keycloak_dependencies import get_current_keycloak_sub +from app.auth.metadata_dependencies import get_current_metadata_user from app.core.config import settings from app.infra.db.dynamic_manager import project_connection_manager from app.infra.db.metadb.database import get_metadata_session -from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository +from app.infra.db.metadb.repositories.metadata_repository import ( + MetadataRepository, + ProjectDbRouting, +) DB_ROLE_BIZ_DATA = "biz_data" DB_ROLE_IOT_DATA = "iot_data" @@ -28,6 +31,8 @@ class ProjectContext: project_code: str user_id: UUID project_role: str + system_role: str = "user" + is_superuser: bool = False async def get_metadata_repository( @@ -36,10 +41,10 @@ async def get_metadata_repository( return MetadataRepository(session) -async def get_project_context( - x_project_id: str = Header(..., alias="X-Project-Id"), - keycloak_sub: UUID = Depends(get_current_keycloak_sub), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), +async def resolve_project_context( + x_project_id: str, + current_user, + metadata_repo: MetadataRepository, ) -> ProjectContext: try: project_uuid = UUID(x_project_id) @@ -59,17 +64,9 @@ async def get_project_context( status_code=status.HTTP_403_FORBIDDEN, detail="Project is not active" ) - user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub) - if not user: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="User not registered" - ) - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" - ) - - membership_role = await metadata_repo.get_membership_role(project_uuid, user.id) + membership_role = await metadata_repo.get_membership_role( + project_uuid, current_user.id + ) if not membership_role: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No access to project" @@ -87,38 +84,65 @@ async def get_project_context( return ProjectContext( project_id=project.id, project_code=project.code, - user_id=user.id, + user_id=current_user.id, project_role=membership_role, + system_role=current_user.role, + is_superuser=current_user.is_superuser, ) +async def get_project_context( + x_project_id: str = Header(..., alias="X-Project-Id"), + current_user=Depends(get_current_metadata_user), + metadata_repo: MetadataRepository = Depends(get_metadata_repository), +) -> ProjectContext: + return await resolve_project_context(x_project_id, current_user, metadata_repo) + + +async def _get_project_routing( + metadata_repo: MetadataRepository, + project_id: UUID, + db_role: str, + expected_db_type: str, + database_label: str, +) -> ProjectDbRouting: + try: + routing = await metadata_repo.get_project_db_routing(project_id, db_role) + except ValueError as exc: + logger.error( + "Invalid project %s routing DSN configuration", + database_label, + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Project {database_label} routing DSN is invalid: {exc}", + ) from exc + + if not routing: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Project {database_label} not configured", + ) + if routing.db_type != expected_db_type: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Project {database_label} type mismatch", + ) + return routing + + async def get_project_pg_session( ctx: ProjectContext = Depends(get_project_context), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> AsyncGenerator[AsyncSession, None]: - try: - routing = await metadata_repo.get_project_db_routing( - ctx.project_id, DB_ROLE_BIZ_DATA - ) - except ValueError as exc: - logger.error( - "Invalid project PostgreSQL routing DSN configuration", - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Project PostgreSQL routing DSN is invalid: {exc}", - ) from exc - if not routing: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Project PostgreSQL not configured", - ) - if routing.db_type != DB_TYPE_POSTGRES: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Project PostgreSQL type mismatch", - ) + routing = await _get_project_routing( + metadata_repo, + ctx.project_id, + DB_ROLE_BIZ_DATA, + DB_TYPE_POSTGRES, + "PostgreSQL", + ) pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE @@ -137,29 +161,13 @@ async def get_project_pg_connection( ctx: ProjectContext = Depends(get_project_context), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> AsyncGenerator[AsyncConnection, None]: - try: - routing = await metadata_repo.get_project_db_routing( - ctx.project_id, DB_ROLE_BIZ_DATA - ) - except ValueError as exc: - logger.error( - "Invalid project PostgreSQL routing DSN configuration", - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Project PostgreSQL routing DSN is invalid: {exc}", - ) from exc - if not routing: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Project PostgreSQL not configured", - ) - if routing.db_type != DB_TYPE_POSTGRES: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Project PostgreSQL type mismatch", - ) + routing = await _get_project_routing( + metadata_repo, + ctx.project_id, + DB_ROLE_BIZ_DATA, + DB_TYPE_POSTGRES, + "PostgreSQL", + ) pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE @@ -178,29 +186,13 @@ async def get_project_timescale_connection( ctx: ProjectContext = Depends(get_project_context), metadata_repo: MetadataRepository = Depends(get_metadata_repository), ) -> AsyncGenerator[AsyncConnection, None]: - try: - routing = await metadata_repo.get_project_db_routing( - ctx.project_id, DB_ROLE_IOT_DATA - ) - except ValueError as exc: - logger.error( - "Invalid project TimescaleDB routing DSN configuration", - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Project TimescaleDB routing DSN is invalid: {exc}", - ) from exc - if not routing: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Project TimescaleDB not configured", - ) - if routing.db_type != DB_TYPE_TIMESCALE: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Project TimescaleDB type mismatch", - ) + routing = await _get_project_routing( + metadata_repo, + ctx.project_id, + DB_ROLE_IOT_DATA, + DB_TYPE_TIMESCALE, + "TimescaleDB", + ) pool_min_size = routing.pool_min_size or settings.PROJECT_TS_POOL_MIN_SIZE pool_max_size = routing.pool_max_size or settings.PROJECT_TS_POOL_MAX_SIZE diff --git a/app/domain/schemas/access.py b/app/domain/schemas/access.py new file mode 100644 index 0000000..c34f22c --- /dev/null +++ b/app/domain/schemas/access.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class AccessContextResponse(BaseModel): + user_id: UUID + username: str + system_role: str + is_system_admin: bool + project_id: UUID | None = None + project_role: str | None = None + permissions: list[str] diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py index 98d2d57..6ebfc25 100644 --- a/app/domain/schemas/admin_metadata.py +++ b/app/domain/schemas/admin_metadata.py @@ -5,8 +5,8 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator -BusinessRole = Literal["admin", "user", "operator", "viewer"] -ProjectRole = Literal["owner", "admin", "member", "viewer"] +BusinessRole = Literal["admin", "user"] +ProjectRole = Literal["member", "viewer"] ProjectStatus = Literal["active", "inactive", "archived"] ProjectDbRole = Literal["biz_data", "iot_data"] diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index f35ef71..b620ba5 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -211,6 +211,7 @@ class MetadataRepository: gs_workspace: str, map_extent: dict | None, status: str, + creator_user_id: UUID | None = None, ) -> models.Project: project = models.Project( id=uuid4(), @@ -224,6 +225,15 @@ class MetadataRepository: updated_at=_utcnow(), ) self.session.add(project) + if creator_user_id is not None: + self.session.add( + models.UserProjectMembership( + id=uuid4(), + user_id=creator_user_id, + project_id=project.id, + project_role="member", + ) + ) await self.session.commit() await self.session.refresh(project) return project @@ -483,7 +493,7 @@ class MetadataRepository: gs_workspace=project.gs_workspace, map_extent=project.map_extent, status=project.status, - project_role="owner", + project_role="member", ) for project in result.scalars().all() ] diff --git a/resources/sql/004_metadata_auth_management.sql b/resources/sql/004_metadata_auth_management.sql index e3d682b..3e58a88 100644 --- a/resources/sql/004_metadata_auth_management.sql +++ b/resources/sql/004_metadata_auth_management.sql @@ -42,6 +42,12 @@ ALTER TABLE users ALTER TABLE users ALTER COLUMN role SET DEFAULT 'user'; +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users + ADD CONSTRAINT users_role_check + CHECK (role IN ('admin', 'user')); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id); CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active); @@ -52,7 +58,9 @@ CREATE TABLE IF NOT EXISTS user_project_membership ( project_id UUID NOT NULL, project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL, CONSTRAINT user_project_membership_role_check - CHECK (project_role IN ('owner', 'admin', 'member', 'viewer')), + CHECK ( + project_role IN ('member', 'viewer') + ), CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id) ); diff --git a/resources/sql/006_metadata_rbac_roles.sql b/resources/sql/006_metadata_rbac_roles.sql new file mode 100644 index 0000000..947e256 --- /dev/null +++ b/resources/sql/006_metadata_rbac_roles.sql @@ -0,0 +1,32 @@ +-- Normalize existing roles to the Web authorization model. +-- This migration is intentionally re-runnable. + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_role_check; + +UPDATE users +SET role = 'user' +WHERE role NOT IN ('admin', 'user'); + +ALTER TABLE users + ADD CONSTRAINT users_role_check + CHECK (role IN ('admin', 'user')); + +ALTER TABLE user_project_membership + DROP CONSTRAINT IF EXISTS user_project_membership_role_check; + +UPDATE user_project_membership +SET project_role = CASE + WHEN project_role IN ( + 'owner', + 'admin', + 'modeler', + 'dispatcher' + ) THEN 'member' + ELSE 'viewer' +END +WHERE project_role NOT IN ('member', 'viewer'); + +ALTER TABLE user_project_membership + ADD CONSTRAINT user_project_membership_role_check + CHECK (project_role IN ('member', 'viewer')); diff --git a/tests/api/test_access_endpoints.py b/tests/api/test_access_endpoints.py new file mode 100644 index 0000000..71c9604 --- /dev/null +++ b/tests/api/test_access_endpoints.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import access as access_endpoint +from app.auth.metadata_dependencies import ( + get_current_metadata_user, + get_metadata_repository, +) +from tests.conftest import build_test_app + + +def _user(**overrides): + data = { + "id": uuid4(), + "username": "alice", + "role": "user", + "is_superuser": False, + } + data.update(overrides) + return SimpleNamespace(**data) + + +def _build_client(user, repo) -> TestClient: + app = build_test_app(access_endpoint.router, "/api/v1") + app.dependency_overrides[get_current_metadata_user] = lambda: user + app.dependency_overrides[get_metadata_repository] = lambda: repo + return TestClient(app) + + +def test_access_context_returns_global_admin_permissions_without_project(): + user = _user(role="admin") + repo = SimpleNamespace() + client = _build_client(user, repo) + + response = client.get("/api/v1/access/context") + + assert response.status_code == 200 + payload = response.json() + assert payload["is_system_admin"] is True + assert payload["project_id"] is None + assert "environment.manage" in payload["permissions"] + assert "webgis.view" not in payload["permissions"] + + +def test_access_context_returns_project_member_permissions(): + project_id = uuid4() + user = _user() + + async def get_project_by_id(value): + assert value == project_id + return SimpleNamespace(id=project_id, code="demo", status="active") + + async def get_membership_role(value, user_id): + assert value == project_id + assert user_id == user.id + return "member" + + repo = SimpleNamespace( + get_project_by_id=get_project_by_id, + get_membership_role=get_membership_role, + ) + client = _build_client(user, repo) + + response = client.get( + "/api/v1/access/context", + headers={"X-Project-Id": str(project_id)}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["project_id"] == str(project_id) + assert payload["project_role"] == "member" + assert "scada.clean" in payload["permissions"] + assert "optimization.run" in payload["permissions"] + assert "model.import" not in payload["permissions"] diff --git a/tests/api/test_admin_metadata_endpoints.py b/tests/api/test_admin_metadata_endpoints.py index 579ede4..70161dc 100644 --- a/tests/api/test_admin_metadata_endpoints.py +++ b/tests/api/test_admin_metadata_endpoints.py @@ -138,7 +138,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch): keycloak_id=users[1].keycloak_id, username="bob", email="bob@example.com", - role="viewer", + role="user", is_active=True, ), ] @@ -156,7 +156,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch): @pytest.mark.anyio async def test_update_metadata_user_updates_role_and_active_status(monkeypatch): user_id = uuid4() - updated = _user(id=user_id, role="operator", is_active=False) + updated = _user(id=user_id, role="user", is_active=False) repo = SimpleNamespace( session=object(), update_user_admin=AsyncMock(return_value=updated), @@ -165,7 +165,7 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch): response = await admin_metadata.update_metadata_user( MetadataUserUpdateRequest( - role="operator", + role="user", is_active=False, ), user_id=user_id, @@ -175,9 +175,9 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch): repo.update_user_admin.assert_awaited_once_with( user_id, - updates={"role": "operator", "is_active": False}, + updates={"role": "user", "is_active": False}, ) - assert response.role == "operator" + assert response.role == "user" admin_metadata.log_audit_event.assert_awaited_once() @@ -192,7 +192,7 @@ async def test_update_metadata_user_rejects_self_update(monkeypatch): with pytest.raises(HTTPException) as exc: await admin_metadata.update_metadata_user( - MetadataUserUpdateRequest(role="viewer"), + MetadataUserUpdateRequest(role="user"), user_id=current_user.id, current_user=current_user, metadata_repo=repo, @@ -221,6 +221,7 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch): create_project=AsyncMock(return_value=project), ) monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) + current_user = _user(role="admin", is_superuser=True) response = await admin_metadata.create_admin_project( AdminProjectCreateRequest( @@ -231,12 +232,16 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch): map_extent={"bbox": [1, 2, 3, 4]}, status="active", ), - current_user=_user(role="admin", is_superuser=True), + current_user=current_user, metadata_repo=repo, ) assert response.project_id == project.id repo.create_project.assert_awaited_once() + assert ( + repo.create_project.await_args.kwargs["creator_user_id"] + == current_user.id + ) admin_metadata.log_audit_event.assert_awaited_once() @@ -482,7 +487,7 @@ async def test_update_project_member_role_audits_change(monkeypatch): membership = _membership( user_id=user_id, project_id=project_id, - project_role="admin", + project_role="member", ) repo = SimpleNamespace( session=object(), @@ -492,16 +497,16 @@ async def test_update_project_member_role_audits_change(monkeypatch): monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock()) response = await admin_metadata.update_project_member( - ProjectMemberUpdateRequest(project_role="admin"), + ProjectMemberUpdateRequest(project_role="member"), project_id=project_id, user_id=user_id, current_user=_user(role="admin", is_superuser=True), metadata_repo=repo, ) - assert response.project_role == "admin" + assert response.project_role == "member" repo.update_project_member_role.assert_awaited_once_with( - project_id, user_id, "admin" + project_id, user_id, "member" ) admin_metadata.log_audit_event.assert_awaited_once() @@ -519,7 +524,7 @@ async def test_update_project_member_rejects_self_membership_change(monkeypatch) with pytest.raises(HTTPException) as exc: await admin_metadata.update_project_member( - ProjectMemberUpdateRequest(project_role="admin"), + ProjectMemberUpdateRequest(project_role="member"), project_id=project_id, user_id=current_user.id, current_user=current_user, diff --git a/tests/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py index 38fbdb2..214fd93 100644 --- a/tests/api/test_agent_auth_endpoints.py +++ b/tests/api/test_agent_auth_endpoints.py @@ -30,7 +30,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): project_id=project_id, project_code="fengyang", user_id=user_id, - project_role="editor", + project_role="member", ), current_user=SimpleNamespace( id=user_id, @@ -52,7 +52,21 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): "is_superuser": False, "project_id": str(project_id), "network": "fengyang", - "project_role": "editor", + "project_role": "member", + "permissions": [ + "burst.run", + "burst.view", + "optimization.run", + "optimization.view", + "risk.run", + "risk.view", + "scada.clean", + "scada.view", + "simulation.run", + "simulation.view", + "webgis.edit", + "webgis.view", + ], "token_expires_at": "2026-06-11T13:10:00+00:00", } diff --git a/tests/api/test_audit_endpoints.py b/tests/api/test_audit_endpoints.py index d043800..5a7c598 100644 --- a/tests/api/test_audit_endpoints.py +++ b/tests/api/test_audit_endpoints.py @@ -1,4 +1,5 @@ from unittest.mock import AsyncMock +from uuid import uuid4 from fastapi.testclient import TestClient @@ -10,7 +11,12 @@ from app.auth.metadata_dependencies import ( from tests.conftest import build_test_app, make_audit_log -def _build_client(repo, *, metadata_admin=None, metadata_user=None) -> TestClient: +def _build_client( + repo, + *, + metadata_admin=None, + metadata_user=None, +) -> TestClient: app = build_test_app(audit_endpoint.router, "/audit") app.dependency_overrides[audit_endpoint.get_audit_repository] = lambda: repo if metadata_admin is not None: diff --git a/tests/api/test_meta_endpoints.py b/tests/api/test_meta_endpoints.py index 899c49e..6934612 100644 --- a/tests/api/test_meta_endpoints.py +++ b/tests/api/test_meta_endpoints.py @@ -54,7 +54,7 @@ def test_meta_project_returns_map_extent(monkeypatch): app = build_test_app(module.router, "/api/v1") app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace( project_id=project_id, - project_role="editor", + project_role="member", ) app.dependency_overrides[module.get_metadata_repository] = lambda: repo client = TestClient(app) diff --git a/tests/api/test_model_import_endpoints.py b/tests/api/test_model_import_endpoints.py new file mode 100644 index 0000000..52639ae --- /dev/null +++ b/tests/api/test_model_import_endpoints.py @@ -0,0 +1,100 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import model_import +from app.auth.metadata_dependencies import ( + get_current_metadata_admin, + get_metadata_repository, +) +from tests.conftest import build_test_app + + +VALID_INP = b"[TITLE]\nDesktop model\n[JUNCTIONS]\n;ID Elev Demand\n" + + +def _client(*, admin=None, repo=None) -> TestClient: + app = build_test_app(model_import.router, "/api/v1") + if admin is not None: + app.dependency_overrides[get_current_metadata_admin] = lambda: admin + if repo is not None: + app.dependency_overrides[get_metadata_repository] = lambda: repo + return TestClient(app) + + +def test_system_admin_can_import_model_without_project_membership( + monkeypatch, +): + project_id = uuid4() + project = SimpleNamespace(id=project_id, code="demo", status="active") + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock(return_value=project), + ) + admin = SimpleNamespace(id=uuid4(), role="admin", is_superuser=False) + monkeypatch.setattr( + model_import, + "_run_uploaded_inp", + AsyncMock(return_value="imported"), + ) + monkeypatch.setattr(model_import, "log_audit_event", AsyncMock()) + client = _client(admin=admin, repo=repo) + + response = client.post( + f"/api/v1/admin/projects/{project_id}/model/import", + files={"file": ("desktop-model.inp", VALID_INP)}, + ) + + assert response.status_code == 200 + assert response.json()["project_id"] == str(project_id) + assert response.json()["result"] == "imported" + repo.get_project_by_id.assert_awaited_once_with(project_id) + model_import.log_audit_event.assert_awaited_once() + + +def test_non_admin_is_denied_model_import(): + def deny_admin(): + raise HTTPException(status_code=403, detail="Admin access required") + + app = build_test_app(model_import.router, "/api/v1") + app.dependency_overrides[get_current_metadata_admin] = deny_admin + client = TestClient(app) + + response = client.post( + f"/api/v1/admin/projects/{uuid4()}/model/import", + files={"file": ("desktop-model.inp", VALID_INP)}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin access required" + + +def test_model_import_rejects_non_inp_file(monkeypatch): + project_id = uuid4() + repo = SimpleNamespace( + session=object(), + get_project_by_id=AsyncMock( + return_value=SimpleNamespace( + id=project_id, + code="demo", + status="active", + ) + ), + ) + monkeypatch.setattr(model_import, "log_audit_event", AsyncMock()) + client = _client( + admin=SimpleNamespace(id=uuid4(), role="admin", is_superuser=False), + repo=repo, + ) + + response = client.post( + f"/api/v1/admin/projects/{project_id}/model/import", + files={"file": ("desktop-model.txt", VALID_INP)}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Only .inp model files are accepted" + model_import.log_audit_event.assert_not_awaited() diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index 34756da..a743693 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from io import BytesIO from types import SimpleNamespace +import pytest from fastapi.testclient import TestClient from tests.conftest import build_test_app, install_stub, load_module_from_path @@ -254,21 +255,25 @@ def test_optimize_rejects_viewer_project_role(monkeypatch): assert response.status_code == 403 -def test_project_owner_and_admin_can_optimize(monkeypatch): +@pytest.mark.parametrize( + "project_role", + ["owner", "admin", "modeler", "dispatcher", "auditor"], +) +def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role): module = _load_module(monkeypatch) - for project_role in ("owner", "admin"): - response = _client(module, project_role=project_role).post( - "/api/v1/sensor-placement-schemes/optimize", - json={ - "network": "tjwater", - "scheme_name": f"{project_role}方案", - "sensor_type": "pressure", - "method": "kmeans", - "sensor_count": 2, - "min_diameter": 300, - }, - ) - assert response.status_code == 200 + response = _client(module, project_role=project_role).post( + "/api/v1/sensor-placement-schemes/optimize", + json={ + "network": "tjwater", + "scheme_name": f"{project_role}方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 403 def test_optimize_maps_running_project_job_to_409(monkeypatch): diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index c5c53b3..cee6eeb 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -1,4 +1,3 @@ -from pathlib import Path from datetime import datetime, timezone from fastapi.testclient import TestClient @@ -199,26 +198,6 @@ def test_project_management_maps_named_arguments(monkeypatch): } -def test_network_update_surfaces_service_error(monkeypatch, tmp_path): - module = _load_simulation_module(monkeypatch) - monkeypatch.chdir(tmp_path) - - def boom(_path): - raise RuntimeError("write failed") - - monkeypatch.setattr(module, "network_update", boom) - client = TestClient(build_test_app(module.router, "/api/v1")) - - response = client.post( - "/api/v1/network_update/", - files={"file": ("update.txt", b"payload")}, - ) - - assert response.status_code == 500 - assert "数据库操作失败: write failed" in response.json()["detail"] - assert list(Path(tmp_path).glob("network_update_*")) - - def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch): module = _load_simulation_module(monkeypatch) captured_calls = [] diff --git a/tests/auth/test_permissions.py b/tests/auth/test_permissions.py new file mode 100644 index 0000000..13d96f0 --- /dev/null +++ b/tests/auth/test_permissions.py @@ -0,0 +1,159 @@ +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.auth.permissions import ( + AUDIT_VIEW, + ENVIRONMENT_MANAGE, + MODEL_IMPORT, + OPTIMIZATION_RUN, + SCADA_CLEAN, + SIMULATION_RUN, + SIMULATION_VIEW, + WEBGIS_EDIT, + WEBGIS_VIEW, + permissions_for_context, + require_method_permission, + require_permission, + resolve_permissions, +) +from app.auth.project_dependencies import ProjectContext + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def _context(project_role: str) -> ProjectContext: + return ProjectContext( + project_id=uuid4(), + project_code="demo", + user_id=uuid4(), + project_role=project_role, + ) + + +def test_project_role_permission_matrix(): + member = resolve_permissions( + project_role="member", + system_role="user", + is_superuser=False, + ) + viewer = resolve_permissions( + project_role="viewer", + system_role="user", + is_superuser=False, + ) + + assert WEBGIS_EDIT in member + assert SCADA_CLEAN in member + assert SIMULATION_RUN in member + assert OPTIMIZATION_RUN in member + assert MODEL_IMPORT not in member + assert WEBGIS_VIEW in viewer + assert SIMULATION_VIEW in viewer + assert WEBGIS_EDIT not in viewer + assert SCADA_CLEAN not in viewer + assert SIMULATION_RUN not in viewer + + +def test_system_admin_permissions_do_not_grant_project_business_access(): + permissions = resolve_permissions( + project_role=None, + system_role="admin", + is_superuser=False, + ) + + assert ENVIRONMENT_MANAGE in permissions + assert AUDIT_VIEW in permissions + assert MODEL_IMPORT in permissions + assert WEBGIS_VIEW not in permissions + + +@pytest.mark.anyio +async def test_permission_dependency_returns_context_when_allowed(): + ctx = _context("member") + dependency = require_permission(WEBGIS_EDIT) + + request = type( + "Request", + (), + { + "path_params": {}, + "query_params": {}, + "headers": {}, + }, + )() + + assert await dependency(request, ctx) is ctx + + +@pytest.mark.anyio +async def test_permission_dependency_returns_structured_403_when_denied(): + ctx = _context("viewer") + dependency = require_permission(WEBGIS_EDIT) + + with pytest.raises(HTTPException) as exc: + await dependency(None, ctx) + + assert exc.value.status_code == 403 + assert exc.value.detail == { + "code": "permission_denied", + "permission": WEBGIS_EDIT, + } + + +@pytest.mark.anyio +async def test_permission_dependency_rejects_cross_project_network(): + ctx = _context("member") + dependency = require_permission(WEBGIS_VIEW) + request = type( + "Request", + (), + { + "path_params": {}, + "query_params": {"network": "other-project"}, + "headers": {}, + }, + )() + + with pytest.raises(HTTPException) as exc: + await dependency(request, ctx) + + assert exc.value.status_code == 403 + assert exc.value.detail["code"] == "project_scope_denied" + + +def test_member_keeps_full_web_business_access(): + permissions = permissions_for_context(_context("member")) + + assert SCADA_CLEAN in permissions + assert SIMULATION_RUN in permissions + + +@pytest.mark.anyio +async def test_viewer_can_read_but_cannot_write_or_run(): + ctx = _context("viewer") + dependency = require_method_permission( + read_permission=SIMULATION_VIEW, + write_permission=SIMULATION_RUN, + ) + read_request = type( + "Request", + (), + {"method": "GET", "path_params": {}, "query_params": {}, "headers": {}}, + )() + write_request = type( + "Request", + (), + {"method": "POST", "path_params": {}, "query_params": {}, "headers": {}}, + )() + + assert await dependency(read_request, ctx) is ctx + with pytest.raises(HTTPException) as exc: + await dependency(write_request, ctx) + + assert exc.value.status_code == 403 + assert exc.value.detail["permission"] == SIMULATION_RUN diff --git a/tests/auth/test_rbac_migration.py b/tests/auth/test_rbac_migration.py new file mode 100644 index 0000000..8111be4 --- /dev/null +++ b/tests/auth/test_rbac_migration.py @@ -0,0 +1,14 @@ +from pathlib import Path + + +def test_rbac_migration_normalizes_legacy_roles(): + sql = Path("resources/sql/006_metadata_rbac_roles.sql").read_text( + encoding="utf-8" + ) + + assert "role IN ('admin', 'user')" in sql + assert "project_role IN ('member', 'viewer')" in sql + assert "'modeler'," in sql + assert "'dispatcher'" in sql + assert "THEN 'member'" in sql + assert "ELSE 'viewer'" in sql diff --git a/tests/unit/test_valve_isolation.py b/tests/unit/test_valve_isolation.py new file mode 100644 index 0000000..f628297 --- /dev/null +++ b/tests/unit/test_valve_isolation.py @@ -0,0 +1,71 @@ +from collections import defaultdict + +from app.algorithms.isolation import valve + + +def test_non_isolatable_omits_affected_node_ids_but_keeps_count(monkeypatch): + pipe_adj = defaultdict( + set, + { + "A": {"B"}, + "B": {"A", "C"}, + "C": {"B"}, + }, + ) + topology = ( + pipe_adj, + {"V-optional": ("A", "C")}, + {"P-1": ("A", "B", "pipe")}, + {"A", "B", "C"}, + ) + monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology) + + result = valve.valve_isolation_analysis("demo", "P-1") + + assert result["isolatable"] is False + assert result["affected_node_count"] == 3 + assert result["affected_nodes"] == [] + assert result["optional_valves"] == ["V-optional"] + + +def test_isolatable_keeps_affected_node_ids_and_count(monkeypatch): + pipe_adj = defaultdict(set, {"A": {"B"}, "B": {"A"}}) + topology = ( + pipe_adj, + {"V-close": ("B", "C")}, + {"P-1": ("A", "B", "pipe")}, + {"A", "B", "C"}, + ) + monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology) + + result = valve.valve_isolation_analysis("demo", "P-1") + + assert result["isolatable"] is True + assert result["affected_node_count"] == 2 + assert result["affected_nodes"] == ["A", "B"] + assert result["must_close_valves"] == ["V-close"] + + +def test_disabled_valve_expands_affected_area_before_counting(monkeypatch): + pipe_adj = defaultdict(set, {"A": {"B"}, "B": {"A"}}) + topology = ( + pipe_adj, + { + "V-disabled": ("B", "C"), + "V-close": ("C", "D"), + }, + {"P-1": ("A", "B", "pipe")}, + {"A", "B", "C", "D"}, + ) + monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology) + + result = valve.valve_isolation_analysis( + "demo", + "P-1", + disabled_valves=["V-disabled"], + ) + + assert result["isolatable"] is True + assert result["affected_node_count"] == 3 + assert result["affected_nodes"] == ["A", "B", "C"] + assert result["must_close_valves"] == ["V-close"] From ba947b616b54f8f69cbe6af828e922e763fac2f4 Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 20:38:51 +0800 Subject: [PATCH 74/93] feat(api): standardize REST contracts and auth --- AUTHENTICATION_AND_USER_MANAGEMENT.md | 37 +- app/api/problem_details.py | 89 + app/api/v1/endpoints/access.py | 2 +- app/api/v1/endpoints/admin_metadata.py | 34 +- app/api/v1/endpoints/agent_auth.py | 2 +- app/api/v1/endpoints/audit.py | 8 +- app/api/v1/endpoints/burst_detection.py | 2 +- app/api/v1/endpoints/burst_location.py | 2 +- app/api/v1/endpoints/cache.py | 8 +- app/api/v1/endpoints/components/controls.py | 12 +- app/api/v1/endpoints/components/curves.py | 14 +- app/api/v1/endpoints/components/options.py | 24 +- app/api/v1/endpoints/components/patterns.py | 14 +- app/api/v1/endpoints/components/quality.py | 50 +- app/api/v1/endpoints/components/visuals.py | 30 +- app/api/v1/endpoints/extension.py | 10 +- app/api/v1/endpoints/geocoding.py | 2 +- app/api/v1/endpoints/leakage.py | 2 +- app/api/v1/endpoints/meta.py | 6 +- app/api/v1/endpoints/misc.py | 5 +- app/api/v1/endpoints/model_import.py | 117 +- app/api/v1/endpoints/network/demands.py | 20 +- app/api/v1/endpoints/network/general.py | 66 +- app/api/v1/endpoints/network/geometry.py | 10 +- app/api/v1/endpoints/network/junctions.py | 36 +- app/api/v1/endpoints/network/pipes.py | 40 +- app/api/v1/endpoints/network/pumps.py | 20 +- app/api/v1/endpoints/network/regions.py | 92 +- app/api/v1/endpoints/network/reservoirs.py | 46 +- app/api/v1/endpoints/network/tags.py | 10 +- app/api/v1/endpoints/network/tanks.py | 56 +- app/api/v1/endpoints/network/valves.py | 48 +- app/api/v1/endpoints/project.py | 46 +- app/api/v1/endpoints/project_data.py | 8 +- app/api/v1/endpoints/risk.py | 10 +- app/api/v1/endpoints/scada.py | 50 +- app/api/v1/endpoints/schemes.py | 5 +- app/api/v1/endpoints/sensor_placement.py | 2 +- app/api/v1/endpoints/simulation.py | 48 +- app/api/v1/endpoints/snapshots.py | 44 +- app/api/v1/endpoints/timeseries/composite.py | 10 +- app/api/v1/endpoints/timeseries/realtime.py | 20 +- app/api/v1/endpoints/timeseries/scada.py | 10 +- app/api/v1/endpoints/timeseries/scheme.py | 24 +- app/api/v1/endpoints/users.py | 6 +- app/api/v1/endpoints/web_search.py | 2 +- app/api/v1/rest_router.py | 349 + app/api/v1/router.py | 6 +- app/auth/metadata_dependencies.py | 4 +- app/auth/project_dependencies.py | 2 +- app/core/config.py | 1 - app/main.py | 5 +- cli/tests/unit/test_tjwater_cli.py | 78 +- cli/tjwater_cli/commands_analysis.py | 98 +- cli/tjwater_cli/commands_data.py | 50 +- cli/tjwater_cli/commands_readonly.py | 94 +- cli/tjwater_cli/common.py | 4 - cli/tjwater_cli/core.py | 82 +- cli/tjwater_cli/registry.py | 70 +- contracts/manifest.json | 9 + contracts/server-v1.openapi.json | 51672 ++++++++++++++++ docs/api-style.md | 22 + infra/docker/keycloak/README.md | 104 + infra/docker/keycloak/configure-theme.sh | 115 + .../login/messages/messages_en.properties | 3 + .../login/messages/messages_zh_CN.properties | 9 + .../login/resources/css/tjwater-login.css | 535 + .../tjwater/login/resources/img/logo-mark.svg | 8 + .../login/resources/img/network-blueprint.svg | 39 + .../login/resources/js/locale-labels.js | 21 + .../themes/tjwater/login/theme.properties | 7 + scripts/check_openapi.py | 143 + scripts/export_openapi.py | 65 + tests/api/test_access_endpoints.py | 4 +- tests/api/test_agent_auth_endpoints.py | 4 +- tests/api/test_api_integration.py | 6 +- tests/api/test_audit_endpoints.py | 8 +- tests/api/test_leakage_endpoints.py | 4 +- tests/api/test_meta_endpoints.py | 4 +- tests/api/test_model_import_endpoints.py | 6 +- tests/api/test_openapi_contract.py | 256 + tests/api/test_project_endpoints.py | 22 +- tests/api/test_regions_endpoints.py | 10 +- tests/api/test_sensor_placement_endpoints.py | 16 +- tests/api/test_simulation_endpoints.py | 30 +- tests/unit/test_keycloak_theme_config.py | 19 + 86 files changed, 54193 insertions(+), 990 deletions(-) create mode 100644 app/api/problem_details.py create mode 100644 app/api/v1/rest_router.py create mode 100644 contracts/manifest.json create mode 100644 contracts/server-v1.openapi.json create mode 100644 docs/api-style.md create mode 100644 infra/docker/keycloak/README.md create mode 100644 infra/docker/keycloak/configure-theme.sh create mode 100644 infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties create mode 100644 infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties create mode 100644 infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css create mode 100644 infra/docker/keycloak/themes/tjwater/login/resources/img/logo-mark.svg create mode 100644 infra/docker/keycloak/themes/tjwater/login/resources/img/network-blueprint.svg create mode 100644 infra/docker/keycloak/themes/tjwater/login/resources/js/locale-labels.js create mode 100644 infra/docker/keycloak/themes/tjwater/login/theme.properties create mode 100644 scripts/check_openapi.py create mode 100644 scripts/export_openapi.py create mode 100644 tests/api/test_openapi_contract.py create mode 100644 tests/unit/test_keycloak_theme_config.py diff --git a/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md index 7a6906e..8b04db3 100644 --- a/AUTHENTICATION_AND_USER_MANAGEMENT.md +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -13,6 +13,25 @@ TJWater metadata stores only business snapshots and authorization data: The backend does not accept passwords, does not issue local JWTs, and does not trust frontend-supplied user IDs. +## Fixed Project RBAC + +Project roles are stored directly in +`user_project_membership.project_role`; there is no separate role table or +user-defined permission editor in this delivery. + +| Role | Main access | +| --- | --- | +| `modeler` | Model upload/import, simulation, burst, risk, and optimization analysis | +| `dispatcher` | SCADA cleaning, simulation and burst analysis | +| `auditor` | Project read access and project-scoped audit logs | +| `viewer` | WebGIS and read-only risk results | + +Legacy `owner`, `admin`, and `member` values remain supported for existing +records. The backend is the authorization boundary; the frontend uses +`GET /api/v1/access/context` only to hide unavailable menus and guard routes. +System admins receive environment, membership, and global-audit permissions, +but still need a project membership for project business APIs. + ## Login Snapshot Refresh Every authenticated metadata-user resolution validates the Keycloak access token @@ -77,14 +96,22 @@ Apply metadata patches in order: 1. `resources/sql/004_metadata_auth_management.sql` 2. `resources/sql/005_metadata_project_configuration.sql` +3. `resources/sql/006_metadata_rbac_roles.sql` `004` creates Keycloak-backed metadata users and project memberships. `005` creates project and project database routing tables with uniqueness, role/type, -and pool-size constraints. +and pool-size constraints. `006` extends existing membership constraints with +the fixed delivery roles. ## Frontend System Management -`/system-admin` is shown only after `GET /api/v1/admin/me` confirms metadata -admin access. The page lets admins maintain metadata users, project members, -projects, project database routing for `biz_data` and `iot_data`, connection -health checks. This replaces direct SQL editing for normal project onboarding. +`/system-admin` is shown only when `GET /api/v1/access/context` returns +`environment.manage`. The page lets admins maintain metadata users, project +members, projects, project database routing for `biz_data` and `iot_data`, and +connection health checks. This replaces direct SQL editing for normal project +onboarding. + +Hydraulic model authoring is outside the Web application. Models are prepared +in the desktop modeling client and uploaded/imported by an authorized modeler; +the system administrator configures the project environment and database +routing. diff --git a/app/api/problem_details.py b/app/api/problem_details.py new file mode 100644 index 0000000..018bf93 --- /dev/null +++ b/app/api/problem_details.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + + +class ProblemDetails(BaseModel): + """RFC 9457 compatible error response used by the REST contract.""" + + type: str + title: str + status: int + detail: str + instance: str + code: str + trace_id: str + errors: list[dict[str, Any]] = Field(default_factory=list) + + +def _trace_id(request: Request) -> str: + return request.headers.get("X-Request-Id") or str(uuid4()) + + +def _problem_response( + request: Request, + *, + status_code: int, + title: str, + detail: str, + code: str, + errors: list[dict[str, Any]] | None = None, +) -> JSONResponse: + problem = ProblemDetails( + type=f"https://tjwater.example/problems/{code.replace('_', '-')}", + title=title, + status=status_code, + detail=detail, + instance=request.url.path, + code=code, + trace_id=_trace_id(request), + errors=errors or [], + ) + return JSONResponse( + status_code=status_code, + content=problem.model_dump(mode="json"), + media_type="application/problem+json", + ) + + +def install_problem_details_handlers(app: FastAPI) -> None: + @app.exception_handler(RequestValidationError) + async def validation_error_handler( + request: Request, + exc: RequestValidationError, + ) -> JSONResponse: + return _problem_response( + request, + status_code=422, + title="Validation error", + detail="Request validation failed", + code="validation_error", + errors=exc.errors(), + ) + + @app.exception_handler(HTTPException) + async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse: + detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail) + code_by_status = { + 401: "unauthenticated", + 403: "forbidden", + 404: "not_found", + 409: "conflict", + 422: "validation_error", + 503: "dependency_unavailable", + } + return _problem_response( + request, + status_code=exc.status_code, + title=code_by_status.get(exc.status_code, "request_error") + .replace("_", " ") + .title(), + detail=detail, + code=code_by_status.get(exc.status_code, "request_error"), + ) diff --git a/app/api/v1/endpoints/access.py b/app/api/v1/endpoints/access.py index fcf6a56..7792856 100644 --- a/app/api/v1/endpoints/access.py +++ b/app/api/v1/endpoints/access.py @@ -12,7 +12,7 @@ from app.infra.db.metadb.repositories.metadata_repository import MetadataReposit router = APIRouter() -@router.get("/access/context", response_model=AccessContextResponse) +@router.get("/access-context", response_model=AccessContextResponse) async def get_access_context( x_project_id: str | None = Header(default=None, alias="X-Project-Id"), current_user=Depends(get_current_metadata_user), diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py index 930a3d8..b8bc55b 100644 --- a/app/api/v1/endpoints/admin_metadata.py +++ b/app/api/v1/endpoints/admin_metadata.py @@ -151,14 +151,14 @@ async def _upsert_and_audit_metadata_user( return MetadataUserResponse.model_validate(user) -@router.get("/me", response_model=MetadataUserResponse) +@router.get("/admin/users/me", response_model=MetadataUserResponse) async def get_metadata_admin_me( current_user=Depends(get_current_metadata_admin), ) -> MetadataUserResponse: return MetadataUserResponse.model_validate(current_user) -@router.post("/users/sync", response_model=MetadataUserResponse) +@router.post("/admin/user-syncs", response_model=MetadataUserResponse) async def sync_metadata_user( payload: MetadataUserSyncRequest, current_user=Depends(get_current_metadata_admin), @@ -184,7 +184,7 @@ async def sync_metadata_user( -@router.post("/users/sync/batch", response_model=List[MetadataUserSyncResult]) +@router.post("/admin/user-syncs/batches", response_model=List[MetadataUserSyncResult]) async def sync_metadata_users_batch( payload: MetadataUsersBatchSyncRequest, current_user=Depends(get_current_metadata_admin), @@ -228,7 +228,7 @@ async def sync_metadata_users_batch( return results -@router.get("/users", response_model=List[MetadataUserResponse]) +@router.get("/admin/users", response_model=List[MetadataUserResponse]) async def list_metadata_users( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), @@ -239,7 +239,7 @@ async def list_metadata_users( return [MetadataUserResponse.model_validate(user) for user in users] -@router.get("/projects", response_model=List[AdminProjectResponse]) +@router.get("/admin/projects", response_model=List[AdminProjectResponse]) async def list_admin_projects( current_user=Depends(get_current_metadata_admin), metadata_repo: MetadataRepository = Depends(get_metadata_repository), @@ -249,7 +249,7 @@ async def list_admin_projects( @router.post( - "/projects", + "/admin/projects", response_model=AdminProjectResponse, status_code=status.HTTP_201_CREATED, ) @@ -293,7 +293,7 @@ async def create_admin_project( @router.patch( - "/projects/{project_id}", + "/admin/projects/{project_id}", response_model=AdminProjectResponse, ) async def update_admin_project( @@ -332,7 +332,7 @@ async def update_admin_project( @router.get( - "/projects/{project_id}/databases", + "/admin/projects/{project_id}/databases", response_model=List[ProjectDatabaseResponse], ) async def list_project_databases( @@ -348,7 +348,7 @@ async def list_project_databases( @router.put( - "/projects/{project_id}/databases", + "/admin/projects/{project_id}/databases", response_model=ProjectDatabaseResponse, ) async def upsert_project_database( @@ -421,7 +421,7 @@ async def upsert_project_database( @router.delete( - "/projects/{project_id}/databases/{db_role}", + "/admin/projects/{project_id}/databases/{db_role}", status_code=status.HTTP_204_NO_CONTENT, ) async def delete_project_database( @@ -449,7 +449,7 @@ async def delete_project_database( @router.post( - "/projects/{project_id}/databases/{db_role}/health", + "/admin/projects/{project_id}/databases/{db_role}/health-checks", response_model=ProjectDatabaseHealthResponse, ) async def check_project_database_health( @@ -504,7 +504,7 @@ async def check_project_database_health( ) -@router.get("/users/{user_id}", response_model=MetadataUserResponse) +@router.get("/admin/users/{user_id}", response_model=MetadataUserResponse) async def get_metadata_user( user_id: UUID = Path(...), current_user=Depends(get_current_metadata_admin), @@ -516,7 +516,7 @@ async def get_metadata_user( return MetadataUserResponse.model_validate(user) -@router.patch("/users/{user_id}", response_model=MetadataUserResponse) +@router.patch("/admin/users/{user_id}", response_model=MetadataUserResponse) async def update_metadata_user( payload: MetadataUserUpdateRequest, user_id: UUID = Path(...), @@ -549,7 +549,7 @@ async def update_metadata_user( @router.get( - "/projects/{project_id}/members", + "/admin/projects/{project_id}/members", response_model=List[ProjectMemberResponse], ) async def list_project_members( @@ -567,7 +567,7 @@ async def list_project_members( @router.post( - "/projects/{project_id}/members", + "/admin/projects/{project_id}/members", response_model=ProjectMemberResponse, status_code=status.HTTP_201_CREATED, ) @@ -622,7 +622,7 @@ async def add_project_member( @router.patch( - "/projects/{project_id}/members/{user_id}", + "/admin/projects/{project_id}/members/{user_id}", response_model=ProjectMemberResponse, ) async def update_project_member( @@ -668,7 +668,7 @@ async def update_project_member( ) -@router.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/admin/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_project_member( project_id: UUID = Path(...), user_id: UUID = Path(...), diff --git a/app/api/v1/endpoints/agent_auth.py b/app/api/v1/endpoints/agent_auth.py index 3aed53b..9e2a970 100644 --- a/app/api/v1/endpoints/agent_auth.py +++ b/app/api/v1/endpoints/agent_auth.py @@ -27,7 +27,7 @@ class AgentAuthContextResponse(BaseModel): token_expires_at: str | None = None -@router.get("/agent/auth/context", response_model=AgentAuthContextResponse) +@router.get("/agent-auth-context", response_model=AgentAuthContextResponse) async def get_agent_auth_context( ctx: ProjectContext = Depends(get_project_context), current_user=Depends(get_current_metadata_user), diff --git a/app/api/v1/endpoints/audit.py b/app/api/v1/endpoints/audit.py index 871ff68..fb47d3c 100644 --- a/app/api/v1/endpoints/audit.py +++ b/app/api/v1/endpoints/audit.py @@ -29,7 +29,7 @@ async def get_audit_repository( @router.get( - "/logs", + "/audit-logs", summary="查询审计日志", description="查询审计日志(仅管理员)", response_model=list[AuditLogResponse], @@ -59,7 +59,7 @@ async def get_audit_logs( @router.get( - "/logs/count", + "/audit-logs/count", summary="获取审计日志总数", description="获取审计日志总数(仅管理员)", ) @@ -84,7 +84,7 @@ async def get_audit_logs_count( return {"count": count} -@router.post("/session-events", status_code=status.HTTP_204_NO_CONTENT) +@router.post("/audit-events", status_code=status.HTTP_204_NO_CONTENT) async def record_session_event( payload: SessionAuditEventRequest, request: Request, @@ -105,7 +105,7 @@ async def record_session_event( @router.get( - "/logs/my", + "/audit-logs/mine", summary="查询我的审计日志", description="查询当前用户的审计日志", response_model=list[AuditLogResponse], diff --git a/app/api/v1/endpoints/burst_detection.py b/app/api/v1/endpoints/burst_detection.py index 9d188d8..d9a0d17 100644 --- a/app/api/v1/endpoints/burst_detection.py +++ b/app/api/v1/endpoints/burst_detection.py @@ -48,7 +48,7 @@ class BurstDetectionRequest(BaseModel): @router.post( - "/detect/", + "/burst-detections", summary="执行爆管检测", description="基于压力观测数据和其他参数执行爆管检测分析" ) diff --git a/app/api/v1/endpoints/burst_location.py b/app/api/v1/endpoints/burst_location.py index fa5995a..e6f52d0 100644 --- a/app/api/v1/endpoints/burst_location.py +++ b/app/api/v1/endpoints/burst_location.py @@ -38,7 +38,7 @@ class BurstLocationRequest(BaseModel): @router.post( - "/locate/", + "/burst-locations", summary="执行爆管定位", description="基于压力和流量数据定位管网中的爆管位置" ) diff --git a/app/api/v1/endpoints/cache.py b/app/api/v1/endpoints/cache.py index 9e4dbdf..fee4ddc 100644 --- a/app/api/v1/endpoints/cache.py +++ b/app/api/v1/endpoints/cache.py @@ -3,7 +3,7 @@ from app.infra.cache.redis_client import redis_client router = APIRouter() -@router.post("/clearrediskey/", summary="清除单个缓存键", description="根据键名清除单个Redis缓存") +@router.delete("/redis-keys/detail", summary="清除单个缓存键", description="根据键名清除单个Redis缓存") async def fastapi_clear_redis_key(key: str = Query(..., description="缓存键名")): """ 清除单个缓存键 @@ -14,7 +14,7 @@ async def fastapi_clear_redis_key(key: str = Query(..., description="缓存键 return True -@router.post("/clearrediskeys/", summary="清除匹配的缓存键", description="根据模式清除匹配的Redis缓存键") +@router.delete("/redis-keys", summary="清除匹配的缓存键", description="根据模式清除匹配的Redis缓存键") async def fastapi_clear_redis_keys(keys: str = Query(..., description="缓存键模式(支持通配符)")): """ 清除匹配的缓存键 @@ -29,7 +29,7 @@ async def fastapi_clear_redis_keys(keys: str = Query(..., description="缓存键 return True -@router.post("/clearallredis/", summary="清除所有缓存", description="清空整个Redis数据库的所有缓存") +@router.delete("/all-redis", summary="清除所有缓存", description="清空整个Redis数据库的所有缓存") async def fastapi_clear_all_redis(): """ 清除所有缓存 @@ -40,7 +40,7 @@ async def fastapi_clear_all_redis(): return True -@router.get("/queryredis/", summary="查询缓存键列表", description="获取Redis中所有的缓存键") +@router.get("/redis", summary="查询缓存键列表", description="获取Redis中所有的缓存键") async def fastapi_query_redis(): """ 查询缓存键列表 diff --git a/app/api/v1/endpoints/components/controls.py b/app/api/v1/endpoints/components/controls.py index 2ff6525..d406f20 100644 --- a/app/api/v1/endpoints/components/controls.py +++ b/app/api/v1/endpoints/components/controls.py @@ -13,7 +13,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getcontrolschema/", summary="获取控制架构", description="获取网络中控制对象的架构定义") +@router.get("/network-schemas/control", summary="获取控制架构", description="获取网络中控制对象的架构定义") async def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取控制架构。 @@ -21,7 +21,7 @@ async def fastapi_get_control_schema(network: str = Query(..., description="管 """ return get_control_schema(network) -@router.get("/getcontrolproperties/", summary="获取控制属性", description="获取指定网络中的控制属性信息") +@router.get("/controls/properties", summary="获取控制属性", description="获取指定网络中的控制属性信息") async def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取控制属性。 @@ -29,7 +29,7 @@ async def fastapi_get_control_properties(network: str = Query(..., description=" """ return get_control(network) -@router.post("/setcontrolproperties/", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性") +@router.patch("/controls/properties", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性") async def fastapi_set_control_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -41,7 +41,7 @@ async def fastapi_set_control_properties( props = await req.json() return set_control(network, ChangeSet(props)) -@router.get("/getruleschema/", summary="获取规则架构", description="获取网络中规则对象的架构定义") +@router.get("/rule-schemas", summary="获取规则架构", description="获取网络中规则对象的架构定义") async def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取规则架构。 @@ -49,7 +49,7 @@ async def fastapi_get_rule_schema(network: str = Query(..., description="管网 """ return get_rule_schema(network) -@router.get("/getruleproperties/", summary="获取规则属性", description="获取指定网络中的规则属性信息") +@router.get("/rule-properties", summary="获取规则属性", description="获取指定网络中的规则属性信息") async def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取规则属性。 @@ -57,7 +57,7 @@ async def fastapi_get_rule_properties(network: str = Query(..., description="管 """ return get_rule(network) -@router.post("/setruleproperties/", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性") +@router.patch("/rule-properties", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性") async def fastapi_set_rule_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None diff --git a/app/api/v1/endpoints/components/curves.py b/app/api/v1/endpoints/components/curves.py index 8b2b45a..c462dab 100644 --- a/app/api/v1/endpoints/components/curves.py +++ b/app/api/v1/endpoints/components/curves.py @@ -14,7 +14,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getcurveschema", summary="获取曲线架构", description="获取网络中曲线对象的架构定义") +@router.get("/network-schemas/curve", summary="获取曲线架构", description="获取网络中曲线对象的架构定义") async def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取曲线架构。 @@ -22,7 +22,7 @@ async def fastapi_get_curve_schema(network: str = Query(..., description="管网 """ return get_curve_schema(network) -@router.post("/addcurve/", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线") +@router.post("/curves", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线") async def fastapi_add_curve( network: str = Query(..., description="管网名称(或数据库名称)"), curve: str = Query(..., description="曲线ID"), @@ -38,7 +38,7 @@ async def fastapi_add_curve( } | props return add_curve(network, ChangeSet(ps)) -@router.post("/deletecurve/", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线") +@router.delete("/curves", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线") async def fastapi_delete_curve( network: str = Query(..., description="管网名称(或数据库名称)"), curve: str = Query(..., description="曲线ID") @@ -50,7 +50,7 @@ async def fastapi_delete_curve( ps = {"id": curve} return delete_curve(network, ChangeSet(ps)) -@router.get("/getcurveproperties/", summary="获取曲线属性", description="获取指定曲线的属性信息") +@router.get("/curves/properties", summary="获取曲线属性", description="获取指定曲线的属性信息") async def fastapi_get_curve_properties( network: str = Query(..., description="管网名称(或数据库名称)"), curve: str = Query(..., description="曲线ID") @@ -61,7 +61,7 @@ async def fastapi_get_curve_properties( """ return get_curve(network, curve) -@router.post("/setcurveproperties/", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性") +@router.patch("/curves/properties", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性") async def fastapi_set_curve_properties( network: str = Query(..., description="管网名称(或数据库名称)"), curve: str = Query(..., description="曲线ID"), @@ -75,7 +75,7 @@ async def fastapi_set_curve_properties( ps = {"id": curve} | props return set_curve(network, ChangeSet(ps)) -@router.get("/getcurves/", summary="获取所有曲线", description="获取网络中的所有曲线列表") +@router.get("/curves", summary="获取所有曲线", description="获取网络中的所有曲线列表") async def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]: """获取所有曲线。 @@ -83,7 +83,7 @@ async def fastapi_get_curves(network: str = Query(..., description="管网名称 """ return get_curves(network) -@router.get("/iscurve/", summary="检查曲线存在性", description="检查指定的曲线是否存在") +@router.get("/curves/existence", summary="检查曲线存在性", description="检查指定的曲线是否存在") async def fastapi_is_curve( network: str = Query(..., description="管网名称(或数据库名称)"), curve: str = Query(..., description="曲线ID") diff --git a/app/api/v1/endpoints/components/options.py b/app/api/v1/endpoints/components/options.py index 8506563..21083ee 100644 --- a/app/api/v1/endpoints/components/options.py +++ b/app/api/v1/endpoints/components/options.py @@ -19,7 +19,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/gettimeschema", summary="获取时间选项架构", description="获取网络中时间选项的架构定义") +@router.get("/network-schemas/time", summary="获取时间选项架构", description="获取网络中时间选项的架构定义") async def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取时间选项架构。 @@ -27,7 +27,7 @@ async def fastapi_get_time_schema(network: str = Query(..., description="管网 """ return get_time_schema(network) -@router.get("/gettimeproperties/", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息") +@router.get("/network-options/time", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息") async def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取时间选项属性。 @@ -35,7 +35,7 @@ async def fastapi_get_time_properties(network: str = Query(..., description="管 """ return get_time(network) -@router.post("/settimeproperties/", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性") +@router.patch("/time-properties", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性") async def fastapi_set_time_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -47,7 +47,7 @@ async def fastapi_set_time_properties( props = await req.json() return set_time(network, ChangeSet(props)) -@router.get("/getenergyschema/", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义") +@router.get("/network-schemas/energy", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义") async def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取能耗选项架构。 @@ -55,7 +55,7 @@ async def fastapi_get_energy_schema(network: str = Query(..., description="管 """ return get_energy_schema(network) -@router.get("/getenergyproperties/", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息") +@router.get("/network-options/energy", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息") async def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取能耗选项属性。 @@ -63,7 +63,7 @@ async def fastapi_get_energy_properties(network: str = Query(..., description=" """ return get_energy(network) -@router.post("/setenergyproperties/", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性") +@router.patch("/energy-properties", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性") async def fastapi_set_energy_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -75,7 +75,7 @@ async def fastapi_set_energy_properties( props = await req.json() return set_energy(network, ChangeSet(props)) -@router.get("/getpumpenergyschema/", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义") +@router.get("/network-schemas/pump-energy", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义") async def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取泵能耗选项架构。 @@ -83,7 +83,7 @@ async def fastapi_get_pump_energy_schema(network: str = Query(..., description=" """ return get_pump_energy_schema(network) -@router.get("/getpumpenergyproperties//", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息") +@router.get("/network-options/pump-energy", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息") async def fastapi_get_pump_energy_proeprties( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="泵ID") @@ -94,7 +94,7 @@ async def fastapi_get_pump_energy_proeprties( """ return get_pump_energy(network, pump) -@router.get("/setpumpenergyproperties//", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性") +@router.patch("/network-options/pump-energy", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性") async def fastapi_set_pump_energy_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="泵ID"), @@ -108,7 +108,7 @@ async def fastapi_set_pump_energy_properties( ps = {"id": pump} | props return set_pump_energy(network, ChangeSet(ps)) -@router.get("/getoptionschema/", summary="获取选项架构", description="获取网络中选项对象的架构定义") +@router.get("/network-schemas/option", summary="获取选项架构", description="获取网络中选项对象的架构定义") async def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取选项架构。 @@ -116,7 +116,7 @@ async def fastapi_get_option_schema(network: str = Query(..., description="管 """ return get_option_v3_schema(network) -@router.get("/getoptionproperties/", summary="获取选项属性", description="获取指定网络中的选项属性信息") +@router.get("/network-options", summary="获取选项属性", description="获取指定网络中的选项属性信息") async def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取选项属性。 @@ -124,7 +124,7 @@ async def fastapi_get_option_properties(network: str = Query(..., description=" """ return get_option_v3(network) -@router.post("/setoptionproperties/", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性") +@router.patch("/network-options", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性") async def fastapi_set_option_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None diff --git a/app/api/v1/endpoints/components/patterns.py b/app/api/v1/endpoints/components/patterns.py index f73eb21..bb6daea 100644 --- a/app/api/v1/endpoints/components/patterns.py +++ b/app/api/v1/endpoints/components/patterns.py @@ -14,7 +14,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getpatternschema", summary="获取模式架构", description="获取网络中模式对象的架构定义") +@router.get("/network-schemas/pattern", summary="获取模式架构", description="获取网络中模式对象的架构定义") async def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取模式架构。 @@ -22,7 +22,7 @@ async def fastapi_get_pattern_schema(network: str = Query(..., description="管 """ return get_pattern_schema(network) -@router.post("/addpattern/", response_model=None, summary="添加模式", description="在网络中添加一个新的模式") +@router.post("/patterns", response_model=None, summary="添加模式", description="在网络中添加一个新的模式") async def fastapi_add_pattern( network: str = Query(..., description="管网名称(或数据库名称)"), pattern: str = Query(..., description="模式ID"), @@ -38,7 +38,7 @@ async def fastapi_add_pattern( } | props return add_pattern(network, ChangeSet(ps)) -@router.post("/deletepattern/", response_model=None, summary="删除模式", description="从网络中删除指定的模式") +@router.delete("/patterns", response_model=None, summary="删除模式", description="从网络中删除指定的模式") async def fastapi_delete_pattern( network: str = Query(..., description="管网名称(或数据库名称)"), pattern: str = Query(..., description="模式ID") @@ -50,7 +50,7 @@ async def fastapi_delete_pattern( ps = {"id": pattern} return delete_pattern(network, ChangeSet(ps)) -@router.get("/getpatternproperties/", summary="获取模式属性", description="获取指定模式的属性信息") +@router.get("/patterns/properties", summary="获取模式属性", description="获取指定模式的属性信息") async def fastapi_get_pattern_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pattern: str = Query(..., description="模式ID") @@ -61,7 +61,7 @@ async def fastapi_get_pattern_properties( """ return get_pattern(network, pattern) -@router.post("/setpatternproperties/", response_model=None, summary="设置模式属性", description="更新指定模式的属性") +@router.patch("/patterns/properties", response_model=None, summary="设置模式属性", description="更新指定模式的属性") async def fastapi_set_pattern_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pattern: str = Query(..., description="模式ID"), @@ -75,7 +75,7 @@ async def fastapi_set_pattern_properties( ps = {"id": pattern} | props return set_pattern(network, ChangeSet(ps)) -@router.get("/ispattern/", summary="检查模式存在性", description="检查指定的模式是否存在") +@router.get("/patterns/existence", summary="检查模式存在性", description="检查指定的模式是否存在") async def fastapi_is_pattern( network: str = Query(..., description="管网名称(或数据库名称)"), pattern: str = Query(..., description="模式ID") @@ -86,7 +86,7 @@ async def fastapi_is_pattern( """ return is_pattern(network, pattern) -@router.get("/getpatterns/", summary="获取所有模式", description="获取网络中的所有模式列表") +@router.get("/patterns", summary="获取所有模式", description="获取网络中的所有模式列表") async def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]: """获取所有模式。 diff --git a/app/api/v1/endpoints/components/quality.py b/app/api/v1/endpoints/components/quality.py index cef72cf..db3c6ca 100644 --- a/app/api/v1/endpoints/components/quality.py +++ b/app/api/v1/endpoints/components/quality.py @@ -32,7 +32,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getqualityschema/", summary="获取水质架构", description="获取网络中水质对象的架构定义") +@router.get("/network-schemas/quality", summary="获取水质架构", description="获取网络中水质对象的架构定义") async def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取水质架构。 @@ -40,7 +40,7 @@ async def fastapi_get_quality_schema(network: str = Query(..., description="管 """ return get_quality_schema(network) -@router.get("/getqualityproperties/", summary="获取水质属性", description="获取指定节点的水质属性信息") +@router.get("/quality-configurations/properties", summary="获取水质属性", description="获取指定节点的水质属性信息") async def fastapi_get_quality_properties( network: str = Query(..., description="管网名称(或数据库名称)"), node: str = Query(..., description="节点ID") @@ -51,7 +51,7 @@ async def fastapi_get_quality_properties( """ return get_quality(network, node) -@router.post("/setqualityproperties/", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性") +@router.patch("/quality-configurations/properties", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性") async def fastapi_set_quality_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -63,7 +63,7 @@ async def fastapi_set_quality_properties( props = await req.json() return set_quality(network, ChangeSet(props)) -@router.get("/getemitterschema", summary="获取发射器架构", description="获取网络中发射器对象的架构定义") +@router.get("/network-schemas/emitter", summary="获取发射器架构", description="获取网络中发射器对象的架构定义") async def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取发射器架构。 @@ -71,7 +71,7 @@ async def fastapi_get_emitter_schema(network: str = Query(..., description="管 """ return get_emitter_schema(network) -@router.get("/getemitterproperties/", summary="获取发射器属性", description="获取指定连接点的发射器属性信息") +@router.get("/emitters/properties", summary="获取发射器属性", description="获取指定连接点的发射器属性信息") async def fastapi_get_emitter_properties( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="连接点ID") @@ -82,7 +82,7 @@ async def fastapi_get_emitter_properties( """ return get_emitter(network, junction) -@router.post("/setemitterproperties/", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性") +@router.patch("/emitters/properties", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性") async def fastapi_set_emitter_properties( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="连接点ID"), @@ -96,7 +96,7 @@ async def fastapi_set_emitter_properties( ps = {"junction": junction} | props return set_emitter(network, ChangeSet(ps)) -@router.get("/getsourcechema/", summary="获取水源架构", description="获取网络中水源对象的架构定义") +@router.get("/network-schemas/source", summary="获取水源架构", description="获取网络中水源对象的架构定义") async def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取水源架构。 @@ -104,7 +104,7 @@ async def fastapi_get_source_schema(network: str = Query(..., description="管 """ return get_source_schema(network) -@router.get("/getsource/", summary="获取水源属性", description="获取指定节点的水源属性信息") +@router.get("/sources/detail", summary="获取水源属性", description="获取指定节点的水源属性信息") async def fastapi_get_source( network: str = Query(..., description="管网名称(或数据库名称)"), node: str = Query(..., description="节点ID") @@ -115,7 +115,7 @@ async def fastapi_get_source( """ return get_source(network, node) -@router.post("/setsource/", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性") +@router.patch("/sources", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性") async def fastapi_set_source( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -127,7 +127,7 @@ async def fastapi_set_source( props = await req.json() return set_source(network, ChangeSet(props)) -@router.post("/addsource/", response_model=None, summary="添加水源", description="在网络中添加一个新的水源") +@router.post("/sources", response_model=None, summary="添加水源", description="在网络中添加一个新的水源") async def fastapi_add_source( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -139,7 +139,7 @@ async def fastapi_add_source( props = await req.json() return add_source(network, ChangeSet(props)) -@router.post("/deletesource/", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源") +@router.delete("/sources", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源") async def fastapi_delete_source( network: str = Query(..., description="管网名称(或数据库名称)"), node: str = Query(..., description="节点ID") @@ -151,7 +151,7 @@ async def fastapi_delete_source( props = {"node": node} return delete_source(network, ChangeSet(props)) -@router.get("/getreactionschema/", summary="获取反应架构", description="获取网络中反应对象的架构定义") +@router.get("/network-schemas/reaction", summary="获取反应架构", description="获取网络中反应对象的架构定义") async def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取反应架构。 @@ -159,7 +159,7 @@ async def fastapi_get_reaction_schema(network: str = Query(..., description="管 """ return get_reaction_schema(network) -@router.get("/getreaction/", summary="获取反应属性", description="获取指定网络中的反应属性信息") +@router.get("/reactions/detail", summary="获取反应属性", description="获取指定网络中的反应属性信息") async def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取反应属性。 @@ -167,7 +167,7 @@ async def fastapi_get_reaction(network: str = Query(..., description="管网名 """ return get_reaction(network) -@router.post("/setreaction/", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性") +@router.patch("/reactions", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性") async def fastapi_set_reaction( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -179,7 +179,7 @@ async def fastapi_set_reaction( props = await req.json() return set_reaction(network, ChangeSet(props)) -@router.get("/getpipereactionschema/", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义") +@router.get("/network-schemas/pipe-reaction", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义") async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取管道反应架构。 @@ -187,7 +187,7 @@ async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description """ return get_pipe_reaction_schema(network) -@router.get("/getpipereaction/", summary="获取管道反应属性", description="获取指定管道的反应属性信息") +@router.get("/pipe-reactions/detail", summary="获取管道反应属性", description="获取指定管道的反应属性信息") async def fastapi_get_pipe_reaction( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -198,7 +198,7 @@ async def fastapi_get_pipe_reaction( """ return get_pipe_reaction(network, pipe) -@router.post("/setpipereaction/", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性") +@router.patch("/pipe-reactions", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性") async def fastapi_set_pipe_reaction( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -210,7 +210,7 @@ async def fastapi_set_pipe_reaction( props = await req.json() return set_pipe_reaction(network, ChangeSet(props)) -@router.get("/gettankreactionschema/", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义") +@router.get("/network-schemas/tank-reaction", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义") async def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取水池反应架构。 @@ -218,7 +218,7 @@ async def fastapi_get_tank_reaction_schema(network: str = Query(..., description """ return get_tank_reaction_schema(network) -@router.get("/gettankreaction/", summary="获取水池反应属性", description="获取指定水池的反应属性信息") +@router.get("/tank-reactions/detail", summary="获取水池反应属性", description="获取指定水池的反应属性信息") async def fastapi_get_tank_reaction( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水池ID") @@ -229,7 +229,7 @@ async def fastapi_get_tank_reaction( """ return get_tank_reaction(network, tank) -@router.post("/settankreaction/", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性") +@router.patch("/tank-reactions", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性") async def fastapi_set_tank_reaction( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -241,7 +241,7 @@ async def fastapi_set_tank_reaction( props = await req.json() return set_tank_reaction(network, ChangeSet(props)) -@router.get("/getmixingschema/", summary="获取混合架构", description="获取网络中混合对象的架构定义") +@router.get("/network-schemas/mixing", summary="获取混合架构", description="获取网络中混合对象的架构定义") async def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取混合架构。 @@ -249,7 +249,7 @@ async def fastapi_get_mixing_schema(network: str = Query(..., description="管 """ return get_mixing_schema(network) -@router.get("/getmixing/", summary="获取混合属性", description="获取指定水池的混合属性信息") +@router.get("/mixing-configurations/detail", summary="获取混合属性", description="获取指定水池的混合属性信息") async def fastapi_get_mixing( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水池ID") @@ -260,7 +260,7 @@ async def fastapi_get_mixing( """ return get_mixing(network, tank) -@router.post("/setmixing/", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性") +@router.patch("/mixing-configurations", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性") async def fastapi_set_mixing( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -272,7 +272,7 @@ async def fastapi_set_mixing( props = await req.json() return api.set_mixing(network, ChangeSet(props)) -@router.post("/addmixing/", response_model=None, summary="添加混合", description="在网络中添加一个新的混合") +@router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合") async def fastapi_add_mixing( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -284,7 +284,7 @@ async def fastapi_add_mixing( props = await req.json() return add_mixing(network, ChangeSet(props)) -@router.post("/deletemixing/", response_model=None, summary="删除混合", description="从网络中删除指定的混合") +@router.delete("/mixing-configurations", response_model=None, summary="删除混合", description="从网络中删除指定的混合") async def fastapi_delete_mixing( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None diff --git a/app/api/v1/endpoints/components/visuals.py b/app/api/v1/endpoints/components/visuals.py index aabd191..7764d86 100644 --- a/app/api/v1/endpoints/components/visuals.py +++ b/app/api/v1/endpoints/components/visuals.py @@ -24,7 +24,7 @@ import json router = APIRouter() -@router.get("/getvertexschema/", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义") +@router.get("/network-schemas/vertex", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义") async def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取图形元素架构。 @@ -32,7 +32,7 @@ async def fastapi_get_vertex_schema(network: str = Query(..., description="管 """ return get_vertex_schema(network) -@router.get("/getvertexproperties/", summary="获取图形元素属性", description="获取指定图形元素的属性信息") +@router.get("/visual-elements/properties", summary="获取图形元素属性", description="获取指定图形元素的属性信息") async def fastapi_get_vertex_properties( network: str = Query(..., description="管网名称(或数据库名称)"), link: str = Query(..., description="图形元素链接") @@ -43,7 +43,7 @@ async def fastapi_get_vertex_properties( """ return get_vertex(network, link) -@router.post("/setvertexproperties/", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性") +@router.patch("/visual-elements/properties", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性") async def fastapi_set_vertex_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -55,7 +55,7 @@ async def fastapi_set_vertex_properties( props = await req.json() return set_vertex(network, ChangeSet(props)) -@router.post("/addvertex/", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素") +@router.post("/visual-elements", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素") async def fastapi_add_vertex( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -67,7 +67,7 @@ async def fastapi_add_vertex( props = await req.json() return add_vertex(network, ChangeSet(props)) -@router.post("/deletevertex/", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素") +@router.delete("/visual-elements", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素") async def fastapi_delete_vertex( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -79,7 +79,7 @@ async def fastapi_delete_vertex( props = await req.json() return delete_vertex(network, ChangeSet(props)) -@router.get("/getallvertexlinks/", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表") +@router.get("/visual-elements/links", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表") async def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]: """获取所有图形元素链接。 @@ -87,7 +87,7 @@ async def fastapi_get_all_vertex_links(network: str = Query(..., description=" """ return json.dumps(get_all_vertex_links(network)) -@router.get("/getallvertices/", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息") +@router.get("/all-vertices", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息") async def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]: """获取所有图形元素。 @@ -95,7 +95,7 @@ async def fastapi_get_all_vertices(network: str = Query(..., description="管网 """ return json.dumps(get_all_vertices(network)) -@router.get("/getlabelschema/", summary="获取标签架构", description="获取网络中标签对象的架构定义") +@router.get("/network-schemas/label", summary="获取标签架构", description="获取网络中标签对象的架构定义") async def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取标签架构。 @@ -103,7 +103,7 @@ async def fastapi_get_label_schema(network: str = Query(..., description="管网 """ return get_label_schema(network) -@router.get("/getlabelproperties/", summary="获取标签属性", description="获取指定坐标处的标签属性信息") +@router.get("/labels/properties", summary="获取标签属性", description="获取指定坐标处的标签属性信息") async def fastapi_get_label_properties( network: str = Query(..., description="管网名称(或数据库名称)"), x: float = Query(..., description="X坐标"), @@ -115,7 +115,7 @@ async def fastapi_get_label_properties( """ return get_label(network, x, y) -@router.post("/setlabelproperties/", response_model=None, summary="设置标签属性", description="更新指定标签的属性") +@router.patch("/labels/properties", response_model=None, summary="设置标签属性", description="更新指定标签的属性") async def fastapi_set_label_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -127,7 +127,7 @@ async def fastapi_set_label_properties( props = await req.json() return set_label(network, ChangeSet(props)) -@router.post("/addlabel/", response_model=None, summary="添加标签", description="在网络中添加一个新的标签") +@router.post("/labels", response_model=None, summary="添加标签", description="在网络中添加一个新的标签") async def fastapi_add_label( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -139,7 +139,7 @@ async def fastapi_add_label( props = await req.json() return add_label(network, ChangeSet(props)) -@router.post("/deletelabel/", response_model=None, summary="删除标签", description="从网络中删除指定的标签") +@router.delete("/labels", response_model=None, summary="删除标签", description="从网络中删除指定的标签") async def fastapi_delete_label( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -151,7 +151,7 @@ async def fastapi_delete_label( props = await req.json() return delete_label(network, ChangeSet(props)) -@router.get("/getbackdropschema/", summary="获取背景架构", description="获取网络中背景对象的架构定义") +@router.get("/network-schemas/backdrop", summary="获取背景架构", description="获取网络中背景对象的架构定义") async def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """获取背景架构。 @@ -159,7 +159,7 @@ async def fastapi_get_backdrop_schema(network: str = Query(..., description="管 """ return get_backdrop_schema(network) -@router.get("/getbackdropproperties/", summary="获取背景属性", description="获取指定网络的背景属性信息") +@router.get("/backdrops/properties", summary="获取背景属性", description="获取指定网络的背景属性信息") async def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """获取背景属性。 @@ -167,7 +167,7 @@ async def fastapi_get_backdrop_properties(network: str = Query(..., description= """ return get_backdrop(network) -@router.post("/setbackdropproperties/", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性") +@router.patch("/backdrops/properties", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性") async def fastapi_set_backdrop_properties( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None diff --git a/app/api/v1/endpoints/extension.py b/app/api/v1/endpoints/extension.py index affb9f2..d9ce025 100644 --- a/app/api/v1/endpoints/extension.py +++ b/app/api/v1/endpoints/extension.py @@ -11,7 +11,7 @@ from app.services.tjnetwork import ( router = APIRouter() @router.get( - "/getallextensiondatakeys/", + "/all-extension-data-keys", summary="获取所有扩展数据键", description="获取指定网络的所有扩展数据的键列表" ) @@ -32,7 +32,7 @@ async def get_all_extension_data_keys_endpoint( return get_all_extension_data_keys(network) @router.get( - "/getallextensiondata/", + "/all-extension-datas", summary="获取所有扩展数据", description="获取指定网络的所有扩展数据" ) @@ -53,7 +53,7 @@ async def get_all_extension_data_endpoint( return get_all_extension_data(network) @router.get( - "/getextensiondata/", + "/extension-datas", summary="获取指定扩展数据", description="获取指定网络中指定键的扩展数据值" ) @@ -75,8 +75,8 @@ async def get_extension_data_endpoint( """ return get_extension_data(network, key) -@router.post( - "/setextensiondata/", +@router.patch( + "/extension-datas", response_model=None, summary="设置扩展数据", description="设置指定网络中的扩展数据" diff --git a/app/api/v1/endpoints/geocoding.py b/app/api/v1/endpoints/geocoding.py index 24c6797..c436d9e 100644 --- a/app/api/v1/endpoints/geocoding.py +++ b/app/api/v1/endpoints/geocoding.py @@ -13,7 +13,7 @@ router = APIRouter() @router.post( - "/tianditu/geocode", + "/geocoding-requests", summary="Tianditu Geocoding", description="调用天地图地理编码服务,将结构化地址转换为经纬度", ) diff --git a/app/api/v1/endpoints/leakage.py b/app/api/v1/endpoints/leakage.py index c57f95b..1af055e 100644 --- a/app/api/v1/endpoints/leakage.py +++ b/app/api/v1/endpoints/leakage.py @@ -38,7 +38,7 @@ class LeakageIdentifyRequest(BaseModel): @router.post( - "/identify/", + "/leakage-identifications", summary="执行漏损识别", description="基于压力观测数据和遗传算法识别管网中的漏损位置和大小" ) diff --git a/app/api/v1/endpoints/meta.py b/app/api/v1/endpoints/meta.py index 969c471..a84b197 100644 --- a/app/api/v1/endpoints/meta.py +++ b/app/api/v1/endpoints/meta.py @@ -25,7 +25,7 @@ router = APIRouter() logger = logging.getLogger(__name__) -@router.get("/meta/project", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse) +@router.get("/projects/current/metadata", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse) async def get_project_metadata( ctx: ProjectContext = Depends(get_project_context), metadata_repo: MetadataRepository = Depends(get_metadata_repository), @@ -52,7 +52,7 @@ async def get_project_metadata( ) -@router.get("/meta/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse]) +@router.get("/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse]) async def list_user_projects( current_user=Depends(get_current_metadata_user), metadata_repo: MetadataRepository = Depends(get_metadata_repository), @@ -88,7 +88,7 @@ async def list_user_projects( ] -@router.get("/meta/db/health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况") +@router.get("/projects/current/database-health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况") async def project_db_health( pg_session: AsyncSession = Depends(get_project_pg_session), ts_conn: AsyncConnection = Depends(get_project_timescale_connection), diff --git a/app/api/v1/endpoints/misc.py b/app/api/v1/endpoints/misc.py index 5500268..255fe47 100644 --- a/app/api/v1/endpoints/misc.py +++ b/app/api/v1/endpoints/misc.py @@ -11,7 +11,6 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getjson/", summary="获取JSON示例", description="获取JSON格式响应示例") async def fastapi_get_json(): """ 获取JSON示例 @@ -29,7 +28,6 @@ async def fastapi_get_json(): @router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息") -@router.get("/getallsensorplacements/", summary="获取所有传感器位置(旧路径)", description="获取网络中所有传感器的放置位置信息", deprecated=True) async def fastapi_get_all_sensor_placements(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: """ 获取所有传感器位置 @@ -39,7 +37,7 @@ async def fastapi_get_all_sensor_placements(network: str = Query(..., descriptio return get_all_sensor_placements(network) -@router.get("/getallburstlocateresults/", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果") +@router.get("/burst-locations", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果") async def fastapi_get_all_burst_locate_results(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: """ 获取所有爆管定位结果 @@ -54,7 +52,6 @@ class Item(BaseModel): str_info: str -@router.post("/test_dict/", summary="测试字典处理", description="测试处理字典类型数据") async def fastapi_test_dict(data: Item) -> dict[str, str]: """ 测试字典处理 diff --git a/app/api/v1/endpoints/model_import.py b/app/api/v1/endpoints/model_import.py index 35fc9fc..ffd84e8 100644 --- a/app/api/v1/endpoints/model_import.py +++ b/app/api/v1/endpoints/model_import.py @@ -1,17 +1,13 @@ -import json from pathlib import Path from tempfile import NamedTemporaryFile from uuid import UUID, uuid4 from fastapi import ( APIRouter, - Body, Depends, File, - Header, HTTPException, Path as ApiPath, - Query, Request, UploadFile, status, @@ -24,7 +20,7 @@ from app.auth.metadata_dependencies import ( from app.core.audit import AuditAction, log_audit_event from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository from app.services.network_import import network_update -from app.services.tjnetwork import ChangeSet, import_inp, run_inp +from app.services.tjnetwork import run_inp router = APIRouter() @@ -145,7 +141,7 @@ async def _apply_model_update(content: bytes) -> None: @router.post( - "/admin/projects/{project_id}/model/import", + "/admin/projects/{project_id}/model-imports", summary="导入桌面端水力模型", ) async def import_project_model( @@ -168,8 +164,8 @@ async def import_project_model( return {"project_id": str(project.id), "filename": filename, "result": result} -@router.post( - "/admin/projects/{project_id}/model/update", +@router.patch( + "/admin/projects/{project_id}/model-imports", summary="更新桌面端水力模型", ) async def update_project_model( @@ -190,108 +186,3 @@ async def update_project_model( action="update", ) return {"project_id": str(project.id), "filename": filename, "updated": True} - - -@router.post("/importinp/", deprecated=True) -async def legacy_import_inp( - request: Request, - network: str = Query(...), - x_project_id: UUID = Header(..., alias="X-Project-Id"), - current_user=Depends(get_current_metadata_admin), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), -): - project = await _get_active_project(x_project_id, metadata_repo) - if network != project.code: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Project scope denied", - ) - payload = await request.json() - inp_text = payload.get("inp") if isinstance(payload, dict) else None - if not isinstance(inp_text, str): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing INP content", - ) - _validate_inp_bytes(inp_text.encode("utf-8"), "model.inp") - result = import_inp(network, ChangeSet({"inp": inp_text})) - await _audit_model_change( - request=request, - current_user=current_user, - metadata_repo=metadata_repo, - project_id=project.id, - action="import", - ) - return result - - -@router.post("/uploadinp/", deprecated=True) -async def legacy_upload_inp( - request: Request, - content: bytes = Body(...), - name: str = Query(...), - x_project_id: UUID = Header(..., alias="X-Project-Id"), - current_user=Depends(get_current_metadata_admin), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), -) -> bool: - project = await _get_active_project(x_project_id, metadata_repo) - safe_name = Path(name).name - if safe_name != name: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid INP file name", - ) - _validate_inp_bytes(content, safe_name) - target_dir = Path("data") - target_dir.mkdir(parents=True, exist_ok=True) - (target_dir / safe_name).write_bytes(content) - await _audit_model_change( - request=request, - current_user=current_user, - metadata_repo=metadata_repo, - project_id=project.id, - action="upload", - ) - return True - - -@router.post("/network_project/", deprecated=True) -async def legacy_network_project( - request: Request, - file: UploadFile = File(...), - x_project_id: UUID = Header(..., alias="X-Project-Id"), - current_user=Depends(get_current_metadata_admin), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), -): - project = await _get_active_project(x_project_id, metadata_repo) - content, _ = await _read_upload(file) - result = await _run_uploaded_inp(content) - await _audit_model_change( - request=request, - current_user=current_user, - metadata_repo=metadata_repo, - project_id=project.id, - action="import", - ) - return result - - -@router.post("/network_update/", deprecated=True) -async def legacy_network_update( - request: Request, - file: UploadFile = File(...), - x_project_id: UUID = Header(..., alias="X-Project-Id"), - current_user=Depends(get_current_metadata_admin), - metadata_repo: MetadataRepository = Depends(get_metadata_repository), -) -> str: - project = await _get_active_project(x_project_id, metadata_repo) - content, _ = await _read_upload(file) - await _apply_model_update(content) - await _audit_model_change( - request=request, - current_user=current_user, - metadata_repo=metadata_repo, - project_id=project.id, - action="update", - ) - return json.dumps({"message": "管网更新成功"}) diff --git a/app/api/v1/endpoints/network/demands.py b/app/api/v1/endpoints/network/demands.py index 96efa9f..ac63be1 100644 --- a/app/api/v1/endpoints/network/demands.py +++ b/app/api/v1/endpoints/network/demands.py @@ -18,7 +18,7 @@ router = APIRouter() ############################################################ @router.get( - "/getdemandschema", + "/network-schemas/demand", summary="获取需水量属性架构", description="获取指定水网中需水量(Demand)的属性架构定义" ) @@ -32,7 +32,7 @@ async def fastapi_get_demand_schema(network: str = Query(..., description="管 @router.get( - "/getdemandproperties/", + "/demands/properties", summary="获取需水量属性", description="获取指定水网中节点的需水量属性信息" ) @@ -49,8 +49,8 @@ async def fastapi_get_demand_properties( # example: set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}]})) -@router.post( - "/setdemandproperties/", +@router.patch( + "/demands/properties", response_model=None, summary="设置需水量属性", description="设置指定水网中节点的需水量属性信息" @@ -72,8 +72,8 @@ async def fastapi_set_demand_properties( ############################################################ # water distribution 36.[Water Distribution] ############################################################ -@router.get( - "/calculatedemandtonodes/", +@router.post( + "/demands/to-nodes", summary="计算需水量到节点分配", description="将总需水量按指定方式分配到多个节点" ) @@ -97,8 +97,8 @@ async def fastapi_calculate_demand_to_nodes( nodes = props["nodes"] return calculate_demand_to_nodes(network, demand, nodes) -@router.get( - "/calculatedemandtoregion/", +@router.post( + "/demands/to-region", summary="计算需水量到区域分配", description="将总需水量按区域特征分配到该区域内的节点" ) @@ -122,8 +122,8 @@ async def fastapi_calculate_demand_to_region( region = props["region"] return calculate_demand_to_region(network, demand, region) -@router.get( - "/calculatedemandtonetwork/", +@router.post( + "/demands/to-network", summary="计算需水量到整网分配", description="将需水量均匀分配到整个水网的所有需水节点" ) diff --git a/app/api/v1/endpoints/network/general.py b/app/api/v1/endpoints/network/general.py index 894739a..0873800 100644 --- a/app/api/v1/endpoints/network/general.py +++ b/app/api/v1/endpoints/network/general.py @@ -45,7 +45,7 @@ router = APIRouter() ############################################################ @router.get( - "/isnode/", + "/nodes/existence", summary="检查节点有效性", description="检查指定ID是否为水网中的有效节点" ) @@ -57,7 +57,7 @@ async def fastapi_is_node( return is_node(network, node) @router.get( - "/isjunction/", + "/junctions/existence", summary="检查是否为接点", description="检查指定ID是否为水网中的接点(需求点)" ) @@ -69,7 +69,7 @@ async def fastapi_is_junction( return is_junction(network, node) @router.get( - "/isreservoir/", + "/reservoirs/existence", summary="检查是否为水源", description="检查指定ID是否为水网中的水源(水库/河流)" ) @@ -81,7 +81,7 @@ async def fastapi_is_reservoir( return is_reservoir(network, node) @router.get( - "/istank/", + "/tanks/existence", summary="检查是否为蓄水池", description="检查指定ID是否为水网中的蓄水池" ) @@ -93,7 +93,7 @@ async def fastapi_is_tank( return is_tank(network, node) @router.get( - "/islink/", + "/links/existence", summary="检查管线有效性", description="检查指定ID是否为水网中的有效管线" ) @@ -105,7 +105,7 @@ async def fastapi_is_link( return is_link(network, link) @router.get( - "/ispipe/", + "/pipes/existence", summary="检查是否为管道", description="检查指定ID是否为水网中的管道" ) @@ -117,7 +117,7 @@ async def fastapi_is_pipe( return is_pipe(network, link) @router.get( - "/ispump/", + "/pumps/existence", summary="检查是否为泵", description="检查指定ID是否为水网中的泵" ) @@ -129,7 +129,7 @@ async def fastapi_is_pump( return is_pump(network, link) @router.get( - "/isvalve/", + "/valves/existence", summary="检查是否为阀门", description="检查指定ID是否为水网中的阀门" ) @@ -141,7 +141,7 @@ async def fastapi_is_valve( return is_valve(network, link) @router.get( - "/getnodetype/", + "/node-types", summary="获取节点类型", description="获取指定节点的类型(接点/水源/蓄水池)" ) @@ -153,7 +153,7 @@ async def fastapi_get_node_type( return get_node_type(network, node) @router.get( - "/getlinktype/", + "/link-types", summary="获取管线类型", description="获取指定管线的类型(管道/泵/阀门)" ) @@ -165,7 +165,7 @@ async def fastapi_get_link_type( return get_link_type(network, link) @router.get( - "/getelementtype/", + "/element-types", summary="获取元素类型", description="获取指定元素的类型(节点或管线)" ) @@ -177,7 +177,7 @@ async def fastapi_get_element_type( return get_element_type(network, element) @router.get( - "/getelementtypevalue/", + "/element-type-values", summary="获取元素类型值", description="获取指定元素的类型数值标识" ) @@ -189,7 +189,7 @@ async def fastapi_get_element_type_value( return get_element_type_value(network, element) @router.get( - "/getnodes/", + "/nodes", summary="获取所有节点", description="获取指定水网中的所有节点ID列表" ) @@ -198,7 +198,7 @@ async def fastapi_get_nodes(network: str = Query(..., description="管网名称 return get_nodes(network) @router.get( - "/getlinks/", + "/links", summary="获取所有管线", description="获取指定水网中的所有管线ID列表" ) @@ -207,7 +207,7 @@ async def fastapi_get_links(network: str = Query(..., description="管网名称 return get_links(network) @router.get( - "/getnodelinks/", + "/node-links", summary="获取节点的关联管线", description="获取指定节点连接的所有管线ID列表" ) @@ -223,7 +223,7 @@ def get_node_links_endpoint( ############################################################ @router.get( - "/getnodeproperties/", + "/node-properties", summary="获取节点属性", description="获取指定节点的所有属性信息" ) @@ -235,7 +235,7 @@ async def fast_get_node_properties( return get_node_properties(network, node) @router.get( - "/getlinkproperties/", + "/link-properties", summary="获取管线属性", description="获取指定管线的所有属性信息" ) @@ -247,7 +247,7 @@ async def fast_get_link_properties( return get_link_properties(network, link) @router.get( - "/getscadaproperties/", + "/scada-properties", summary="获取SCADA点属性", description="获取指定SCADA点的属性信息" ) @@ -259,7 +259,7 @@ async def fast_get_scada_properties( return get_scada_info(network, scada) @router.get( - "/getallscadaproperties/", + "/all-scada-properties", summary="获取所有SCADA点属性", description="获取指定水网中所有SCADA点的属性信息" ) @@ -270,7 +270,7 @@ async def fast_get_all_scada_properties( return get_all_scada_info(network) @router.get( - "/getelementpropertieswithtype/", + "/element-properties-with-types", summary="获取指定类型元素属性", description="获取指定类型的元素属性信息" ) @@ -283,7 +283,7 @@ async def fast_get_element_properties_with_type( return get_element_properties_with_type(network, elementtype, element) @router.get( - "/getelementproperties/", + "/element-properties", summary="获取元素属性", description="获取指定元素的属性信息" ) @@ -299,7 +299,7 @@ async def fast_get_element_properties( ############################################################ @router.get( - "/gettitleschema/", + "/title-schemas", summary="获取标题属性架构", description="获取指定水网的标题(标题)属性架构定义" ) @@ -310,7 +310,7 @@ async def fast_get_title_schema( return get_title_schema(network) @router.get( - "/gettitle/", + "/titles", summary="获取水网标题属性", description="获取指定水网的标题(Title)信息" ) @@ -318,8 +318,8 @@ async def fast_get_title(network: str = Query(..., description="管网名称( """获取水网的标题属性。""" return get_title(network) -@router.get( - "/settitle/", +@router.patch( + "/titles", response_model=None, summary="设置水网标题属性", description="设置指定水网的标题(Title)信息" @@ -337,7 +337,7 @@ async def fastapi_set_title( ############################################################ @router.get( - "/getstatusschema", + "/status-schemas", summary="获取状态属性架构", description="获取指定水网的状态(Status)属性架构定义" ) @@ -348,7 +348,7 @@ async def fastapi_get_status_schema( return get_status_schema(network) @router.get( - "/getstatus/", + "/status", summary="获取管线状态", description="获取指定管线的状态信息" ) @@ -359,8 +359,8 @@ async def fastapi_get_status( """获取管线的状态属性。""" return get_status(network, link) -@router.post( - "/setstatus/", +@router.patch( + "/status-properties", response_model=None, summary="设置管线状态", description="设置指定管线的状态信息" @@ -379,8 +379,8 @@ async def fastapi_set_status_properties( # General Deletion ############################################################ -@router.post( - "/deletenode/", +@router.delete( + "/nodes", response_model=None, summary="删除节点", description="删除指定的节点(接点/水源/蓄水池)" @@ -399,8 +399,8 @@ async def fastapi_delete_node( return delete_tank(network, ChangeSet(ps)) return ChangeSet() # Should probably raise error or return empty -@router.post( - "/deletelink/", +@router.delete( + "/links", response_model=None, summary="删除管线", description="删除指定的管线(管道/泵/阀门)" diff --git a/app/api/v1/endpoints/network/geometry.py b/app/api/v1/endpoints/network/geometry.py index 8acf2f4..3b7a90f 100644 --- a/app/api/v1/endpoints/network/geometry.py +++ b/app/api/v1/endpoints/network/geometry.py @@ -31,7 +31,7 @@ router = APIRouter() # return set_coord(network, ChangeSet(props)) @router.get( - "/getnodecoord/", + "/node-coords", summary="获取节点坐标", description="获取指定节点的地理坐标(X, Y)" ) @@ -44,7 +44,7 @@ async def fastapi_get_node_coord( # Additional geometry queries found in main.py logic (implicit or explicit) @router.get( - "/getnetworkinextent/", + "/network-in-extents", summary="获取范围内的网络元素", description="获取指定地理范围内的网络节点和管线" ) @@ -59,7 +59,7 @@ async def fastapi_get_network_in_extent( return get_network_in_extent(network, x1, y1, x2, y2) @router.get( - "/getmajornodecoords/", + "/majornode-coords", summary="获取主要节点坐标", description="获取直径大于等于指定值的节点坐标" ) @@ -71,7 +71,7 @@ async def fastapi_get_majornode_coords( return get_major_node_coords(network, diameter) @router.get( - "/getmajorpipenodes/", + "/major-pipe-nodes", summary="获取主要管道节点", description="获取直径大于等于指定值的管道的节点ID" ) @@ -83,7 +83,7 @@ async def fastapi_get_major_pipe_nodes( return get_major_pipe_nodes(network, diameter) @router.get( - "/getnetworklinknodes/", + "/network-link-nodes", summary="获取网络管线节点", description="获取指定水网所有管线的起点和终点节点" ) diff --git a/app/api/v1/endpoints/network/junctions.py b/app/api/v1/endpoints/network/junctions.py index a7eff35..4959dcd 100644 --- a/app/api/v1/endpoints/network/junctions.py +++ b/app/api/v1/endpoints/network/junctions.py @@ -13,7 +13,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getjunctionschema", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。") +@router.get("/network-schemas/junction", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。") async def fast_get_junction_schema( network: str = Query(..., description="管网名称(或数据库名称)") ) -> dict[str, dict[str, Any]]: @@ -27,7 +27,7 @@ async def fast_get_junction_schema( """ return get_junction_schema(network) -@router.post("/addjunction/", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。") +@router.post("/junctions", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。") async def fastapi_add_junction( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -51,7 +51,7 @@ async def fastapi_add_junction( ps = {"id": junction, "x": x, "y": y, "elevation": z} return add_junction(network, ChangeSet(ps)) -@router.post("/deletejunction/", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。") +@router.delete("/junctions", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。") async def fastapi_delete_junction( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -69,7 +69,7 @@ async def fastapi_delete_junction( ps = {"id": junction} return delete_junction(network, ChangeSet(ps)) -@router.get("/getjunctionelevation/", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。") +@router.get("/junctions/elevation", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。") async def fastapi_get_junction_elevation( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -87,7 +87,7 @@ async def fastapi_get_junction_elevation( ps = get_junction(network, junction) return ps["elevation"] -@router.get("/getjunctionx/", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。") +@router.get("/junctions/x", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。") async def fastapi_get_junction_x( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -105,7 +105,7 @@ async def fastapi_get_junction_x( ps = get_junction(network, junction) return ps["x"] -@router.get("/getjunctiony/", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。") +@router.get("/junctions/y", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。") async def fastapi_get_junction_y( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -123,7 +123,7 @@ async def fastapi_get_junction_y( ps = get_junction(network, junction) return ps["y"] -@router.get("/getjunctioncoord/", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。") +@router.get("/junctions/coord", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。") async def fastapi_get_junction_coord( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -142,7 +142,7 @@ async def fastapi_get_junction_coord( coord = {"x": ps["x"], "y": ps["y"]} return coord -@router.get("/getjunctiondemand/", summary="获取节点需水量", description="获取指定节点的需水量。") +@router.get("/junctions/demand", summary="获取节点需水量", description="获取指定节点的需水量。") async def fastapi_get_junction_demand( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -160,7 +160,7 @@ async def fastapi_get_junction_demand( ps = get_junction(network, junction) return ps["demand"] -@router.get("/getjunctionpattern/", summary="获取节点需水模式", description="获取指定节点的需水模式标识。") +@router.get("/junctions/pattern", summary="获取节点需水模式", description="获取指定节点的需水模式标识。") async def fastapi_get_junction_pattern( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -178,7 +178,7 @@ async def fastapi_get_junction_pattern( ps = get_junction(network, junction) return ps["pattern"] -@router.post("/setjunctionelevation/", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。") +@router.patch("/junctions/elevation", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。") async def fastapi_set_junction_elevation( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -198,7 +198,7 @@ async def fastapi_set_junction_elevation( ps = {"id": junction, "elevation": elevation} return set_junction(network, ChangeSet(ps)) -@router.post("/setjunctionx/", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。") +@router.patch("/junctions/x", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。") async def fastapi_set_junction_x( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -218,7 +218,7 @@ async def fastapi_set_junction_x( ps = {"id": junction, "x": x} return set_junction(network, ChangeSet(ps)) -@router.post("/setjunctiony/", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。") +@router.patch("/junctions/y", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。") async def fastapi_set_junction_y( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -238,7 +238,7 @@ async def fastapi_set_junction_y( ps = {"id": junction, "y": y} return set_junction(network, ChangeSet(ps)) -@router.post("/setjunctioncoord/", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。") +@router.patch("/junctions/coord", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。") async def fastapi_set_junction_coord( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -260,7 +260,7 @@ async def fastapi_set_junction_coord( ps = {"id": junction, "x": x, "y": y} return set_junction(network, ChangeSet(ps)) -@router.post("/setjunctiondemand/", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。") +@router.patch("/junctions/demand", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。") async def fastapi_set_junction_demand( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -280,7 +280,7 @@ async def fastapi_set_junction_demand( ps = {"id": junction, "demand": demand} return set_junction(network, ChangeSet(ps)) -@router.post("/setjunctionpattern/", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。") +@router.patch("/junctions/pattern", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。") async def fastapi_set_junction_pattern( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), @@ -300,7 +300,7 @@ async def fastapi_set_junction_pattern( ps = {"id": junction, "pattern": pattern} return set_junction(network, ChangeSet(ps)) -@router.get("/getjunctionproperties/", summary="获取节点属性", description="获取指定节点的所有属性信息。") +@router.get("/junctions/properties", summary="获取节点属性", description="获取指定节点的所有属性信息。") async def fastapi_get_junction_properties( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID") @@ -317,7 +317,7 @@ async def fastapi_get_junction_properties( """ return get_junction(network, junction) -@router.get("/getalljunctionproperties/", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。") +@router.get("/junctions", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。") async def fastapi_get_all_junction_properties( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -337,7 +337,7 @@ async def fastapi_get_all_junction_properties( results = get_all_junctions(network) return results -@router.post("/setjunctionproperties/", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。") +@router.patch("/junctions/properties", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。") async def fastapi_set_junction_properties( network: str = Query(..., description="管网名称(或数据库名称)"), junction: str = Query(..., description="节点 ID"), diff --git a/app/api/v1/endpoints/network/pipes.py b/app/api/v1/endpoints/network/pipes.py index 7ef513d..d65a83b 100644 --- a/app/api/v1/endpoints/network/pipes.py +++ b/app/api/v1/endpoints/network/pipes.py @@ -14,7 +14,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getpipeschema", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型") +@router.get("/network-schemas/pipe", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型") async def fastapi_get_pipe_schema( network: str = Query(..., description="管网名称(或数据库名称)") ) -> dict[str, dict[str, Any]]: @@ -29,7 +29,7 @@ async def fastapi_get_pipe_schema( """ return get_pipe_schema(network) -@router.post("/addpipe/", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等") +@router.post("/pipes", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等") async def fastapi_add_pipe( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道标识符"), @@ -70,7 +70,7 @@ async def fastapi_add_pipe( } return add_pipe(network, ChangeSet(ps)) -@router.post("/deletepipe/", response_model=None, summary="删除管道", description="从网络中删除指定的管道") +@router.delete("/pipes", response_model=None, summary="删除管道", description="从网络中删除指定的管道") async def fastapi_delete_pipe( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="要删除的管道ID") @@ -88,7 +88,7 @@ async def fastapi_delete_pipe( ps = {"id": pipe} return delete_pipe(network, ChangeSet(ps)) -@router.get("/getpipenode1/", summary="获取管道起始节点", description="获取指定管道的起始节点ID") +@router.get("/pipes/node1", summary="获取管道起始节点", description="获取指定管道的起始节点ID") async def fastapi_get_pipe_node1( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -106,7 +106,7 @@ async def fastapi_get_pipe_node1( ps = get_pipe(network, pipe) return ps["node1"] -@router.get("/getpipenode2/", summary="获取管道终止节点", description="获取指定管道的终止节点ID") +@router.get("/pipes/node2", summary="获取管道终止节点", description="获取指定管道的终止节点ID") async def fastapi_get_pipe_node2( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -124,7 +124,7 @@ async def fastapi_get_pipe_node2( ps = get_pipe(network, pipe) return ps["node2"] -@router.get("/getpipelength/", summary="获取管道长度", description="获取指定管道的长度") +@router.get("/pipes/length", summary="获取管道长度", description="获取指定管道的长度") async def fastapi_get_pipe_length( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -142,7 +142,7 @@ async def fastapi_get_pipe_length( ps = get_pipe(network, pipe) return ps["length"] -@router.get("/getpipediameter/", summary="获取管道管径", description="获取指定管道的管径") +@router.get("/pipes/diameter", summary="获取管道管径", description="获取指定管道的管径") async def fastapi_get_pipe_diameter( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -160,7 +160,7 @@ async def fastapi_get_pipe_diameter( ps = get_pipe(network, pipe) return ps["diameter"] -@router.get("/getpiperoughness/", summary="获取管道粗糙度", description="获取指定管道的粗糙度") +@router.get("/pipes/roughness", summary="获取管道粗糙度", description="获取指定管道的粗糙度") async def fastapi_get_pipe_roughness( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -178,7 +178,7 @@ async def fastapi_get_pipe_roughness( ps = get_pipe(network, pipe) return ps["roughness"] -@router.get("/getpipeminorloss/", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数") +@router.get("/pipes/minor-loss", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数") async def fastapi_get_pipe_minor_loss( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -196,7 +196,7 @@ async def fastapi_get_pipe_minor_loss( ps = get_pipe(network, pipe) return ps["minor_loss"] -@router.get("/getpipestatus/", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)") +@router.get("/pipes/status", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)") async def fastapi_get_pipe_status( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -214,7 +214,7 @@ async def fastapi_get_pipe_status( ps = get_pipe(network, pipe) return ps["status"] -@router.post("/setpipenode1/", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点") +@router.patch("/pipes/node1", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点") async def fastapi_set_pipe_node1( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -234,7 +234,7 @@ async def fastapi_set_pipe_node1( ps = {"id": pipe, "node1": node1} return set_pipe(network, ChangeSet(ps)) -@router.post("/setpipenode2/", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点") +@router.patch("/pipes/node2", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点") async def fastapi_set_pipe_node2( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -254,7 +254,7 @@ async def fastapi_set_pipe_node2( ps = {"id": pipe, "node2": node2} return set_pipe(network, ChangeSet(ps)) -@router.post("/setpipelength/", response_model=None, summary="设置管道长度", description="设置指定管道的长度") +@router.patch("/pipes/length", response_model=None, summary="设置管道长度", description="设置指定管道的长度") async def fastapi_set_pipe_length( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -274,7 +274,7 @@ async def fastapi_set_pipe_length( ps = {"id": pipe, "length": length} return set_pipe(network, ChangeSet(ps)) -@router.post("/setpipediameter/", response_model=None, summary="设置管道管径", description="设置指定管道的管径") +@router.patch("/pipes/diameter", response_model=None, summary="设置管道管径", description="设置指定管道的管径") async def fastapi_set_pipe_diameter( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -294,7 +294,7 @@ async def fastapi_set_pipe_diameter( ps = {"id": pipe, "diameter": diameter} return set_pipe(network, ChangeSet(ps)) -@router.post("/setpiperoughness/", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度") +@router.patch("/pipes/roughness", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度") async def fastapi_set_pipe_roughness( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -314,7 +314,7 @@ async def fastapi_set_pipe_roughness( ps = {"id": pipe, "roughness": roughness} return set_pipe(network, ChangeSet(ps)) -@router.post("/setpipeminorloss/", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数") +@router.patch("/pipes/minor-loss", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数") async def fastapi_set_pipe_minor_loss( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -334,7 +334,7 @@ async def fastapi_set_pipe_minor_loss( ps = {"id": pipe, "minor_loss": minor_loss} return set_pipe(network, ChangeSet(ps)) -@router.post("/setpipestatus/", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)") +@router.patch("/pipes/status", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)") async def fastapi_set_pipe_status( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), @@ -354,7 +354,7 @@ async def fastapi_set_pipe_status( ps = {"id": pipe, "status": status} return set_pipe(network, ChangeSet(ps)) -@router.get("/getpipeproperties/", summary="获取管道属性", description="获取指定管道的所有属性信息") +@router.get("/pipes/properties", summary="获取管道属性", description="获取指定管道的所有属性信息") async def fastapi_get_pipe_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID") @@ -371,7 +371,7 @@ async def fastapi_get_pipe_properties( """ return get_pipe(network, pipe) -@router.get("/getallpipeproperties/", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表") +@router.get("/pipes", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表") async def fastapi_get_all_pipe_properties( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -389,7 +389,7 @@ async def fastapi_get_all_pipe_properties( results = get_all_pipes(network) return results -@router.post("/setpipeproperties/", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性") +@router.patch("/pipes/properties", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性") async def fastapi_set_pipe_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pipe: str = Query(..., description="管道ID"), diff --git a/app/api/v1/endpoints/network/pumps.py b/app/api/v1/endpoints/network/pumps.py index 8b79f53..d947f67 100644 --- a/app/api/v1/endpoints/network/pumps.py +++ b/app/api/v1/endpoints/network/pumps.py @@ -13,7 +13,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getpumpschema", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型") +@router.get("/network-schemas/pump", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型") async def fastapi_get_pump_schema( network: str = Query(..., description="管网名称(或数据库名称)") ) -> dict[str, dict[str, Any]]: @@ -28,7 +28,7 @@ async def fastapi_get_pump_schema( """ return get_pump_schema(network) -@router.post("/addpump/", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等") +@router.post("/pumps", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等") async def fastapi_add_pump( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵标识符"), @@ -52,7 +52,7 @@ async def fastapi_add_pump( ps = {"id": pump, "node1": node1, "node2": node2, "power": power} return add_pump(network, ChangeSet(ps)) -@router.post("/deletepump/", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵") +@router.delete("/pumps", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵") async def fastapi_delete_pump( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="要删除的水泵ID") @@ -70,7 +70,7 @@ async def fastapi_delete_pump( ps = {"id": pump} return delete_pump(network, ChangeSet(ps)) -@router.get("/getpumpnode1/", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID") +@router.get("/pumps/node1", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID") async def fastapi_get_pump_node1( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵ID") @@ -88,7 +88,7 @@ async def fastapi_get_pump_node1( ps = get_pump(network, pump) return ps["node1"] -@router.get("/getpumpnode2/", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID") +@router.get("/pumps/node2", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID") async def fastapi_get_pump_node2( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵ID") @@ -106,7 +106,7 @@ async def fastapi_get_pump_node2( ps = get_pump(network, pump) return ps["node2"] -@router.post("/setpumpnode1/", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点") +@router.patch("/pumps/node1", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点") async def fastapi_set_pump_node1( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵ID"), @@ -126,7 +126,7 @@ async def fastapi_set_pump_node1( ps = {"id": pump, "node1": node1} return set_pump(network, ChangeSet(ps)) -@router.post("/setpumpnode2/", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点") +@router.patch("/pumps/node2", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点") async def fastapi_set_pump_node2( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵ID"), @@ -146,7 +146,7 @@ async def fastapi_set_pump_node2( ps = {"id": pump, "node2": node2} return set_pump(network, ChangeSet(ps)) -@router.get("/getpumpproperties/", summary="获取水泵属性", description="获取指定水泵的所有属性信息") +@router.get("/pumps/properties", summary="获取水泵属性", description="获取指定水泵的所有属性信息") async def fastapi_get_pump_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵ID") @@ -163,7 +163,7 @@ async def fastapi_get_pump_properties( """ return get_pump(network, pump) -@router.get("/getallpumpproperties/", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表") +@router.get("/pumps", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表") async def fastapi_get_all_pump_properties( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -181,7 +181,7 @@ async def fastapi_get_all_pump_properties( results = get_all_pumps(network) return results -@router.post("/setpumpproperties/", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性") +@router.patch("/pumps/properties", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性") async def fastapi_set_pump_properties( network: str = Query(..., description="管网名称(或数据库名称)"), pump: str = Query(..., description="水泵ID"), diff --git a/app/api/v1/endpoints/network/regions.py b/app/api/v1/endpoints/network/regions.py index 4d1b564..833852b 100644 --- a/app/api/v1/endpoints/network/regions.py +++ b/app/api/v1/endpoints/network/regions.py @@ -45,7 +45,7 @@ router = APIRouter() ############################################################ @router.get( - "/getregionschema/", + "/network-schemas/region", summary="获取区域属性架构", description="获取指定水网的区域属性架构定义" ) @@ -56,7 +56,7 @@ async def fastapi_get_region_schema( return get_region_schema(network) @router.get( - "/getregion/", + "/regions/detail", summary="获取区域信息", description="获取指定ID的区域详细信息" ) @@ -67,8 +67,8 @@ async def fastapi_get_region( """获取区域的详细信息。""" return get_region(network, id) -@router.post( - "/setregion/", +@router.patch( + "/regions", response_model=None, summary="设置区域属性", description="修改指定区域的属性信息" @@ -82,7 +82,7 @@ async def fastapi_set_region( return set_region(network, ChangeSet(props)) @router.post( - "/addregion/", + "/regions", response_model=None, summary="添加新区域", description="向水网添加一个新的区域" @@ -95,8 +95,8 @@ async def fastapi_add_region( props = await req.json() return add_region(network, ChangeSet(props)) -@router.post( - "/deleteregion/", +@router.delete( + "/regions", response_model=None, summary="删除区域", description="删除指定的区域" @@ -114,8 +114,8 @@ async def fastapi_delete_region( # district_metering_area 33 ############################################################ -@router.get( - "/calculatedistrictmeteringareaforregion/", +@router.post( + "/district-metering-areas/for-region", summary="计算区域内DMA分区", description="为指定区域计算区域计量(DMA)分区方案" ) @@ -141,8 +141,8 @@ async def fastapi_calculate_district_metering_area_for_region( network, region, part_count, part_type ) -@router.get( - "/calculatedistrictmeteringareafornetwork/", +@router.post( + "/district-metering-areas/for-network", summary="计算整网DMA分区", description="为整个水网计算区域计量(DMA)分区方案" ) @@ -165,7 +165,7 @@ async def fastapi_calculate_district_metering_area_for_network( return calculate_district_metering_area_for_network(network, part_count, part_type) @router.get( - "/getdistrictmeteringareaschema/", + "/network-schemas/district-metering-area", summary="获取DMA属性架构", description="获取指定水网的区域计量(DMA)属性架构定义" ) @@ -176,7 +176,7 @@ async def fastapi_get_district_metering_area_schema( return get_district_metering_area_schema(network) @router.get( - "/getdistrictmeteringarea/", + "/district-metering-areas/detail", summary="获取DMA信息", description="获取指定ID的区域计量(DMA)详细信息" ) @@ -187,8 +187,8 @@ async def fastapi_get_district_metering_area( """获取DMA的详细信息。""" return get_district_metering_area(network, id) -@router.post( - "/setdistrictmeteringarea/", +@router.patch( + "/district-metering-areas", response_model=None, summary="设置DMA属性", description="修改指定DMA的属性信息" @@ -202,7 +202,7 @@ async def fastapi_set_district_metering_area( return set_district_metering_area(network, ChangeSet(props)) @router.post( - "/adddistrictmeteringarea/", + "/district-metering-areas", response_model=None, summary="添加新DMA", description="向水网添加一个新的区域计量(DMA)" @@ -222,8 +222,8 @@ async def fastapi_add_district_metering_area( props["boundary"] = newBoundary return add_district_metering_area(network, ChangeSet(props)) -@router.post( - "/deletedistrictmeteringarea/", +@router.delete( + "/district-metering-areas", response_model=None, summary="删除DMA", description="删除指定的区域计量(DMA)" @@ -237,7 +237,7 @@ async def fastapi_delete_district_metering_area( return delete_district_metering_area(network, ChangeSet(props)) @router.get( - "/getalldistrictmeteringareaids/", + "/district-metering-areas/ids", summary="获取所有DMA ID", description="获取指定水网中所有DMA的ID列表" ) @@ -248,7 +248,7 @@ async def fastapi_get_all_district_metering_area_ids( return get_all_district_metering_area_ids(network) @router.get( - "/getalldistrictmeteringareas/", + "/district-metering-areas", summary="获取所有DMA", description="获取指定水网中所有DMA的详细信息" ) @@ -259,7 +259,7 @@ async def getalldistrictmeteringareas( return get_all_district_metering_areas(network) @router.post( - "/generatedistrictmeteringarea/", + "/district-metering-area-generation-runs", response_model=None, summary="生成DMA分区", description="根据参数自动生成水网的DMA分区方案" @@ -276,7 +276,7 @@ async def fastapi_generate_district_metering_area( ) @router.post( - "/generatesubdistrictmeteringarea/", + "/sub-district-metering-areas", response_model=None, summary="生成DMA子分区", description="为指定DMA生成子DMA分区" @@ -298,8 +298,8 @@ async def fastapi_generate_sub_district_metering_area( # service_area 34 ############################################################ -@router.get( - "/calculateservicearea/", +@router.post( + "/service-area-calculations", summary="计算服务区", description="计算指定水网的服务区分区,返回全部时间步结果" ) @@ -310,7 +310,7 @@ async def fastapi_calculate_service_area( return calculate_service_area(network) @router.get( - "/getserviceareaschema/", + "/network-schemas/service-area", summary="获取服务区属性架构", description="获取指定水网的服务区属性架构定义" ) @@ -321,7 +321,7 @@ async def fastapi_get_service_area_schema( return get_service_area_schema(network) @router.get( - "/getservicearea/", + "/service-areas/detail", summary="获取服务区信息", description="获取指定ID的服务区详细信息" ) @@ -332,8 +332,8 @@ async def fastapi_get_service_area( """获取服务区的详细信息。""" return get_service_area(network, id) -@router.post( - "/setservicearea/", +@router.patch( + "/service-areas", response_model=None, summary="设置服务区属性", description="修改指定服务区的属性信息" @@ -347,7 +347,7 @@ async def fastapi_set_service_area( return set_service_area(network, ChangeSet(props)) @router.post( - "/addservicearea/", + "/service-areas", response_model=None, summary="添加新服务区", description="向水网添加一个新的服务区" @@ -360,8 +360,8 @@ async def fastapi_add_service_area( props = await req.json() return add_service_area(network, ChangeSet(props)) -@router.post( - "/deleteservicearea/", +@router.delete( + "/service-areas", response_model=None, summary="删除服务区", description="删除指定的服务区" @@ -375,7 +375,7 @@ async def fastapi_delete_service_area( return delete_service_area(network, ChangeSet(props)) @router.get( - "/getallserviceareas/", + "/service-areas", summary="获取所有服务区", description="获取指定水网中的所有服务区信息" ) @@ -386,7 +386,7 @@ async def fastapi_get_all_service_areas( return get_all_service_areas(network) @router.post( - "/generateservicearea/", + "/service-area-generation-runs", response_model=None, summary="生成服务区分区", description="根据参数自动生成水网的服务区分区" @@ -403,8 +403,8 @@ async def fastapi_generate_service_area( # virtual_district 35 ############################################################ -@router.get( - "/calculatevirtualdistrict/", +@router.post( + "/virtual-district-calculations", summary="计算虚拟分区", description="根据指定的压力监测节点作为中心节点计算虚拟分区方案" ) @@ -416,7 +416,7 @@ async def fastapi_calculate_virtual_district( return calculate_virtual_district(network, centers) @router.get( - "/getvirtualdistrictschema/", + "/network-schemas/virtual-district", summary="获取虚拟分区属性架构", description="获取指定水网的虚拟分区属性架构定义" ) @@ -427,7 +427,7 @@ async def fastapi_get_virtual_district_schema( return get_virtual_district_schema(network) @router.get( - "/getvirtualdistrict/", + "/virtual-districts/detail", summary="获取虚拟分区信息", description="获取指定ID的虚拟分区详细信息" ) @@ -438,8 +438,8 @@ async def fastapi_get_virtual_district( """获取虚拟分区的详细信息。""" return get_virtual_district(network, id) -@router.post( - "/setvirtualdistrict/", +@router.patch( + "/virtual-districts", response_model=None, summary="设置虚拟分区属性", description="修改指定虚拟分区的属性信息" @@ -453,7 +453,7 @@ async def fastapi_set_virtual_district( return set_virtual_district(network, ChangeSet(props)) @router.post( - "/addvirtualdistrict/", + "/virtual-districts", response_model=None, summary="添加新虚拟分区", description="向水网添加一个新的虚拟分区" @@ -466,8 +466,8 @@ async def fastapi_add_virtual_district( props = await req.json() return add_virtual_district(network, ChangeSet(props)) -@router.post( - "/deletevirtualdistrict/", +@router.delete( + "/virtual-districts", response_model=None, summary="删除虚拟分区", description="删除指定的虚拟分区" @@ -481,7 +481,7 @@ async def fastapi_delete_virtual_district( return delete_virtual_district(network, ChangeSet(props)) @router.get( - "/getallvirtualdistrict/", + "/virtual-districts", summary="获取所有虚拟分区", description="获取指定水网中的所有虚拟分区信息" ) @@ -492,7 +492,7 @@ async def fastapi_get_all_virtual_district( return get_all_virtual_districts(network) @router.post( - "/generatevirtualdistrict/", + "/virtual-district-generation-runs", response_model=None, summary="生成虚拟分区", description="根据参数自动生成虚拟分区方案" @@ -506,8 +506,8 @@ async def fastapi_generate_virtual_district( props = await req.json() return generate_virtual_district(network, props["centers"], inflate_delta) -@router.get( - "/calculatedistrictmeteringareafornodes/", +@router.post( + "/district-metering-areas/for-nodes", summary="计算节点DMA分区", description="为指定节点集计算区域计量(DMA)分区方案" ) diff --git a/app/api/v1/endpoints/network/reservoirs.py b/app/api/v1/endpoints/network/reservoirs.py index cf58b74..c2e29c0 100644 --- a/app/api/v1/endpoints/network/reservoirs.py +++ b/app/api/v1/endpoints/network/reservoirs.py @@ -14,7 +14,7 @@ from app.services.tjnetwork import ( router = APIRouter() @router.get( - "/getreservoirschema", + "/network-schemas/reservoir", summary="获取水库模式", description="获取指定供水网络中所有水库的模式/属性字段定义" ) @@ -35,7 +35,7 @@ async def fast_get_reservoir_schema( return get_reservoir_schema(network) @router.post( - "/addreservoir/", + "/reservoirs", response_model=None, summary="添加水库", description="在指定供水网络中添加新的水库/水源节点" @@ -65,8 +65,8 @@ async def fastapi_add_reservoir( ps = {"id": reservoir, "x": x, "y": y, "head": head} return add_reservoir(network, ChangeSet(ps)) -@router.post( - "/deletereservoir/", +@router.delete( + "/reservoirs", response_model=None, summary="删除水库", description="从指定供水网络中删除指定的水库/水源节点" @@ -91,7 +91,7 @@ async def fastapi_delete_reservoir( return delete_reservoir(network, ChangeSet(ps)) @router.get( - "/getreservoirhead/", + "/reservoirs/head", summary="获取水库水头", description="获取指定水库的供水水头/总水头值" ) @@ -115,7 +115,7 @@ async def fastapi_get_reservoir_head( return ps["head"] @router.get( - "/getreservoirpattern/", + "/reservoirs/pattern", summary="获取水库模式", description="获取指定水库的运行模式/供水模式" ) @@ -139,7 +139,7 @@ async def fastapi_get_reservoir_pattern( return ps["pattern"] @router.get( - "/getreservoirx/", + "/reservoirs/x", summary="获取水库X坐标", description="获取指定水库的X坐标位置" ) @@ -163,7 +163,7 @@ async def fastapi_get_reservoir_x( return ps["x"] @router.get( - "/getreservoiry/", + "/reservoirs/y", summary="获取水库Y坐标", description="获取指定水库的Y坐标位置" ) @@ -187,7 +187,7 @@ async def fastapi_get_reservoir_y( return ps["y"] @router.get( - "/getreservoircoord/", + "/reservoirs/coord", summary="获取水库坐标", description="获取指定水库的平面坐标(X和Y坐标)" ) @@ -211,8 +211,8 @@ async def fastapi_get_reservoir_coord( coord = {"id": reservoir, "x": ps["x"], "y": ps["y"]} return coord -@router.post( - "/setreservoirhead/", +@router.patch( + "/reservoirs/head", response_model=None, summary="设置水库水头", description="更新指定水库的供水水头/总水头值" @@ -238,8 +238,8 @@ async def fastapi_set_reservoir_head( ps = {"id": reservoir, "head": head} return set_reservoir(network, ChangeSet(ps)) -@router.post( - "/setreservoirpattern/", +@router.patch( + "/reservoirs/pattern", response_model=None, summary="设置水库模式", description="更新指定水库的运行模式/供水模式" @@ -265,8 +265,8 @@ async def fastapi_set_reservoir_pattern( ps = {"id": reservoir, "pattern": pattern} return set_reservoir(network, ChangeSet(ps)) -@router.post( - "/setreservoirx/", +@router.patch( + "/reservoirs/x", response_model=None, summary="设置水库X坐标", description="更新指定水库的X坐标位置" @@ -292,8 +292,8 @@ async def fastapi_set_reservoir_x( ps = {"id": reservoir, "x": x} return set_reservoir(network, ChangeSet(ps)) -@router.post( - "/setreservoiry/", +@router.patch( + "/reservoirs/y", response_model=None, summary="设置水库Y坐标", description="更新指定水库的Y坐标位置" @@ -319,8 +319,8 @@ async def fastapi_set_reservoir_y( ps = {"id": reservoir, "y": y} return set_reservoir(network, ChangeSet(ps)) -@router.post( - "/setreservoircoord/", +@router.patch( + "/reservoirs/coord", response_model=None, summary="设置水库坐标", description="更新指定水库的平面坐标(X和Y坐标)" @@ -349,7 +349,7 @@ async def fastapi_set_reservoir_coord( return set_reservoir(network, ChangeSet(ps)) @router.get( - "/getreservoirproperties/", + "/reservoirs/properties", summary="获取水库属性", description="获取指定水库的所有属性" ) @@ -372,7 +372,7 @@ async def fastapi_get_reservoir_properties( return get_reservoir(network, reservoir) @router.get( - "/getallreservoirproperties/", + "/reservoirs", summary="获取所有水库属性", description="获取指定供水网络中所有水库的属性" ) @@ -393,8 +393,8 @@ async def fastapi_get_all_reservoir_properties( results = get_all_reservoirs(network) return results -@router.post( - "/setreservoirproperties/", +@router.patch( + "/reservoirs/properties", response_model=None, summary="设置水库属性", description="批量更新指定水库的多个属性" diff --git a/app/api/v1/endpoints/network/tags.py b/app/api/v1/endpoints/network/tags.py index fb43228..6a0964e 100644 --- a/app/api/v1/endpoints/network/tags.py +++ b/app/api/v1/endpoints/network/tags.py @@ -16,7 +16,7 @@ router = APIRouter() ############################################################ @router.get( - "/gettagschema/", + "/network-schemas/tag", summary="获取标签属性架构", description="获取指定水网的标签(Tag)属性架构定义" ) @@ -27,7 +27,7 @@ async def fastapi_get_tag_schema( return get_tag_schema(network) @router.get( - "/gettag/", + "/tags/detail", summary="获取标签信息", description="获取指定类型和ID的标签信息" ) @@ -40,7 +40,7 @@ async def fastapi_get_tag( return get_tag(network, t_type, id) @router.get( - "/gettags/", + "/tags", summary="获取所有标签", description="获取指定水网中的所有标签信息" ) @@ -51,8 +51,8 @@ async def fastapi_get_tags( tags = get_tags(network) return tags -@router.post( - "/settag/", +@router.patch( + "/tags", response_model=None, summary="设置标签", description="为指定元素设置或修改标签信息" diff --git a/app/api/v1/endpoints/network/tanks.py b/app/api/v1/endpoints/network/tanks.py index 319a091..9d579b0 100644 --- a/app/api/v1/endpoints/network/tanks.py +++ b/app/api/v1/endpoints/network/tanks.py @@ -13,7 +13,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/gettankschema", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义") +@router.get("/network-schemas/tank", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义") async def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]: """ 获取水箱的数据结构模式。 @@ -26,7 +26,7 @@ async def fast_get_tank_schema(network: str = Query(..., description="管网名 """ return get_tank_schema(network) -@router.post("/addtank/", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None) +@router.post("/tanks", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None) async def fastapi_add_tank( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -70,7 +70,7 @@ async def fastapi_add_tank( } return add_tank(network, ChangeSet(ps)) -@router.post("/deletetank/", summary="删除水箱", description="删除指定网络中的水箱", response_model=None) +@router.delete("/tanks", summary="删除水箱", description="删除指定网络中的水箱", response_model=None) async def fastapi_delete_tank( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -88,7 +88,7 @@ async def fastapi_delete_tank( ps = {"id": tank} return delete_tank(network, ChangeSet(ps)) -@router.get("/gettankelevation/", summary="获取水箱标高", description="获取指定水箱的标高值") +@router.get("/tanks/elevation", summary="获取水箱标高", description="获取指定水箱的标高值") async def fastapi_get_tank_elevation( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -106,7 +106,7 @@ async def fastapi_get_tank_elevation( ps = get_tank(network, tank) return ps["elevation"] -@router.get("/gettankinitlevel/", summary="获取水箱初始水位", description="获取指定水箱的初始水位值") +@router.get("/tanks/init-level", summary="获取水箱初始水位", description="获取指定水箱的初始水位值") async def fastapi_get_tank_init_level( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -124,7 +124,7 @@ async def fastapi_get_tank_init_level( ps = get_tank(network, tank) return ps["init_level"] -@router.get("/gettankminlevel/", summary="获取水箱最小水位", description="获取指定水箱的最小水位值") +@router.get("/tanks/min-level", summary="获取水箱最小水位", description="获取指定水箱的最小水位值") async def fastapi_get_tank_min_level( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -142,7 +142,7 @@ async def fastapi_get_tank_min_level( ps = get_tank(network, tank) return ps["min_level"] -@router.get("/gettankmaxlevel/", summary="获取水箱最大水位", description="获取指定水箱的最大水位值") +@router.get("/tanks/max-level", summary="获取水箱最大水位", description="获取指定水箱的最大水位值") async def fastapi_get_tank_max_level( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -160,7 +160,7 @@ async def fastapi_get_tank_max_level( ps = get_tank(network, tank) return ps["max_level"] -@router.get("/gettankdiameter/", summary="获取水箱直径", description="获取指定水箱的直径值") +@router.get("/tanks/diameter", summary="获取水箱直径", description="获取指定水箱的直径值") async def fastapi_get_tank_diameter( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -178,7 +178,7 @@ async def fastapi_get_tank_diameter( ps = get_tank(network, tank) return ps["diameter"] -@router.get("/gettankminvol/", summary="获取水箱最小体积", description="获取指定水箱的最小体积值") +@router.get("/tanks/min-vol", summary="获取水箱最小体积", description="获取指定水箱的最小体积值") async def fastapi_get_tank_min_vol( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -196,7 +196,7 @@ async def fastapi_get_tank_min_vol( ps = get_tank(network, tank) return ps["min_vol"] -@router.get("/gettankvolcurve/", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识") +@router.get("/tanks/vol-curve", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识") async def fastapi_get_tank_vol_curve( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -214,7 +214,7 @@ async def fastapi_get_tank_vol_curve( ps = get_tank(network, tank) return ps["vol_curve"] -@router.get("/gettankoverflow/", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置") +@router.get("/tanks/overflow", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置") async def fastapi_get_tank_overflow( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -232,7 +232,7 @@ async def fastapi_get_tank_overflow( ps = get_tank(network, tank) return ps["overflow"] -@router.get("/gettankx/", summary="获取水箱X坐标", description="获取指定水箱的X坐标值") +@router.get("/tanks/x", summary="获取水箱X坐标", description="获取指定水箱的X坐标值") async def fastapi_get_tank_x( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -250,7 +250,7 @@ async def fastapi_get_tank_x( ps = get_tank(network, tank) return ps["x"] -@router.get("/gettanky/", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值") +@router.get("/tanks/y", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值") async def fastapi_get_tank_y( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -268,7 +268,7 @@ async def fastapi_get_tank_y( ps = get_tank(network, tank) return ps["y"] -@router.get("/gettankcoord/", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标") +@router.get("/tanks/coord", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标") async def fastapi_get_tank_coord( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -287,7 +287,7 @@ async def fastapi_get_tank_coord( coord = {"x": ps["x"], "y": ps["y"]} return coord -@router.post("/settankelevation/", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None) +@router.patch("/tanks/elevation", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None) async def fastapi_set_tank_elevation( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -307,7 +307,7 @@ async def fastapi_set_tank_elevation( ps = {"id": tank, "elevation": elevation} return set_tank(network, ChangeSet(ps)) -@router.post("/settankinitlevel/", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None) +@router.patch("/tanks/init-level", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None) async def fastapi_set_tank_init_level( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -327,7 +327,7 @@ async def fastapi_set_tank_init_level( ps = {"id": tank, "init_level": init_level} return set_tank(network, ChangeSet(ps)) -@router.post("/settankminlevel/", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None) +@router.patch("/tanks/min-level", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None) async def fastapi_set_tank_min_level( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -347,7 +347,7 @@ async def fastapi_set_tank_min_level( ps = {"id": tank, "min_level": min_level} return set_tank(network, ChangeSet(ps)) -@router.post("/settankmaxlevel/", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None) +@router.patch("/tanks/max-level", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None) async def fastapi_set_tank_max_level( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -367,7 +367,7 @@ async def fastapi_set_tank_max_level( ps = {"id": tank, "max_level": max_level} return set_tank(network, ChangeSet(ps)) -@router.post("/settankdiameter/", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None) +@router.patch("/tanks/diameter", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None) async def fastapi_set_tank_diameter( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -387,7 +387,7 @@ async def fastapi_set_tank_diameter( ps = {"id": tank, "diameter": diameter} return set_tank(network, ChangeSet(ps)) -@router.post("/settankminvol/", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None) +@router.patch("/tanks/min-vol", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None) async def fastapi_set_tank_min_vol( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -407,7 +407,7 @@ async def fastapi_set_tank_min_vol( ps = {"id": tank, "min_vol": min_vol} return set_tank(network, ChangeSet(ps)) -@router.post("/settankvolcurve/", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None) +@router.patch("/tanks/vol-curve", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None) async def fastapi_set_tank_vol_curve( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -427,7 +427,7 @@ async def fastapi_set_tank_vol_curve( ps = {"id": tank, "vol_curve": vol_curve} return set_tank(network, ChangeSet(ps)) -@router.post("/settankoverflow/", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None) +@router.patch("/tanks/overflow", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None) async def fastapi_set_tank_overflow( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -447,7 +447,7 @@ async def fastapi_set_tank_overflow( ps = {"id": tank, "overflow": overflow} return set_tank(network, ChangeSet(ps)) -@router.post("/settankx/", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None) +@router.patch("/tanks/x", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None) async def fastapi_set_tank_x( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -467,7 +467,7 @@ async def fastapi_set_tank_x( ps = {"id": tank, "x": x} return set_tank(network, ChangeSet(ps)) -@router.post("/settanky/", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None) +@router.patch("/tanks/y", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None) async def fastapi_set_tank_y( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -487,7 +487,7 @@ async def fastapi_set_tank_y( ps = {"id": tank, "y": y} return set_tank(network, ChangeSet(ps)) -@router.post("/settankcoord/", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None) +@router.patch("/tanks/coord", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None) async def fastapi_set_tank_coord( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), @@ -509,7 +509,7 @@ async def fastapi_set_tank_coord( ps = {"id": tank, "x": x, "y": y} return set_tank(network, ChangeSet(ps)) -@router.get("/gettankproperties/", summary="获取水箱属性", description="获取指定水箱的所有属性") +@router.get("/tanks/properties", summary="获取水箱属性", description="获取指定水箱的所有属性") async def fastapi_get_tank_properties( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID") @@ -526,7 +526,7 @@ async def fastapi_get_tank_properties( """ return get_tank(network, tank) -@router.get("/getalltankproperties/", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性") +@router.get("/tanks", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性") async def fastapi_get_all_tank_properties( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -544,7 +544,7 @@ async def fastapi_get_all_tank_properties( results = get_all_tanks(network) return results -@router.post("/settankproperties/", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None) +@router.patch("/tanks/properties", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None) async def fastapi_set_tank_properties( network: str = Query(..., description="管网名称(或数据库名称)"), tank: str = Query(..., description="水箱ID"), diff --git a/app/api/v1/endpoints/network/valves.py b/app/api/v1/endpoints/network/valves.py index b6745b8..43acf30 100644 --- a/app/api/v1/endpoints/network/valves.py +++ b/app/api/v1/endpoints/network/valves.py @@ -15,7 +15,7 @@ from app.services.tjnetwork import ( router = APIRouter() @router.get( - "/getvalveschema", + "/network-schemas/valve", summary="获取阀门架构", description="获取指定水网中所有阀门的架构和字段定义", ) @@ -30,7 +30,7 @@ async def fastapi_get_valve_schema( return get_valve_schema(network) @router.post( - "/addvalve/", + "/valves", response_model=None, summary="添加阀门", description="在指定的水网中添加新的阀门", @@ -62,8 +62,8 @@ async def fastapi_add_valve( return add_valve(network, ChangeSet(ps)) -@router.post( - "/deletevalve/", +@router.delete( + "/valves", response_model=None, summary="删除阀门", description="从指定的水网中删除指定的阀门", @@ -81,7 +81,7 @@ async def fastapi_delete_valve( return delete_valve(network, ChangeSet(ps)) @router.get( - "/getvalvenode1/", + "/valves/node1", summary="获取阀门起点节点", description="获取指定阀门连接的起点节点ID", ) @@ -98,7 +98,7 @@ async def fastapi_get_valve_node1( return ps["node1"] @router.get( - "/getvalvenode2/", + "/valves/node2", summary="获取阀门终点节点", description="获取指定阀门连接的终点节点ID", ) @@ -115,7 +115,7 @@ async def fastapi_get_valve_node2( return ps["node2"] @router.get( - "/getvalvediameter/", + "/valves/diameter", summary="获取阀门直径", description="获取指定阀门的直径", ) @@ -132,7 +132,7 @@ async def fastapi_get_valve_diameter( return ps["diameter"] @router.get( - "/getvalvetype/", + "/valves/type", summary="获取阀门类型", description="获取指定阀门的类型", ) @@ -149,7 +149,7 @@ async def fastapi_get_valve_type( return ps["type"] @router.get( - "/getvalvesetting/", + "/valves/setting", summary="获取阀门开度", description="获取指定阀门的开度/设置值", ) @@ -166,7 +166,7 @@ async def fastapi_get_valve_setting( return ps["setting"] @router.get( - "/getvalveminorloss/", + "/valves/minor-loss", summary="获取阀门损失系数", description="获取指定阀门的损失系数", ) @@ -182,8 +182,8 @@ async def fastapi_get_valve_minor_loss( ps = get_valve(network, valve) return ps["minor_loss"] -@router.post( - "/setvalvenode1/", +@router.patch( + "/valves/node1", response_model=None, summary="设置阀门起点节点", description="设置指定阀门的起点节点", @@ -201,8 +201,8 @@ async def fastapi_set_valve_node1( ps = {"id": valve, "node1": node1} return set_valve(network, ChangeSet(ps)) -@router.post( - "/setvalvenode2/", +@router.patch( + "/valves/node2", response_model=None, summary="设置阀门终点节点", description="设置指定阀门的终点节点", @@ -220,8 +220,8 @@ async def fastapi_set_valve_node2( ps = {"id": valve, "node2": node2} return set_valve(network, ChangeSet(ps)) -@router.post( - "/setvalvenodediameter/", +@router.patch( + "/valves/diameter", response_model=None, summary="设置阀门直径", description="设置指定阀门的直径", @@ -239,8 +239,8 @@ async def fastapi_set_valve_diameter( ps = {"id": valve, "diameter": diameter} return set_valve(network, ChangeSet(ps)) -@router.post( - "/setvalvetype/", +@router.patch( + "/valves/type", response_model=None, summary="设置阀门类型", description="设置指定阀门的类型", @@ -258,8 +258,8 @@ async def fastapi_set_valve_type( ps = {"id": valve, "type": type} return set_valve(network, ChangeSet(ps)) -@router.post( - "/setvalvesetting/", +@router.patch( + "/valves/setting", response_model=None, summary="设置阀门开度", description="设置指定阀门的开度/设置值", @@ -278,7 +278,7 @@ async def fastapi_set_valve_setting( return set_valve(network, ChangeSet(ps)) @router.get( - "/getvalveproperties/", + "/valves/properties", summary="获取阀门所有属性", description="获取指定阀门的所有属性", ) @@ -294,7 +294,7 @@ async def fastapi_get_valve_properties( return get_valve(network, valve) @router.get( - "/getallvalveproperties/", + "/valves", summary="获取所有阀门属性", description="获取指定水网中所有阀门的属性", ) @@ -311,8 +311,8 @@ async def fastapi_get_all_valve_properties( results = get_all_valves(network) return results -@router.post( - "/setvalveproperties/", +@router.patch( + "/valves/properties", response_model=None, summary="批量设置阀门属性", description="批量设置指定阀门的多个属性", diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index b0afe84..cf34a77 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -45,8 +45,7 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere. router = APIRouter() lockedPrjs: Dict[str, str] = {} -@router.get("/project-info", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse) -@router.get("/project_info/", summary="获取项目信息(旧路径)", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse, deprecated=True) +@router.get("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse) async def get_project_info_endpoint( network: str = Query(..., description="管网名称(或项目代码)"), metadata_repo: MetadataRepository = Depends(get_metadata_repository), @@ -70,7 +69,7 @@ async def get_project_info_endpoint( project_role="viewer", # Default role for public access ) -@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。") +@router.get("/project-codes", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。") async def list_projects_endpoint() -> list[str]: """ 获取项目列表 @@ -79,7 +78,7 @@ async def list_projects_endpoint() -> list[str]: """ return list_project() -@router.get("/haveproject/", summary="检查项目是否存在", description="检查指定名称的项目是否存在。") +@router.get("/projects/existence", summary="检查项目是否存在", description="检查指定名称的项目是否存在。") async def have_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)") ): @@ -90,7 +89,7 @@ async def have_project_endpoint( """ return have_project(network) -@router.post("/createproject/", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。") +@router.post("/projects", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。") async def create_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), _=Depends(require_permission(ENVIRONMENT_MANAGE)), @@ -103,7 +102,7 @@ async def create_project_endpoint( create_project(network) return network -@router.post("/deleteproject/", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。") +@router.delete("/projects", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。") async def delete_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), _=Depends(require_permission(ENVIRONMENT_MANAGE)), @@ -116,7 +115,7 @@ async def delete_project_endpoint( delete_project(network) return True -@router.get("/isprojectopen/", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。") +@router.get("/projects/current/status", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。") async def is_project_open_endpoint( network: str = Query(..., description="管网名称(或数据库名称)") ): @@ -127,8 +126,7 @@ async def is_project_open_endpoint( """ return is_project_open(network) -@router.post("/projects/open", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。") -@router.post("/openproject/", summary="打开项目(旧路径)", description="将指定项目加载到内存中,并初始化数据库连接池。", deprecated=True) +@router.post("/projects/current", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。") async def open_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)") ): @@ -162,7 +160,7 @@ async def open_project_endpoint( return network -@router.post("/closeproject/", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。") +@router.delete("/projects/current", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。") async def close_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)") ): @@ -174,7 +172,7 @@ async def close_project_endpoint( close_project(network) return True -@router.post("/copyproject/", summary="复制项目", description="将现有项目复制为新项目。") +@router.post("/project-copies", summary="复制项目", description="将现有项目复制为新项目。") async def copy_project_endpoint( source: str = Query(..., description="管网名称(或数据库名称)"), target: str = Query(..., description="管网名称(或数据库名称)"), @@ -189,7 +187,7 @@ async def copy_project_endpoint( copy_project(source, target) return True -@router.get("/exportinp/", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。") +@router.get("/projects/current/exports/change-set", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。") async def export_inp_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), version: str = Query(..., description="版本号 (通常用于增量更新)") @@ -222,7 +220,7 @@ async def export_inp_endpoint( return cs -@router.post("/readinp/", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。") +@router.post("/projects/current/imports", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。") async def read_inp_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), inp: str = Query(..., description="INP 文件名 (不包含路径)") @@ -236,7 +234,7 @@ async def read_inp_endpoint( read_inp(network, inp) return True -@router.get("/dumpinp/", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。") +@router.post("/projects/current/exports/inp", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。") async def dump_inp_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), inp: str = Query(..., description="目标文件名") @@ -250,7 +248,7 @@ async def dump_inp_endpoint( dump_inp(network, inp) return True -@router.get("/isprojectlocked/", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。") +@router.get("/projects/current/lock", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。") async def is_project_locked_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -262,7 +260,7 @@ async def is_project_locked_endpoint( """ return network in lockedPrjs.keys() -@router.get("/isprojectlockedbyme/", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。") +@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。") async def is_project_locked_by_me_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -278,7 +276,7 @@ async def is_project_locked_by_me_endpoint( # 0 successfully locked # 1 already locked by you # 2 locked by others -@router.post("/lockproject/", summary="锁定项目", description="锁定指定项目以防止并发修改。") +@router.post("/projects/current/lock", summary="锁定项目", description="锁定指定项目以防止并发修改。") async def lock_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -301,7 +299,7 @@ async def lock_project_endpoint( else: return 2 -@router.post("/unlockproject/", summary="解锁项目", description="释放对项目的锁定。") +@router.delete("/projects/current/lock", summary="解锁项目", description="释放对项目的锁定。") def unlock_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -319,7 +317,7 @@ def unlock_project_endpoint( return False -@router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。") +@router.get("/projects/current/files/inp", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。") async def fastapi_download_inp( name: str = Query(..., description="文件名"), response: Response = None @@ -339,7 +337,7 @@ async def fastapi_download_inp( return True # DingZQ, 2024-12-28, convert v3 to v2 -@router.get("/convertv3tov2/", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。") +@router.post("/project-conversions", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。") async def fastapi_convert_v3_to_v2( req: Request ) -> ChangeSet: @@ -373,7 +371,6 @@ async def fastapi_convert_v3_to_v2( return cs -@router.post("/readinp/", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。") async def read_inp_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), inp: str = Query(..., description="INP 文件名 (不包含路径)") @@ -387,7 +384,6 @@ async def read_inp_endpoint( read_inp(network, inp) return True -@router.get("/dumpinp/", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。") async def dump_inp_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), inp: str = Query(..., description="目标文件名") @@ -401,7 +397,6 @@ async def dump_inp_endpoint( dump_inp(network, inp) return True -@router.get("/isprojectlocked/", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。") async def is_project_locked_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -413,7 +408,6 @@ async def is_project_locked_endpoint( """ return network in lockedPrjs.keys() -@router.get("/isprojectlockedbyme/", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。") async def is_project_locked_by_me_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -429,7 +423,6 @@ async def is_project_locked_by_me_endpoint( # 0 successfully locked # 1 already locked by you # 2 locked by others -@router.post("/lockproject/", summary="锁定项目", description="锁定指定项目以防止并发修改。") async def lock_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -452,7 +445,6 @@ async def lock_project_endpoint( else: return 2 -@router.post("/unlockproject/", summary="解锁项目", description="释放对项目的锁定。") def unlock_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -470,7 +462,6 @@ def unlock_project_endpoint( return False -@router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。") async def fastapi_download_inp( name: str = Query(..., description="文件名"), response: Response = None @@ -490,7 +481,6 @@ async def fastapi_download_inp( return True # DingZQ, 2024-12-28, convert v3 to v2 -@router.get("/convertv3tov2/", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。") async def fastapi_convert_v3_to_v2( req: Request ) -> ChangeSet: diff --git a/app/api/v1/endpoints/project_data.py b/app/api/v1/endpoints/project_data.py index 58efa1b..dcf333a 100644 --- a/app/api/v1/endpoints/project_data.py +++ b/app/api/v1/endpoints/project_data.py @@ -15,7 +15,7 @@ async def get_database_connection( yield conn -@router.get("/scada-info", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息") +@router.get("/scada-info/database-view", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息") async def get_scada_info_with_connection( conn: AsyncConnection = Depends(get_database_connection), ): @@ -33,7 +33,7 @@ async def get_scada_info_with_connection( ) -@router.get("/scheme-list", summary="获取方案列表", description="使用连接池查询所有方案信息") +@router.get("/schemes/list-with-connection", summary="获取方案列表", description="使用连接池查询所有方案信息") async def get_scheme_list_with_connection( conn: AsyncConnection = Depends(get_database_connection), ): @@ -49,7 +49,7 @@ async def get_scheme_list_with_connection( raise HTTPException(status_code=500, detail=f"查询方案信息时发生错误: {str(e)}") -@router.get("/burst-locate-result", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果") +@router.get("/burst-locations/database-view", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果") async def get_burst_locate_result_with_connection( conn: AsyncConnection = Depends(get_database_connection), ): @@ -67,7 +67,7 @@ async def get_burst_locate_result_with_connection( ) -@router.get("/burst-locate-result/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果") +@router.get("/burst-locations/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果") async def get_burst_locate_result_by_incident( burst_incident: str = Path(..., description="爆管事件ID"), conn: AsyncConnection = Depends(get_database_connection), diff --git a/app/api/v1/endpoints/risk.py b/app/api/v1/endpoints/risk.py index 20a009c..58a2b02 100644 --- a/app/api/v1/endpoints/risk.py +++ b/app/api/v1/endpoints/risk.py @@ -11,7 +11,7 @@ from app.services.tjnetwork import ( router = APIRouter() @router.get( - "/getpiperiskprobabilitynow/", + "/pipes/risk-probability-now", summary="获取管道当前风险概率", description="获取指定管道当前时刻的风险概率值" ) @@ -35,7 +35,7 @@ async def fastapi_get_pipe_risk_probability_now( @router.get( - "/getpiperiskprobability/", + "/pipes/risk-probability", summary="获取管道风险概率历史", description="获取指定管道的风险概率历史数据" ) @@ -59,7 +59,7 @@ async def fastapi_get_pipe_risk_probability( @router.get( - "/getpipesriskprobability/", + "/pipes-risk-probabilities", summary="批量获取多条管道风险概率", description="批量获取多条管道的风险概率值" ) @@ -84,7 +84,7 @@ async def fastapi_get_pipes_risk_probability( @router.get( - "/getnetworkpiperiskprobabilitynow/", + "/network-pipe-risk-probability-nows", summary="获取整个网络的管道风险概率", description="获取指定网络中所有管道的当前风险概率值" ) @@ -106,7 +106,7 @@ async def fastapi_get_network_pipe_risk_probability_now( @router.get( - "/getpiperiskprobabilitygeometries/", + "/pipes/risk-probability-geometries", summary="获取管道风险几何信息", description="获取指定网络中管道的风险相关几何数据" ) diff --git a/app/api/v1/endpoints/scada.py b/app/api/v1/endpoints/scada.py index 29b8fee..48f822c 100644 --- a/app/api/v1/endpoints/scada.py +++ b/app/api/v1/endpoints/scada.py @@ -31,7 +31,6 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getscadaproperties/", summary="获取SCADA属性", tags=["SCADA基础"]) async def fast_get_scada_properties( network: str = Query(..., description="管网名称(或数据库名称)"), scada: str = Query(..., description="SCADA设备ID") @@ -50,7 +49,6 @@ async def fast_get_scada_properties( """ return get_scada_info(network, scada) -@router.get("/getallscadaproperties/", summary="获取所有SCADA属性", tags=["SCADA基础"]) async def fast_get_all_scada_properties( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -72,7 +70,7 @@ async def fast_get_all_scada_properties( # scada_device 设备管理 ############################################################ -@router.get("/getscadadeviceschema/", summary="获取SCADA设备架构", tags=["SCADA设备"]) +@router.get("/network-schemas/scada-device", summary="获取SCADA设备架构", tags=["SCADA设备"]) async def fastapi_get_scada_device_schema( network: str = Query(..., description="管网名称(或数据库名称)") ) -> dict[str, dict[str, Any]]: @@ -89,7 +87,7 @@ async def fastapi_get_scada_device_schema( """ return get_scada_device_schema(network) -@router.get("/getscadadevice/", summary="获取SCADA设备", tags=["SCADA设备"]) +@router.get("/scada-devices/detail", summary="获取SCADA设备", tags=["SCADA设备"]) async def fastapi_get_scada_device( network: str = Query(..., description="管网名称(或数据库名称)"), id: str = Query(..., description="SCADA设备ID") @@ -108,7 +106,7 @@ async def fastapi_get_scada_device( """ return get_scada_device(network, id) -@router.post("/setscadadevice/", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"]) +@router.patch("/scada-devices", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"]) async def fastapi_set_scada_device( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -128,7 +126,7 @@ async def fastapi_set_scada_device( props = await req.json() return set_scada_device(network, ChangeSet(props)) -@router.post("/addscadadevice/", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"]) +@router.post("/scada-devices", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"]) async def fastapi_add_scada_device( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -148,7 +146,7 @@ async def fastapi_add_scada_device( props = await req.json() return add_scada_device(network, ChangeSet(props)) -@router.post("/deletescadadevice/", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"]) +@router.delete("/scada-devices", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"]) async def fastapi_delete_scada_device( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -168,7 +166,7 @@ async def fastapi_delete_scada_device( props = await req.json() return delete_scada_device(network, ChangeSet(props)) -@router.post("/cleanscadadevice/", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"]) +@router.post("/scada-device-cleaning-runs", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"]) async def fastapi_clean_scada_device( network: str = Query(..., description="管网名称(或数据库名称)") ) -> ChangeSet: @@ -185,7 +183,7 @@ async def fastapi_clean_scada_device( """ return clean_scada_device(network) -@router.get("/getallscadadeviceids/", summary="获取所有SCADA设备ID", tags=["SCADA设备"]) +@router.get("/scada-devices/ids", summary="获取所有SCADA设备ID", tags=["SCADA设备"]) async def fastapi_get_all_scada_device_ids( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[str]: @@ -200,7 +198,7 @@ async def fastapi_get_all_scada_device_ids( """ return get_all_scada_device_ids(network) -@router.get("/getallscadadevices/", summary="获取所有SCADA设备", tags=["SCADA设备"]) +@router.get("/scada-devices", summary="获取所有SCADA设备", tags=["SCADA设备"]) async def fastapi_get_all_scada_devices( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -220,7 +218,7 @@ async def fastapi_get_all_scada_devices( # scada_device_data 设备数据管理 ############################################################ -@router.get("/getscadadevicedataschema/", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"]) +@router.get("/network-schemas/scada-device-data", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"]) async def fastapi_get_scada_device_data_schema( network: str = Query(..., description="管网名称(或数据库名称)"), ) -> dict[str, dict[str, Any]]: @@ -237,7 +235,7 @@ async def fastapi_get_scada_device_data_schema( """ return get_scada_device_data_schema(network) -@router.get("/getscadadevicedata/", summary="获取SCADA设备数据", tags=["SCADA设备数据"]) +@router.get("/scada-device-datas/detail", summary="获取SCADA设备数据", tags=["SCADA设备数据"]) async def fastapi_get_scada_device_data( network: str = Query(..., description="管网名称(或数据库名称)"), device_id: str = Query(..., description="SCADA设备ID") @@ -256,7 +254,7 @@ async def fastapi_get_scada_device_data( """ return get_scada_device_data(network, device_id) -@router.post("/setscadadevicedata/", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"]) +@router.patch("/scada-device-datas", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"]) async def fastapi_set_scada_device_data( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -276,7 +274,7 @@ async def fastapi_set_scada_device_data( props = await req.json() return set_scada_device_data(network, ChangeSet(props)) -@router.post("/addscadadevicedata/", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"]) +@router.post("/scada-device-datas", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"]) async def fastapi_add_scada_device_data( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -296,7 +294,7 @@ async def fastapi_add_scada_device_data( props = await req.json() return add_scada_device_data(network, ChangeSet(props)) -@router.post("/deletescadadevicedata/", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"]) +@router.delete("/scada-device-datas", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"]) async def fastapi_delete_scada_device_data( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -316,7 +314,7 @@ async def fastapi_delete_scada_device_data( props = await req.json() return delete_scada_device_data(network, ChangeSet(props)) -@router.post("/cleanscadadevicedata/", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"]) +@router.post("/scada-device-data-cleaning-runs", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"]) async def fastapi_clean_scada_device_data( network: str = Query(..., description="管网名称(或数据库名称)") ) -> ChangeSet: @@ -338,7 +336,7 @@ async def fastapi_clean_scada_device_data( # scada_element SCADA元素映射 ############################################################ -@router.get("/getscadaelementschema/", summary="获取SCADA元素架构", tags=["SCADA元素映射"]) +@router.get("/network-schemas/scada-element", summary="获取SCADA元素架构", tags=["SCADA元素映射"]) async def fastapi_get_scada_element_schema( network: str = Query(..., description="管网名称(或数据库名称)"), ) -> dict[str, dict[str, Any]]: @@ -355,7 +353,7 @@ async def fastapi_get_scada_element_schema( """ return get_scada_element_schema(network) -@router.get("/getscadaelements/", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"]) +@router.get("/scada-elements", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"]) async def fastapi_get_scada_elements( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: @@ -372,7 +370,7 @@ async def fastapi_get_scada_elements( """ return get_all_scada_elements(network) -@router.get("/getscadaelement/", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"]) +@router.get("/scada-elements/detail", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"]) async def fastapi_get_scada_element( network: str = Query(..., description="管网名称(或数据库名称)"), id: str = Query(..., description="SCADA元素映射ID") @@ -391,7 +389,7 @@ async def fastapi_get_scada_element( """ return get_scada_element(network, id) -@router.post("/setscadaelement/", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"]) +@router.patch("/scada-elements", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"]) async def fastapi_set_scada_element( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -411,7 +409,7 @@ async def fastapi_set_scada_element( props = await req.json() return set_scada_element(network, ChangeSet(props)) -@router.post("/addscadaelement/", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"]) +@router.post("/scada-elements", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"]) async def fastapi_add_scada_element( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -431,7 +429,7 @@ async def fastapi_add_scada_element( props = await req.json() return add_scada_element(network, ChangeSet(props)) -@router.post("/deletescadaelement/", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"]) +@router.delete("/scada-elements", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"]) async def fastapi_delete_scada_element( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -451,7 +449,7 @@ async def fastapi_delete_scada_element( props = await req.json() return delete_scada_element(network, ChangeSet(props)) -@router.post("/cleanscadaelement/", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"]) +@router.post("/scada-element-cleaning-runs", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"]) async def fastapi_clean_scada_element( network: str = Query(..., description="管网名称(或数据库名称)") ) -> ChangeSet: @@ -473,7 +471,7 @@ async def fastapi_clean_scada_element( # scada_info SCADA信息 ############################################################ -@router.get("/getscadainfoschema/", summary="获取SCADA信息架构", tags=["SCADA信息"]) +@router.get("/scada-info-schemas", summary="获取SCADA信息架构", tags=["SCADA信息"]) async def fastapi_get_scada_info_schema( network: str = Query(..., description="管网名称(或数据库名称)") ) -> dict[str, dict[str, Any]]: @@ -490,7 +488,7 @@ async def fastapi_get_scada_info_schema( """ return get_scada_info_schema(network) -@router.get("/getscadainfo/", summary="获取SCADA信息", tags=["SCADA信息"]) +@router.get("/scada-info/detail", summary="获取SCADA信息", tags=["SCADA信息"]) async def fastapi_get_scada_info( network: str = Query(..., description="管网名称(或数据库名称)"), id: str = Query(..., description="SCADA信息ID") @@ -509,7 +507,7 @@ async def fastapi_get_scada_info( """ return get_scada_info(network, id) -@router.get("/getallscadainfo/", summary="获取所有SCADA信息", tags=["SCADA信息"]) +@router.get("/scada-info", summary="获取所有SCADA信息", tags=["SCADA信息"]) async def fastapi_get_all_scada_info( network: str = Query(..., description="管网名称(或数据库名称)") ) -> list[dict[str, Any]]: diff --git a/app/api/v1/endpoints/schemes.py b/app/api/v1/endpoints/schemes.py index 7195ad4..ff365cc 100644 --- a/app/api/v1/endpoints/schemes.py +++ b/app/api/v1/endpoints/schemes.py @@ -7,7 +7,7 @@ from app.services.time_api import extract_date router = APIRouter() -@router.get("/getschemeschema/", summary="获取方案模式", description="获取指定网络的方案模式定义") +@router.get("/network-schemas/scheme", summary="获取方案模式", description="获取指定网络的方案模式定义") async def fastapi_get_scheme_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]: """ 获取方案模式定义 @@ -16,7 +16,7 @@ async def fastapi_get_scheme_schema(network: str = Query(..., description="管 """ return get_scheme_schema(network) -@router.get("/getscheme/", summary="获取单个方案", description="根据名称获取指定的方案信息") +@router.get("/schemes/detail", summary="获取单个方案", description="根据名称获取指定的方案信息") async def fastapi_get_scheme(network: str = Query(..., description="管网名称(或数据库名称)"), schema_name: str = Query(..., description="方案名称")) -> dict[Any, Any]: """ 获取单个方案详情 @@ -26,7 +26,6 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称 return get_scheme(network, schema_name) @router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息") -@router.get("/getallschemes/", summary="获取所有方案(旧路径)", description="获取指定网络的所有方案信息", deprecated=True) async def fastapi_get_all_schemes( network: str = Query(..., description="管网名称(或数据库名称)"), scheme_type: str | None = Query(None, description="方案类型;为空时返回全部类型"), diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 6fd87c6..61f0b26 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -95,7 +95,7 @@ def _get_scheme_response( @router.post( - "/sensor-placement-schemes/optimize", + "/sensor-placement-optimization-runs", response_model=SensorPlacementSchemeResponse, summary="创建并返回监测点优化方案", ) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index b4133ea..3720639 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -139,7 +139,7 @@ def run_simulation_manually_by_date( # 必须用这个PlainTextResponse,不然每个key都有引号 -@router.get("/runproject/", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。") +@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。") async def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str: """ 运行项目模拟 @@ -155,7 +155,7 @@ async def run_project_endpoint(network: str = Query(..., description="管网名 # output 和 report # output 是 json # report 是 text -@router.get("/runprojectreturndict/", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。") +@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。") async def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]: """ 运行项目模拟(返回字典) @@ -172,7 +172,7 @@ async def run_project_return_dict_endpoint(network: str = Query(..., description # put in inp folder, name without extension -@router.get("/runinp/", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。") +@router.post("/inp-runs", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。") async def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str: """ 运行INP文件 @@ -185,7 +185,7 @@ async def run_inp_endpoint(network: str = Query(..., description="inp文件名 # path is absolute path -@router.get("/dumpoutput/", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。") +@router.get("/outputs", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。") async def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str: """ 导出模拟输出 @@ -198,8 +198,7 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输 # Analysis Endpoints -@router.get("/burst-analysis", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") -@router.get("/burst_analysis/", summary="爆管分析(高级,旧路径)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。", deprecated=True) +@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") async def fastapi_burst_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"), @@ -233,7 +232,7 @@ async def fastapi_burst_analysis( return "success" -@router.get("/valve_close_analysis/", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。") +@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。") async def fastapi_valve_close_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"), @@ -262,8 +261,7 @@ async def fastapi_valve_close_analysis( return result or "success" -@router.get("/valve-isolation-analysis", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。") -@router.get("/valve_isolation_analysis/", summary="阀门隔离分析(旧路径)", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。", deprecated=True) +@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。") async def valve_isolation_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"), @@ -304,8 +302,7 @@ async def valve_isolation_endpoint( return result -@router.get("/flushing-analysis", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") -@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级,旧路径)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", deprecated=True) +@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") async def fastapi_flushing_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"), @@ -347,8 +344,7 @@ async def fastapi_flushing_analysis( return result or "success" -@router.get("/contaminant-simulation", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。") -@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟(旧路径)", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。", deprecated=True) +@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。") async def fastapi_contaminant_simulation( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"), @@ -385,7 +381,7 @@ async def fastapi_contaminant_simulation( return result or "success" -@router.get("/age_analysis/", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。") +@router.post("/water-age-analyses", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。") async def fastapi_age_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"), @@ -409,7 +405,7 @@ async def fastapi_age_analysis( # return scheduling_analysis(network) -@router.get("/pressureregulation/", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。") +@router.post("/pressure-regulation-calculations", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。") async def pressure_regulation_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), target_node: str = Query(..., description="目标节点ID"), @@ -427,7 +423,7 @@ async def pressure_regulation_endpoint( return pressure_regulation(network, target_node, target_pressure) -@router.post("/pressure_regulation/", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。") +@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。") async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str: """ 压力调节(高级版本) @@ -465,7 +461,7 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr return "success" -@router.post("/project_management/", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。") +@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。") async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str: """ 项目管理(高级版本) @@ -494,7 +490,7 @@ async def fastapi_project_management(data: ProjectManagement = Body(..., descrip # return daily_scheduling_analysis(network) -@router.post("/scheduling_analysis/", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。") +@router.post("/scheduling-analyses", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。") async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str: """ 排程分析 @@ -520,7 +516,7 @@ async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., descr ) -@router.post("/daily_scheduling_analysis/", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。") +@router.post("/daily-scheduling-analyses", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。") async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str: """ 日排程分析 @@ -552,7 +548,7 @@ async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body # return pump_failure(network, pump_id, time) -@router.post("/pump_failure/", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。") +@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。") async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str: """ 泵故障管理 @@ -596,7 +592,7 @@ async def fastapi_pump_failure(data: PumpFailureState = Body(..., description=" return json.dumps("SUCCESS") -@router.get("/pressuresensorplacementsensitivity/", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。") +@router.post("/pressure-sensor-placement-sensitivity-calculations", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。") async def pressure_sensor_placement_sensitivity_endpoint( name: str = Query(..., description="管网名称(或数据库名称)"), scheme_name: str = Query(..., description="放置方案名称"), @@ -620,7 +616,7 @@ async def pressure_sensor_placement_sensitivity_endpoint( ) -@router.post("/pressure_sensor_placement_sensitivity/", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。") +@router.post("/pressure-sensor-placement-sensitivities", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。") async def fastapi_pressure_sensor_placement_sensitivity( data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"), ) -> None: @@ -646,7 +642,7 @@ async def fastapi_pressure_sensor_placement_sensitivity( ) -@router.get("/pressuresensorplacementkmeans/", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。") +@router.post("/pressure-sensor-placement-kmeans-calculations", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。") async def pressure_sensor_placement_kmeans_endpoint( name: str = Query(..., description="管网名称(或数据库名称)"), scheme_name: str = Query(..., description="放置方案名称"), @@ -670,7 +666,7 @@ async def pressure_sensor_placement_kmeans_endpoint( ) -@router.post("/pressure_sensor_placement_kmeans/", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。") +@router.post("/pressure-sensor-placement-kmeans", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。") async def fastapi_pressure_sensor_placement_kmeans( data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"), ) -> None: @@ -697,7 +693,6 @@ async def fastapi_pressure_sensor_placement_kmeans( @router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。") -@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建(旧路径)", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。", deprecated=True) async def fastapi_pressure_sensor_placement( network: str = Query(..., description="管网名称(或数据库名称)"), scheme_name: str = Query(..., description="放置方案名称"), @@ -745,8 +740,7 @@ async def fastapi_pressure_sensor_placement( return "success" -@router.post("/simulations/run-by-date", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") -@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟(旧路径)", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。", deprecated=True) +@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") async def fastapi_run_simulation_manually_by_date( data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"), ) -> dict[str, str]: diff --git a/app/api/v1/endpoints/snapshots.py b/app/api/v1/endpoints/snapshots.py index e3690be..2d6e245 100644 --- a/app/api/v1/endpoints/snapshots.py +++ b/app/api/v1/endpoints/snapshots.py @@ -23,7 +23,7 @@ from app.services.tjnetwork import ( router = APIRouter() -@router.get("/getcurrentoperationid/", summary="获取当前操作ID", description="获取网络当前的操作ID") +@router.get("/current-operation-ids", summary="获取当前操作ID", description="获取网络当前的操作ID") async def get_current_operation_id_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int: """ 获取当前操作ID @@ -32,7 +32,7 @@ async def get_current_operation_id_endpoint(network: str = Query(..., descriptio """ return get_current_operation(network) -@router.post("/undo/", summary="撤销操作", description="撤销网络上最后的一个操作") +@router.post("/undos", summary="撤销操作", description="撤销网络上最后的一个操作") async def undo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): """ 撤销操作 @@ -41,7 +41,7 @@ async def undo_endpoint(network: str = Query(..., description="管网名称( """ return execute_undo(network) -@router.post("/redo/", summary="重做操作", description="重做网络上被撤销的操作") +@router.post("/redos", summary="重做操作", description="重做网络上被撤销的操作") async def redo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")): """ 重做操作 @@ -50,7 +50,7 @@ async def redo_endpoint(network: str = Query(..., description="管网名称( """ return execute_redo(network) -@router.get("/getsnapshots/", summary="获取快照列表", description="获取网络中的所有快照") +@router.get("/snapshots", summary="获取快照列表", description="获取网络中的所有快照") async def list_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[tuple[int, str]]: """ 获取快照列表 @@ -59,7 +59,7 @@ async def list_snapshot_endpoint(network: str = Query(..., description="管网 """ return list_snapshot(network) -@router.get("/havesnapshot/", summary="检查快照是否存在", description="检查指定标签的快照是否存在") +@router.get("/snapshots/existence", summary="检查快照是否存在", description="检查指定标签的快照是否存在") async def have_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> bool: """ 检查快照是否存在 @@ -68,7 +68,7 @@ async def have_snapshot_endpoint(network: str = Query(..., description="管网 """ return have_snapshot(network, tag) -@router.get("/havesnapshotforoperation/", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在") +@router.get("/snapshot-for-operations", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在") async def have_snapshot_for_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> bool: """ 检查操作快照是否存在 @@ -77,7 +77,7 @@ async def have_snapshot_for_operation_endpoint(network: str = Query(..., descrip """ return have_snapshot_for_operation(network, operation) -@router.get("/havesnapshotforcurrentoperation/", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在") +@router.get("/snapshot-for-current-operations", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在") async def have_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> bool: """ 检查当前操作快照是否存在 @@ -86,7 +86,7 @@ async def have_snapshot_for_current_operation_endpoint(network: str = Query(..., """ return have_snapshot_for_current_operation(network) -@router.post("/takesnapshotforoperation/", summary="为操作创建快照", description="为指定的操作创建快照") +@router.post("/snapshot-for-operations", summary="为操作创建快照", description="为指定的操作创建快照") async def take_snapshot_for_operation_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID"), @@ -99,7 +99,7 @@ async def take_snapshot_for_operation_endpoint( """ return take_snapshot_for_operation(network, operation, tag) -@router.post("/takesnapshotforcurrentoperation", summary="为当前操作创建快照", description="为当前操作创建快照") +@router.post("/snapshot-for-current-operations", summary="为当前操作创建快照", description="为当前操作创建快照") async def take_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None: """ 为当前操作创建快照 @@ -108,17 +108,7 @@ async def take_snapshot_for_current_operation_endpoint(network: str = Query(..., """ return take_snapshot_for_current_operation(network, tag) -# 兼容旧拼写: takenapshotforcurrentoperation -@router.post("/takenapshotforcurrentoperation", summary="为当前操作创建快照(兼容模式)", description="为当前操作创建快照(兼容旧的API路径)") -async def take_snapshot_for_current_operation_legacy_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None: - """ - 为当前操作创建快照(兼容模式) - - 兼容旧的API路径,为网络当前操作创建一个快照 - """ - return take_snapshot_for_current_operation(network, tag) - -@router.post("/takesnapshot/", summary="创建快照", description="为网络创建一个快照") +@router.post("/snapshots", summary="创建快照", description="为网络创建一个快照") async def take_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None: """ 创建快照 @@ -127,7 +117,7 @@ async def take_snapshot_endpoint(network: str = Query(..., description="管网 """ return take_snapshot(network, tag) -@router.post("/picksnapshot/", summary="选择快照", description="选择并恢复到指定的快照", response_model=None) +@router.patch("/snapshots", summary="选择快照", description="选择并恢复到指定的快照", response_model=None) async def pick_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签"), discard: bool = Query(False, description="是否丢弃当前更改")) -> ChangeSet: """ 选择快照 @@ -136,7 +126,7 @@ async def pick_snapshot_endpoint(network: str = Query(..., description="管网 """ return pick_snapshot(network, tag, discard) -@router.post("/pickoperation/", summary="选择操作", description="选择并恢复到指定的操作", response_model=None) +@router.patch("/operations", summary="选择操作", description="选择并恢复到指定的操作", response_model=None) async def pick_operation_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID"), @@ -149,7 +139,7 @@ async def pick_operation_endpoint( """ return pick_operation(network, operation, discard) -@router.get("/syncwithserver/", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None) +@router.post("/with-servers", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None) async def sync_with_server_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="目标操作ID"), @@ -162,7 +152,7 @@ async def sync_with_server_endpoint( """ return sync_with_server(network, operation) -@router.post("/batch/", summary="执行批量命令", description="执行多个网络操作命令", response_model=None) +@router.post("/network-command-batches", summary="执行批量命令", description="执行多个网络操作命令", response_model=None) async def execute_batch_commands_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None) -> ChangeSet: """ 执行批量命令 @@ -175,7 +165,7 @@ async def execute_batch_commands_endpoint(network: str = Query(..., description= rcs = execute_batch_commands(network, cs) return rcs -@router.post("/compressedbatch/", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None) +@router.post("/network-command-batches/compressed", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None) async def execute_compressed_batch_commands_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None @@ -190,7 +180,7 @@ async def execute_compressed_batch_commands_endpoint( cs.operations = jo_root["operations"] return execute_batch_command(network, cs) -@router.get("/getrestoreoperation/", summary="获取恢复操作ID", description="获取网络的恢复操作ID") +@router.get("/restore-operations", summary="获取恢复操作ID", description="获取网络的恢复操作ID") async def get_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int: """ 获取恢复操作ID @@ -199,7 +189,7 @@ async def get_restore_operation_endpoint(network: str = Query(..., description=" """ return get_restore_operation(network) -@router.post("/setrestoreoperation/", summary="设置恢复操作ID", description="设置网络的恢复操作ID") +@router.patch("/restore-operations", summary="设置恢复操作ID", description="设置网络的恢复操作ID") async def set_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> None: """ 设置恢复操作ID diff --git a/app/api/v1/endpoints/timeseries/composite.py b/app/api/v1/endpoints/timeseries/composite.py index b863097..7ac740d 100644 --- a/app/api/v1/endpoints/timeseries/composite.py +++ b/app/api/v1/endpoints/timeseries/composite.py @@ -8,7 +8,7 @@ from .dependencies import get_timescale_connection, get_postgres_connection router = APIRouter() -@router.get("/composite/scada-simulation", summary="获取SCADA关联的模拟数据") +@router.get("/timeseries/views/scada-simulations", summary="获取SCADA关联的模拟数据") async def get_scada_associated_simulation_data( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -73,7 +73,7 @@ async def get_scada_associated_simulation_data( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/composite/element-simulation", summary="获取管网元素的模拟数据") +@router.get("/timeseries/views/element-simulations", summary="获取管网元素的模拟数据") async def get_feature_simulation_data( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -143,7 +143,7 @@ async def get_feature_simulation_data( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/composite/element-scada", summary="获取管网元素关联的SCADA监测数据") +@router.get("/timeseries/views/element-scada-readings", summary="获取管网元素关联的SCADA监测数据") async def get_element_associated_scada_data( element_id: str = Query(..., description="管网元素ID(管道或节点)"), start_time: datetime = Query(..., description="查询开始时间"), @@ -185,7 +185,7 @@ async def get_element_associated_scada_data( raise HTTPException(status_code=400, detail=str(e)) -@router.post("/composite/clean-scada", summary="清洗SCADA监测数据") +@router.post("/timeseries/scada-cleaning-runs", summary="清洗SCADA监测数据") async def clean_scada_data( device_ids: str = Query(..., description="设备ID列表或 'all' 表示清洗所有设备"), start_time: datetime = Query(..., description="清洗数据的开始时间"), @@ -228,7 +228,7 @@ async def clean_scada_data( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/composite/pipeline-health-prediction", summary="预测管道健康状况") +@router.get("/pipeline-health-predictions", summary="预测管道健康状况") async def predict_pipeline_health( query_time: datetime = Query(..., description="查询时间"), network_name: str = Query(..., description="管网名称(或数据库名称)"), diff --git a/app/api/v1/endpoints/timeseries/realtime.py b/app/api/v1/endpoints/timeseries/realtime.py index eb6ee8b..705d27a 100644 --- a/app/api/v1/endpoints/timeseries/realtime.py +++ b/app/api/v1/endpoints/timeseries/realtime.py @@ -13,7 +13,7 @@ TIME_RANGE_START_DESC = f"时间范围开始时间。{TIME_WITH_TZ_DESC}" TIME_RANGE_END_DESC = f"时间范围结束时间。{TIME_WITH_TZ_DESC}" -@router.post("/realtime/links/batch", status_code=201, summary="批量插入实时管道数据") +@router.post("/timeseries/realtime/links/batches", status_code=201, summary="批量插入实时管道数据") async def insert_realtime_links( data: List[dict] = Body(..., description="管道数据列表,每项包含管道ID、时间戳等信息"), conn: AsyncConnection = Depends(get_timescale_connection) @@ -34,7 +34,7 @@ async def insert_realtime_links( @router.get( - "/realtime/links", + "/timeseries/realtime/links", summary="查询实时管道数据", description="按时间范围查询实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", ) @@ -60,7 +60,7 @@ async def get_realtime_links( @router.delete( - "/realtime/links", + "/timeseries/realtime/links", summary="删除实时管道数据", description="按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", ) @@ -85,7 +85,7 @@ async def delete_realtime_links( return {"message": "Deleted successfully"} -@router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段") +@router.patch("/timeseries/realtime/links/{link_id}/field", summary="更新实时管道字段") async def update_realtime_link_field( link_id: str = Path(..., description="管道ID"), time: datetime = Query(..., description=f"要更新记录的时间戳。{TIME_WITH_TZ_DESC}"), @@ -117,7 +117,7 @@ async def update_realtime_link_field( raise HTTPException(status_code=400, detail=str(e)) -@router.post("/realtime/nodes/batch", status_code=201, summary="批量插入实时节点数据") +@router.post("/timeseries/realtime/nodes/batches", status_code=201, summary="批量插入实时节点数据") async def insert_realtime_nodes( data: List[dict] = Body(..., description="节点数据列表,每项包含节点ID、时间戳等信息"), conn: AsyncConnection = Depends(get_timescale_connection) @@ -138,7 +138,7 @@ async def insert_realtime_nodes( @router.get( - "/realtime/nodes", + "/timeseries/realtime/nodes", summary="查询实时节点数据", description="按时间范围查询实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", ) @@ -164,7 +164,7 @@ async def get_realtime_nodes( @router.delete( - "/realtime/nodes", + "/timeseries/realtime/nodes", summary="删除实时节点数据", description="按时间范围删除实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", ) @@ -191,7 +191,7 @@ async def delete_realtime_nodes( -@router.post("/realtime/simulation/store", status_code=201, summary="存储实时模拟结果") +@router.post("/timeseries/realtime/simulation-results", status_code=201, summary="存储实时模拟结果") async def store_realtime_simulation_result( node_result_list: List[dict] = Body(..., description="节点模拟结果列表"), link_result_list: List[dict] = Body(..., description="管道模拟结果列表"), @@ -218,7 +218,7 @@ async def store_realtime_simulation_result( @router.get( - "/realtime/query/by-time-property", + "/timeseries/realtime/records", summary="按时间和属性查询实时数据", description="查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", ) @@ -254,7 +254,7 @@ async def query_realtime_records_by_time_property( @router.get( - "/realtime/query/by-id-time", + "/timeseries/realtime/simulation-results", summary="按ID和时间查询实时模拟数据", description="查询指定元素在某一时间点的实时模拟结果。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", ) diff --git a/app/api/v1/endpoints/timeseries/scada.py b/app/api/v1/endpoints/timeseries/scada.py index 3ed1bab..3f75b98 100644 --- a/app/api/v1/endpoints/timeseries/scada.py +++ b/app/api/v1/endpoints/timeseries/scada.py @@ -9,7 +9,7 @@ from .dependencies import get_timescale_connection router = APIRouter() -@router.post("/scada/batch", status_code=201, summary="批量插入SCADA监测数据") +@router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据") async def insert_scada_data( data: List[dict] = Body(..., description="SCADA设备监测数据列表"), conn: AsyncConnection = Depends(get_timescale_connection), @@ -29,7 +29,7 @@ async def insert_scada_data( return {"message": f"Inserted {len(data)} records"} -@router.get("/scada/by-ids-time-range", summary="按设备ID和时间范围查询SCADA数据") +@router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据") async def get_scada_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), @@ -60,7 +60,7 @@ async def get_scada_by_ids_time_range( @router.get( - "/scada/by-ids-field-time-range", summary="按设备ID、字段和时间范围查询SCADA数据" + "/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据" ) async def get_scada_field_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), @@ -101,7 +101,7 @@ async def get_scada_field_by_ids_time_range( raise HTTPException(status_code=400, detail=str(e)) -@router.patch("/scada/{device_id}/field", summary="更新SCADA设备字段") +@router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段") async def update_scada_field( device_id: str = Path(..., description="设备ID"), time: datetime = Query(..., description="更新数据的时间戳"), @@ -133,7 +133,7 @@ async def update_scada_field( raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/scada/by-id-time-range", summary="按设备ID和时间范围删除SCADA数据") +@router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据") async def delete_scada_data( device_id: str = Query(..., description="设备ID"), start_time: datetime = Query(..., description="删除开始时间"), diff --git a/app/api/v1/endpoints/timeseries/scheme.py b/app/api/v1/endpoints/timeseries/scheme.py index 76f71e6..0c56a75 100644 --- a/app/api/v1/endpoints/timeseries/scheme.py +++ b/app/api/v1/endpoints/timeseries/scheme.py @@ -9,7 +9,7 @@ from .dependencies import get_timescale_connection router = APIRouter() -@router.post("/scheme/links/batch", status_code=201, summary="批量插入方案管道数据") +@router.post("/timeseries/schemes/links/batches", status_code=201, summary="批量插入方案管道数据") async def insert_scheme_links( data: List[dict] = Body(..., description="方案管道数据列表"), conn: AsyncConnection = Depends(get_timescale_connection), @@ -29,7 +29,7 @@ async def insert_scheme_links( return {"message": f"Inserted {len(data)} records"} -@router.get("/scheme/links", summary="查询方案管道数据") +@router.get("/timeseries/schemes/links", summary="查询方案管道数据") async def get_scheme_links( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -56,7 +56,7 @@ async def get_scheme_links( ) -@router.get("/scheme/links/{link_id}/field", summary="查询方案管道字段数据") +@router.get("/timeseries/schemes/links/{link_id}/field", summary="查询方案管道字段数据") async def get_scheme_link_field( link_id: str = Path(..., description="管道ID"), scheme_type: str = Query(..., description="方案类型"), @@ -93,7 +93,7 @@ async def get_scheme_link_field( raise HTTPException(status_code=400, detail=str(e)) -@router.patch("/scheme/links/{link_id}/field", summary="更新方案管道字段") +@router.patch("/timeseries/schemes/links/{link_id}/field", summary="更新方案管道字段") async def update_scheme_link_field( link_id: str = Path(..., description="管道ID"), scheme_type: str = Query(..., description="方案类型"), @@ -131,7 +131,7 @@ async def update_scheme_link_field( raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/scheme/links", summary="删除方案管道数据") +@router.delete("/timeseries/schemes/links", summary="删除方案管道数据") async def delete_scheme_links( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -159,7 +159,7 @@ async def delete_scheme_links( return {"message": "Deleted successfully"} -@router.post("/scheme/nodes/batch", status_code=201, summary="批量插入方案节点数据") +@router.post("/timeseries/schemes/nodes/batches", status_code=201, summary="批量插入方案节点数据") async def insert_scheme_nodes( data: List[dict] = Body(..., description="方案节点数据列表"), conn: AsyncConnection = Depends(get_timescale_connection), @@ -179,7 +179,7 @@ async def insert_scheme_nodes( return {"message": f"Inserted {len(data)} records"} -@router.get("/scheme/nodes/{node_id}/field", summary="查询方案节点字段数据") +@router.get("/timeseries/schemes/nodes/{node_id}/field", summary="查询方案节点字段数据") async def get_scheme_node_field( node_id: str = Path(..., description="节点ID"), scheme_type: str = Query(..., description="方案类型"), @@ -216,7 +216,7 @@ async def get_scheme_node_field( raise HTTPException(status_code=400, detail=str(e)) -@router.patch("/scheme/nodes/{node_id}/field", summary="更新方案节点字段") +@router.patch("/timeseries/schemes/nodes/{node_id}/field", summary="更新方案节点字段") async def update_scheme_node_field( node_id: str = Path(..., description="节点ID"), scheme_type: str = Query(..., description="方案类型"), @@ -254,7 +254,7 @@ async def update_scheme_node_field( raise HTTPException(status_code=400, detail=str(e)) -@router.delete("/scheme/nodes", summary="删除方案节点数据") +@router.delete("/timeseries/schemes/nodes", summary="删除方案节点数据") async def delete_scheme_nodes( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -282,7 +282,7 @@ async def delete_scheme_nodes( return {"message": "Deleted successfully"} -@router.post("/scheme/simulation/store", status_code=201, summary="存储方案模拟结果") +@router.post("/timeseries/schemes/simulation-results", status_code=201, summary="存储方案模拟结果") async def store_scheme_simulation_result( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), @@ -318,7 +318,7 @@ async def store_scheme_simulation_result( @router.get( - "/scheme/query/by-scheme-time-property", summary="按方案、时间和属性查询数据" + "/timeseries/schemes/records", summary="按方案、时间和属性查询数据" ) async def query_scheme_records_by_scheme_time_property( scheme_type: str = Query(..., description="方案类型"), @@ -355,7 +355,7 @@ async def query_scheme_records_by_scheme_time_property( raise HTTPException(status_code=400, detail=str(e)) -@router.get("/scheme/query/by-id-time", summary="按ID和时间查询方案模拟数据") +@router.get("/timeseries/schemes/simulation-results", summary="按ID和时间查询方案模拟数据") async def query_scheme_simulation_by_id_time( scheme_type: str = Query(..., description="方案类型"), scheme_name: str = Query(..., description="方案名称"), diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py index 9cd1507..867a766 100644 --- a/app/api/v1/endpoints/users.py +++ b/app/api/v1/endpoints/users.py @@ -8,7 +8,7 @@ router = APIRouter() # user 39 ########################################################### -@router.get("/getuserschema/", summary="获取用户模式", description="获取指定网络的用户模式定义") +@router.get("/network-schemas/user", summary="获取用户模式", description="获取指定网络的用户模式定义") async def fastapi_get_user_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]: """ 获取用户模式定义 @@ -17,7 +17,7 @@ async def fastapi_get_user_schema(network: str = Query(..., description="管网 """ return get_user_schema(network) -@router.get("/getuser/", summary="获取单个用户", description="获取指定网络中的单个用户信息") +@router.get("/users/detail", summary="获取单个用户", description="获取指定网络中的单个用户信息") async def fastapi_get_user(network: str = Query(..., description="管网名称(或数据库名称)"), user_name: str = Query(..., description="用户名")) -> dict[Any, Any]: """ 获取用户信息 @@ -26,7 +26,7 @@ async def fastapi_get_user(network: str = Query(..., description="管网名称 """ return get_user(network, user_name) -@router.get("/getallusers/", summary="获取所有用户", description="获取指定网络的所有用户列表") +@router.get("/users", summary="获取所有用户", description="获取指定网络的所有用户列表") async def fastapi_get_all_users(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: """ 获取所有用户列表 diff --git a/app/api/v1/endpoints/web_search.py b/app/api/v1/endpoints/web_search.py index d3e2675..29f05e1 100644 --- a/app/api/v1/endpoints/web_search.py +++ b/app/api/v1/endpoints/web_search.py @@ -13,7 +13,7 @@ router = APIRouter() @router.post( - "/web-search", + "/web-searches", summary="Web Search", description="调用 Bocha Web Search API 获取实时网页搜索结果", ) diff --git a/app/api/v1/rest_router.py b/app/api/v1/rest_router.py new file mode 100644 index 0000000..fc736ee --- /dev/null +++ b/app/api/v1/rest_router.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import inspect +import re +from collections.abc import Iterable +from copy import copy +from functools import wraps +from typing import Any, Generic, TypeVar, get_args, get_origin + +from fastapi import APIRouter, Depends, Query +from fastapi.routing import APIRoute +from pydantic import BaseModel, JsonValue, create_model + +from app.api.problem_details import ProblemDetails +from app.api.v1.router import api_router as handler_api_router +from app.auth.metadata_dependencies import get_current_metadata_user +from app.auth.project_dependencies import ProjectContext, get_project_context + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + items: list[T] + total: int + limit: int + offset: int + + +_NAME_IS_NETWORK = { + "pressure_sensor_placement_sensitivity_endpoint", + "pressure_sensor_placement_kmeans_endpoint", +} +_DERIVE_USERNAME = { + "pressure_sensor_placement_sensitivity_endpoint": "username", + "pressure_sensor_placement_kmeans_endpoint": "username", + "fastapi_pressure_sensor_placement": "user_name", +} +_PUBLIC_PARAMETER_RENAMES = { + "burst_ID": "burst_id", + "drainage_node_ID": "drainage_node_id", +} +_MODEL_NAME_IS_NETWORK = {"RunSimulationManuallyByDate"} +_MODEL_USERNAME_FROM_AUTH: set[str] = set() + + +def _clean_name(name: str) -> str: + for prefix in ("fastapi_", "fast_"): + if name.startswith(prefix): + name = name[len(prefix) :] + break + if name.endswith("_endpoint"): + name = name[: -len("_endpoint")] + return name + + +def _rest_body_model(annotation): + if not inspect.isclass(annotation) or not issubclass(annotation, BaseModel): + return None + project_fields = { + name + for name in ("network", "network_name") + if name in annotation.model_fields + } + if annotation.__name__ in _MODEL_NAME_IS_NETWORK and "name" in annotation.model_fields: + project_fields.add("name") + username_fields = ( + { + name + for name in ("username", "user_name") + if name in annotation.model_fields + } + if annotation.__name__ in _MODEL_USERNAME_FROM_AUTH + else set() + ) + excluded_fields = project_fields | username_fields + if not excluded_fields: + return None + + public_fields = { + name: (field.annotation, copy(field)) + for name, field in annotation.model_fields.items() + if name not in excluded_fields + } + public_model = create_model( + f"{annotation.__name__}Rest", + __module__=annotation.__module__, + **public_fields, + ) + return annotation, public_model, project_fields, username_fields + + +def _with_header_project_context(endpoint, route_name: str): + signature = inspect.signature(endpoint) + network_parameters = [ + name for name in ("network", "network_name") if name in signature.parameters + ] + if route_name in _NAME_IS_NETWORK and "name" in signature.parameters: + network_parameters.append("name") + username_parameter = _DERIVE_USERNAME.get(route_name) + parameter_renames = { + internal: public + for internal, public in _PUBLIC_PARAMETER_RENAMES.items() + if internal in signature.parameters + } + body_models = { + name: body_model + for name, parameter in signature.parameters.items() + if (body_model := _rest_body_model(parameter.annotation)) is not None + } + model_has_username = any(model[3] for model in body_models.values()) + if ( + not network_parameters + and not username_parameter + and not parameter_renames + and not body_models + ): + return endpoint + + existing_context_parameter = next( + ( + name + for name, parameter in signature.parameters.items() + if parameter.annotation is ProjectContext + ), + None, + ) + injected_context_name = existing_context_parameter or "_rest_project_context" + injected_user_name = "_rest_current_user" + + @wraps(endpoint) + async def wrapper(*args, **kwargs): + project_context = kwargs.get(injected_context_name) + if not isinstance(project_context, ProjectContext): + raise RuntimeError("REST project context was not resolved") + if not existing_context_parameter: + kwargs.pop(injected_context_name, None) + for parameter_name in network_parameters: + kwargs[parameter_name] = project_context.project_code + if username_parameter: + kwargs[username_parameter] = kwargs[injected_user_name].username + kwargs.pop(injected_user_name, None) + for internal_name, public_name in parameter_renames.items(): + kwargs[internal_name] = kwargs.pop(public_name) + for parameter_name, ( + original_model, + _public_model, + project_fields, + username_fields, + ) in body_models.items(): + data = kwargs[parameter_name].model_dump() + data.update( + {field_name: project_context.project_code for field_name in project_fields} + ) + if username_fields: + current_user = kwargs[injected_user_name] + data.update( + {field_name: current_user.username for field_name in username_fields} + ) + kwargs[parameter_name] = original_model.model_validate(data) + if model_has_username: + kwargs.pop(injected_user_name, None) + result = endpoint(*args, **kwargs) + if inspect.isawaitable(result): + return await result + return result + + parameters = [] + for name, parameter in signature.parameters.items(): + if name in network_parameters or name == username_parameter: + continue + public_name = parameter_renames.get(name, name) + if public_name != name: + default = copy(parameter.default) + default.alias = public_name + default.validation_alias = public_name + default.serialization_alias = public_name + parameter = parameter.replace(name=public_name, default=default) + if name in body_models: + parameter = parameter.replace(annotation=body_models[name][1]) + parameters.append(parameter) + if not existing_context_parameter: + parameters.append( + inspect.Parameter( + injected_context_name, + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=ProjectContext, + default=Depends(get_project_context), + ) + ) + if username_parameter or model_has_username: + parameters.append( + inspect.Parameter( + injected_user_name, + kind=inspect.Parameter.KEYWORD_ONLY, + default=Depends(get_current_metadata_user), + ) + ) + wrapper.__signature__ = signature.replace(parameters=parameters) + return wrapper + + +def _with_pagination(endpoint): + signature = inspect.signature(endpoint) + if "limit" in signature.parameters or "offset" in signature.parameters: + return endpoint + + @wraps(endpoint) + async def wrapper(*args, **kwargs): + limit = kwargs.pop("_rest_limit") + offset = kwargs.pop("_rest_offset") + result = endpoint(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + if not isinstance(result, list): + return result + return Page( + items=result[offset : offset + limit], + total=len(result), + limit=limit, + offset=offset, + ) + + parameters = list(signature.parameters.values()) + parameters.extend( + [ + inspect.Parameter( + "_rest_limit", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=Query(100, ge=1, le=1000, alias="limit"), + ), + inspect.Parameter( + "_rest_offset", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=Query(0, ge=0, alias="offset"), + ), + ] + ) + wrapper.__signature__ = signature.replace(parameters=parameters) + return wrapper + + +def _adapt_route(route: APIRoute) -> APIRoute: + methods = route.methods or set() + if len(methods) != 1: + raise RuntimeError( + f"REST route {route.name!r} must declare exactly one HTTP method" + ) + method = next(iter(methods)) + responses = dict(route.responses or {}) + for status_code, description in ( + (401, "Authentication required"), + (403, "Insufficient permission"), + (404, "Resource not found"), + (409, "Resource conflict"), + (422, "Validation error"), + (503, "Dependency unavailable"), + ): + responses.setdefault( + status_code, + {"model": ProblemDetails, "description": description}, + ) + + endpoint = _with_header_project_context(route.endpoint, route.name) + response_model = route.response_model + if get_origin(response_model) is list: + item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue + response_model = Page[item_type] + endpoint = _with_pagination(endpoint) + + clean_name = _clean_name(route.name) + creates_resource = clean_name.startswith( + ("add_", "create_", "copy_", "import_", "insert_", "store_", "take_", "upload_") + ) or route.name == "fastapi_pressure_sensor_placement" + status_code = ( + 204 + if method == "DELETE" + else 201 + if method == "POST" and creates_resource + else route.status_code + ) + if status_code == 204: + response_model = None + elif response_model is None: + response_model = JsonValue + + return APIRoute( + path=route.path, + endpoint=endpoint, + response_model=response_model, + status_code=status_code, + tags=route.tags, + dependencies=route.dependencies, + summary=route.summary, + description=route.description, + response_description=route.response_description, + responses=responses, + deprecated=False, + name=route.name, + methods={method}, + operation_id=f"{method.lower()}_{re.sub(r'[^a-z0-9]+', '_', route.path).strip('_')}", + response_model_include=route.response_model_include, + response_model_exclude=route.response_model_exclude, + response_model_by_alias=route.response_model_by_alias, + response_model_exclude_unset=route.response_model_exclude_unset, + response_model_exclude_defaults=route.response_model_exclude_defaults, + response_model_exclude_none=route.response_model_exclude_none, + include_in_schema=route.include_in_schema, + response_class=route.response_class, + callbacks=route.callbacks, + openapi_extra=route.openapi_extra, + ) + + +def build_rest_router(routes: Iterable[Any]) -> APIRouter: + router = APIRouter() + seen: dict[tuple[str, str], APIRoute] = {} + operation_ids: set[str] = set() + + for route in routes: + if not isinstance(route, APIRoute): + continue + + methods = route.methods or set() + if len(methods) != 1: + raise RuntimeError( + f"REST route {route.name!r} must declare exactly one HTTP method" + ) + method = next(iter(methods)) + key = (method, route.path) + if key in seen: + previous = seen[key] + raise RuntimeError( + "REST route collision for " + f"{method} {route.path}: {previous.name!r} and {route.name!r}." + ) + + adapted = _adapt_route(route) + if adapted.operation_id in operation_ids: + adapted.operation_id = f"{adapted.operation_id}_{route.name}" + seen[key] = route + operation_ids.add(adapted.operation_id or "") + router.routes.append(adapted) + + return router + + +api_router = build_rest_router(handler_api_router.routes) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 887be17..b6f801c 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -98,11 +98,10 @@ api_router.include_router(access.router, tags=["Access Control"]) api_router.include_router(agent_auth.router, tags=["Agent Auth"]) api_router.include_router( admin_metadata.router, - prefix="/admin", tags=["Metadata Admin"], ) api_router.include_router(model_import.router, tags=["Model Administration"]) -api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) +api_router.include_router(audit.router, tags=["Audit Logs"]) api_router.include_router(meta.router, tags=["Metadata"]) api_router.include_router( project.router, @@ -190,19 +189,16 @@ api_router.include_router( ) api_router.include_router( leakage.router, - prefix="/leakage", tags=["Leakage"], dependencies=[burst_run_access], ) api_router.include_router( burst_detection.router, - prefix="/burst-detection", tags=["Burst Detection"], dependencies=[burst_run_access], ) api_router.include_router( burst_location.router, - prefix="/burst-location", tags=["Burst Location"], dependencies=[burst_run_access], ) diff --git a/app/auth/metadata_dependencies.py b/app/auth/metadata_dependencies.py index 5d3b6cf..47742f9 100644 --- a/app/auth/metadata_dependencies.py +++ b/app/auth/metadata_dependencies.py @@ -61,7 +61,7 @@ async def get_current_metadata_user( ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Metadata database error: {exc}", + detail="Metadata database is unavailable", ) from exc if not user or not user.is_active: raise HTTPException( @@ -80,7 +80,7 @@ async def get_current_metadata_user( ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Metadata database error: {exc}", + detail="Metadata database is unavailable", ) from exc return user diff --git a/app/auth/project_dependencies.py b/app/auth/project_dependencies.py index 362a186..6a2d387 100644 --- a/app/auth/project_dependencies.py +++ b/app/auth/project_dependencies.py @@ -78,7 +78,7 @@ async def resolve_project_context( ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Metadata database error: {exc}", + detail="Metadata database is unavailable", ) from exc return ProjectContext( diff --git a/app/core/config.py b/app/core/config.py index 3aca912..521252a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -8,7 +8,6 @@ class Settings(BaseSettings): PROJECT_NAME: str = "TJWater Server" ENVIRONMENT: str = "production" API_V1_STR: str = "/api/v1" - NETWORK_NAME: str = "default_network" # 敏感配置加密密钥 (Fernet) diff --git a/app/main.py b/app/main.py index d82cb87..d3fe175 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,8 @@ import logging from datetime import datetime import app.services.project_info as project_info -from app.api.v1.router import api_router +from app.api.problem_details import install_problem_details_handlers +from app.api.v1.rest_router import api_router from app.infra.db.timescaledb.database import db as tsdb from app.infra.db.postgresql.database import db as pgdb from app.infra.db.dynamic_manager import project_connection_manager @@ -64,11 +65,13 @@ app = FastAPI( docs_url=None if is_production else "/docs", redoc_url=None if is_production else "/redoc", openapi_url=None if is_production else "/openapi.json", + redirect_slashes=False, ) # Include Routers app.include_router(api_router, prefix="/api/v1") +install_problem_details_handlers(app) # Legcy Routers without version prefix # app.include_router(api_router) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 8e16ec2..eaa5e93 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -32,24 +32,18 @@ def test_load_auth_context_supports_aliases(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") monkeypatch.setenv("TJWATER_PROJECT_ID", "p1") - monkeypatch.setenv("TJWATER_USERNAME", "tester") - monkeypatch.setenv("TJWATER_NETWORK", "net1") auth = core.load_auth_context(auth_stdin=False) assert auth.server == "http://server" assert auth.access_token == "abc" assert auth.project_id == "p1" - assert auth.username == "tester" - assert auth.network == "net1" def test_build_runtime_context_uses_default_server(monkeypatch): monkeypatch.delenv("TJWATER_SERVER", raising=False) monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False) monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False) - monkeypatch.delenv("TJWATER_USERNAME", raising=False) - monkeypatch.delenv("TJWATER_NETWORK", raising=False) monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False) runtime = core.build_runtime_context( @@ -68,7 +62,7 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): def fake_request_json(ctx, **kwargs): observed_runtime_ids.append(id(ctx)) assert ctx.auth.access_token == "token-1" - assert kwargs["params"] == {"network": "tjwater", "junction": "11"} + assert kwargs["params"] == {"junction": "11"} return {"id": "11"}, 5 monkeypatch.setattr(common, "request_json", fake_request_json) @@ -81,7 +75,6 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): "server": "http://server", "access_token": "token-1", "project_id": "project-1", - "network": "tjwater", } ), ) @@ -105,7 +98,6 @@ def test_network_get_junction_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"]) @@ -116,8 +108,8 @@ def test_network_get_junction_properties_uses_network_context(monkeypatch): assert payload["data"] == {"id": "J1"} assert captured == { "access_token": "abc", - "path": "/getjunctionproperties/", - "params": {"network": "tjwater", "junction": "J1"}, + "path": "/junctions/properties", + "params": {"junction": "J1"}, } @@ -132,7 +124,6 @@ def test_network_get_pipe_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"]) @@ -143,8 +134,8 @@ def test_network_get_pipe_properties_uses_network_context(monkeypatch): assert payload["data"] == {"id": "P1"} assert captured == { "access_token": "abc", - "path": "/getpipeproperties/", - "params": {"network": "tjwater", "pipe": "P1"}, + "path": "/pipes/properties", + "params": {"pipe": "P1"}, } @@ -159,7 +150,6 @@ def test_network_get_all_pipes_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-all-pipes-properties"]) @@ -170,8 +160,8 @@ def test_network_get_all_pipes_properties_uses_network_context(monkeypatch): assert payload["data"] == [{"id": "P1"}] assert captured == { "access_token": "abc", - "path": "/getallpipeproperties/", - "params": {"network": "tjwater"}, + "path": "/pipes", + "params": {}, } @@ -186,7 +176,6 @@ def test_network_get_reservoir_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"]) @@ -197,8 +186,8 @@ def test_network_get_reservoir_properties_uses_network_context(monkeypatch): assert payload["data"] == {"id": "R1"} assert captured == { "access_token": "abc", - "path": "/getreservoirproperties/", - "params": {"network": "tjwater", "reservoir": "R1"}, + "path": "/reservoirs/properties", + "params": {"reservoir": "R1"}, } @@ -213,7 +202,6 @@ def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-all-reservoirs-properties"]) @@ -224,8 +212,8 @@ def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch): assert payload["data"] == [{"id": "R1"}] assert captured == { "access_token": "abc", - "path": "/getallreservoirproperties/", - "params": {"network": "tjwater"}, + "path": "/reservoirs", + "params": {}, } @@ -240,7 +228,6 @@ def test_network_get_tank_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"]) @@ -251,8 +238,8 @@ def test_network_get_tank_properties_uses_network_context(monkeypatch): assert payload["data"] == {"id": "T1"} assert captured == { "access_token": "abc", - "path": "/gettankproperties/", - "params": {"network": "tjwater", "tank": "T1"}, + "path": "/tanks/properties", + "params": {"tank": "T1"}, } @@ -267,7 +254,6 @@ def test_network_get_all_tank_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-all-tanks-properties"]) @@ -278,8 +264,8 @@ def test_network_get_all_tank_properties_uses_network_context(monkeypatch): assert payload["data"] == [{"id": "T1"}] assert captured == { "access_token": "abc", - "path": "/getalltankproperties/", - "params": {"network": "tjwater"}, + "path": "/tanks", + "params": {}, } @@ -294,7 +280,6 @@ def test_network_get_pump_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"]) @@ -305,8 +290,8 @@ def test_network_get_pump_properties_uses_network_context(monkeypatch): assert payload["data"] == {"id": "PU1"} assert captured == { "access_token": "abc", - "path": "/getpumpproperties/", - "params": {"network": "tjwater", "pump": "PU1"}, + "path": "/pumps/properties", + "params": {"pump": "PU1"}, } @@ -321,7 +306,6 @@ def test_network_get_all_pump_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-all-pumps-properties"]) @@ -332,8 +316,8 @@ def test_network_get_all_pump_properties_uses_network_context(monkeypatch): assert payload["data"] == [{"id": "PU1"}] assert captured == { "access_token": "abc", - "path": "/getallpumpproperties/", - "params": {"network": "tjwater"}, + "path": "/pumps", + "params": {}, } @@ -348,7 +332,6 @@ def test_network_get_valve_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"]) @@ -359,8 +342,8 @@ def test_network_get_valve_properties_uses_network_context(monkeypatch): assert payload["data"] == {"id": "V1"} assert captured == { "access_token": "abc", - "path": "/getvalveproperties/", - "params": {"network": "tjwater", "valve": "V1"}, + "path": "/valves/properties", + "params": {"valve": "V1"}, } @@ -375,7 +358,6 @@ def test_network_get_all_valve_properties_uses_network_context(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setattr(common, "request_json", fake_request_json) result = runner.invoke(app, ["network", "get-all-valves-properties"]) @@ -386,8 +368,8 @@ def test_network_get_all_valve_properties_uses_network_context(monkeypatch): assert payload["data"] == [{"id": "V1"}] assert captured == { "access_token": "abc", - "path": "/getallvalveproperties/", - "params": {"network": "tjwater"}, + "path": "/valves", + "params": {}, } @@ -525,7 +507,6 @@ def test_realtime_property_help_lists_supported_fields(): def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") burst_path = tmp_path / "burst.json" burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8") @@ -559,7 +540,6 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: def test_analysis_contaminant_sends_required_scheme_name(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") captured = {} def fake_request(**kwargs): @@ -588,7 +568,6 @@ def test_analysis_contaminant_sends_required_scheme_name(monkeypatch): assert result.exit_code == 0 assert captured["params"] == { - "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", "source": "N1", "concentration": 10.0, @@ -600,7 +579,6 @@ def test_analysis_contaminant_sends_required_scheme_name(monkeypatch): def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") captured = {} valve_path = tmp_path / "valve.json" valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") @@ -633,11 +611,10 @@ def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Pat assert result.exit_code == 0 assert captured["params"] == { - "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", "valves": ["V1"], "valves_k": [0.5], - "drainage_node_ID": "N1", + "drainage_node_id": "N1", "flush_flow": 100.0, "duration": 900, "scheme_name": "flush_case_01", @@ -647,7 +624,6 @@ def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Pat def test_analysis_valve_close_sends_required_scheme_name(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") captured = {} def fake_request(**kwargs): @@ -676,7 +652,6 @@ def test_analysis_valve_close_sends_required_scheme_name(monkeypatch): assert result.exit_code == 0 assert captured["params"] == { - "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", "valves": ["V1"], "duration": 900, @@ -687,7 +662,6 @@ def test_analysis_valve_close_sends_required_scheme_name(monkeypatch): def test_analysis_contaminant_requires_scheme(monkeypatch, capsys): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") exit_code = main( [ @@ -713,7 +687,6 @@ def test_analysis_contaminant_requires_scheme(monkeypatch, capsys): def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") valve_path = tmp_path / "valve.json" valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") @@ -741,7 +714,6 @@ def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys): def test_analysis_valve_close_requires_scheme(monkeypatch, capsys): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") exit_code = main( [ @@ -921,7 +893,6 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): def test_simulation_run_translates_rfc3339(monkeypatch): monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_NETWORK", "demo") captured = {} def fake_request(**kwargs): @@ -944,7 +915,6 @@ def test_simulation_run_translates_rfc3339(monkeypatch): assert result.exit_code == 0 assert captured["json"] == { - "name": "demo", "start_time": "2025-01-02T03:04:05+08:00", "duration": 30, } diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index d43b3eb..a933e74 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -27,8 +27,6 @@ from .core import ( parse_time_with_timezone, parse_valve_setting_file, request_json, - require_network, - require_username, resolve_scheme, ) from .option_types import DataSource, ValveMode @@ -41,11 +39,9 @@ def simulation_run( duration: Annotated[int, typer.Option("--duration", help="持续分钟数")], ) -> None: runtime = runtime_context(ctx) - network = require_network(runtime) parsed = parse_time_with_timezone(start_time, option_name="--start-time") end_time = (parsed + timedelta(minutes=duration)).isoformat() body = { - "name": network, "start_time": parsed.replace(microsecond=0).isoformat(), "duration": duration, } @@ -53,10 +49,9 @@ def simulation_run( ctx, summary="触发模拟成功", method="POST", - path="/simulations/run-by-date", + path="/simulation-runs", json_body=body, require_auth=True, - require_network_ctx=True, next_commands=[ f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", @@ -76,9 +71,8 @@ def analysis_burst( ids, sizes = parse_burst_file(burst_file) scheme_name = resolve_scheme(runtime, scheme, required=True) params = { - "network": require_network(runtime), "modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "burst_ID": ids, + "burst_id": ids, "burst_size": sizes, "modify_total_duration": duration, "scheme_name": scheme_name, @@ -86,11 +80,10 @@ def analysis_burst( emit_api( ctx, summary="爆管分析执行成功", - method="GET", - path="/burst-analysis", + method="POST", + path="/burst-analyses", params=params, require_auth=True, - require_network_ctx=True, next_commands=[ f"tjwater-cli data scheme get --name {scheme_name}", "tjwater-cli data scheme list", @@ -110,7 +103,6 @@ def analysis_valve( scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None, ) -> None: runtime = runtime_context(ctx) - network = require_network(runtime) if mode == ValveMode.CLOSE: if not start_time or not valve: raise CLIError( @@ -120,7 +112,6 @@ def analysis_valve( exit_code=2, ) params = { - "network": network, "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "valves": valve, "duration": duration or 900, @@ -129,11 +120,10 @@ def analysis_valve( emit_api( ctx, summary="阀门关闭分析执行成功", - method="GET", - path="/valve_close_analysis/", + method="POST", + path="/valve-isolation-analyses", params=params, require_auth=True, - require_network_ctx=True, ) return if mode == ValveMode.ISOLATION: @@ -144,17 +134,16 @@ def analysis_valve( message="isolation mode requires at least one --element", exit_code=2, ) - params = {"network": network, "accident_element": element} + params = {"accident_element": element} if disabled_valve: params["disabled_valves"] = disabled_valve emit_api( ctx, summary="阀门隔离分析执行成功", - method="GET", - path="/valve-isolation-analysis", + method="POST", + path="/valve-isolation-analyses", params=params, require_auth=True, - require_network_ctx=True, ) return raise AssertionError(f"unreachable valve mode: {mode}") @@ -173,11 +162,10 @@ def analysis_flushing( runtime = runtime_context(ctx) valves, openings = parse_valve_setting_file(valve_setting_file) params = { - "network": require_network(runtime), "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "valves": valves, "valves_k": openings, - "drainage_node_ID": drainage_node, + "drainage_node_id": drainage_node, "flush_flow": flow, "duration": duration or 900, "scheme_name": resolve_scheme(runtime, scheme, required=True), @@ -185,11 +173,10 @@ def analysis_flushing( emit_api( ctx, summary="冲洗分析执行成功", - method="GET", - path="/flushing-analysis", + method="POST", + path="/flushing-analyses", params=params, require_auth=True, - require_network_ctx=True, ) @@ -203,15 +190,13 @@ def analysis_age( emit_api( ctx, summary="水龄分析执行成功", - method="GET", - path="/age_analysis/", + method="POST", + path="/water-age-analyses", params={ - "network": require_network(runtime), "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "duration": duration, }, require_auth=True, - require_network_ctx=True, ) @@ -227,7 +212,6 @@ def analysis_contaminant( ) -> None: runtime = runtime_context(ctx) params = { - "network": require_network(runtime), "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "source": source_node, "concentration": concentration, @@ -239,11 +223,10 @@ def analysis_contaminant( emit_api( ctx, summary="污染物模拟执行成功", - method="GET", - path="/contaminant-simulation", + method="POST", + path="/contaminant-simulations", params=params, require_auth=True, - require_network_ctx=True, ) @@ -256,21 +239,17 @@ def analysis_sensor_placement_kmeans( ) -> None: runtime = runtime_context(ctx) body = { - "name": require_network(runtime), "scheme_name": resolve_scheme(runtime, scheme, required=True), "sensor_number": count, "min_diameter": min_diameter, - "username": require_username(runtime), } emit_api( ctx, summary="传感器选址执行成功", method="POST", - path="/pressure_sensor_placement_kmeans/", + path="/pressure-sensor-placement-kmeans", json_body=body, require_auth=True, - require_network_ctx=True, - require_username_ctx=True, ) @@ -283,7 +262,6 @@ def analysis_leakage_identify( ) -> None: runtime = runtime_context(ctx) body = { - "network": require_network(runtime), "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), "scheme_name": resolve_scheme(runtime, scheme, required=True), @@ -292,10 +270,9 @@ def analysis_leakage_identify( ctx, summary="漏损识别执行成功", method="POST", - path="/leakage/identify/", + path="/leakage-identifications", json_body=body, require_auth=True, - require_network_ctx=True, ) @@ -308,11 +285,9 @@ def analysis_leakage_schemes_list(ctx: typer.Context) -> None: method="GET", path="/schemes", params={ - "network": require_network(runtime), "scheme_type": "dma_leak_identification", }, require_auth=True, - require_network_ctx=True, ) @@ -328,11 +303,9 @@ def analysis_leakage_schemes_get( method="GET", path=f"/schemes/{scheme_name}", params={ - "network": require_network(runtime), "scheme_type": "dma_leak_identification", }, require_auth=True, - require_network_ctx=True, ) @@ -345,7 +318,6 @@ def analysis_burst_detection_detect( ) -> None: runtime = runtime_context(ctx) body = { - "network": require_network(runtime), "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), "scheme_name": resolve_scheme(runtime, scheme, required=True), @@ -354,10 +326,9 @@ def analysis_burst_detection_detect( ctx, summary="爆管检测执行成功", method="POST", - path="/burst-detection/detect/", + path="/burst-detections", json_body=body, require_auth=True, - require_network_ctx=True, ) @@ -370,11 +341,9 @@ def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None: method="GET", path="/schemes", params={ - "network": require_network(runtime), "scheme_type": "burst_detection", }, require_auth=True, - require_network_ctx=True, ) @@ -390,11 +359,9 @@ def analysis_burst_detection_schemes_get( method="GET", path=f"/schemes/{scheme_name}", params={ - "network": require_network(runtime), "scheme_type": "burst_detection", }, require_auth=True, - require_network_ctx=True, ) @@ -416,7 +383,6 @@ def analysis_burst_location_locate( pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {} flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {} body = { - "network": require_network(runtime), "scheme_name": resolve_scheme(runtime, scheme, required=True), "data_source": data_source.value, "scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), @@ -436,10 +402,9 @@ def analysis_burst_location_locate( ctx, summary="爆管定位执行成功", method="POST", - path="/burst-location/locate/", + path="/burst-locations", json_body=body, require_auth=True, - require_network_ctx=True, ) @@ -452,11 +417,9 @@ def analysis_burst_location_schemes_list(ctx: typer.Context) -> None: method="GET", path="/schemes", params={ - "network": require_network(runtime), "scheme_type": "burst_location", }, require_auth=True, - require_network_ctx=True, ) @@ -472,11 +435,9 @@ def analysis_burst_location_schemes_get( method="GET", path=f"/schemes/{scheme_name}", params={ - "network": require_network(runtime), "scheme_type": "burst_location", }, require_auth=True, - require_network_ctx=True, ) @@ -490,10 +451,9 @@ def analysis_risk_pipe_now( ctx, summary="读取当前管道风险成功", method="GET", - path="/getpiperiskprobabilitynow/", - params={"network": require_network(runtime), "pipe_id": pipe}, + path="/pipes/risk-probability-now", + params={"pipe_id": pipe}, require_auth=True, - require_network_ctx=True, ) @@ -507,32 +467,26 @@ def analysis_risk_pipe_history( ctx, summary="读取历史管道风险成功", method="GET", - path="/getpiperiskprobability/", - params={"network": require_network(runtime), "pipe_id": pipe}, + path="/pipes/risk-probability", + params={"pipe_id": pipe}, require_auth=True, - require_network_ctx=True, ) @analysis_risk_app.command("network") def analysis_risk_network(ctx: typer.Context) -> None: runtime = runtime_context(ctx) - network = require_network(runtime) probabilities, duration_prob = request_json( runtime, method="GET", - path="/getnetworkpiperiskprobabilitynow/", - params={"network": network}, + path="/network-pipe-risk-probability-nows", require_auth=True, - require_network_ctx=True, ) geometries, duration_geo = request_json( runtime, method="GET", - path="/getpiperiskprobabilitygeometries/", - params={"network": network}, + path="/pipes/risk-probability-geometries", require_auth=True, - require_network_ctx=True, ) emit_success( summary="读取全网风险成功", diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py index 5e0810b..d08b14b 100644 --- a/cli/tjwater_cli/commands_data.py +++ b/cli/tjwater_cli/commands_data.py @@ -13,7 +13,7 @@ from .apps import ( data_timeseries_scheme_app, ) from .common import emit_api, runtime_context -from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme +from .core import CLIError, parse_time_with_timezone, resolve_scheme from .option_types import ( CompositeKind, ElementType, @@ -73,7 +73,7 @@ def data_realtime_links( ctx, summary="读取实时管道数据成功", method="GET", - path="/realtime/links", + path="/timeseries/realtime/links", params={ "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), @@ -93,7 +93,7 @@ def data_realtime_nodes( ctx, summary="读取实时节点数据成功", method="GET", - path="/realtime/nodes", + path="/timeseries/realtime/nodes", params={ "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), @@ -114,7 +114,7 @@ def data_realtime_simulation_by_id_time( ctx, summary="读取实时模拟数据成功", method="GET", - path="/realtime/query/by-id-time", + path="/timeseries/realtime/simulation-results", params={ "id": id, "type": type.value, @@ -137,7 +137,7 @@ def data_realtime_simulation_by_time_property( ctx, summary="读取实时属性聚合数据成功", method="GET", - path="/realtime/query/by-time-property", + path="/timeseries/realtime/records", params={ "type": type.value, "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), @@ -161,7 +161,7 @@ def data_scheme_links( ctx, summary="读取方案管道数据成功", method="GET", - path="/scheme/links", + path="/timeseries/schemes/links", params={ "scheme_name": resolve_scheme(runtime, scheme, required=True), "scheme_type": _scheme_type_option(scheme_type), @@ -189,7 +189,7 @@ def data_scheme_node_field( ctx, summary="读取方案节点字段成功", method="GET", - path=f"/scheme/nodes/{node}/field", + path=f"/timeseries/schemes/nodes/{node}/field", params={ "field": field, "scheme_name": resolve_scheme(runtime, scheme, required=True), @@ -233,7 +233,7 @@ def data_scheme_simulation( ctx, summary="读取方案单点模拟数据成功", method="GET", - path="/scheme/query/by-id-time", + path="/timeseries/schemes/simulation-results", params=params, require_auth=True, require_project=True, @@ -253,7 +253,7 @@ def data_scheme_simulation( ctx, summary="读取方案属性聚合数据成功", method="GET", - path="/scheme/query/by-scheme-time-property", + path="/timeseries/schemes/records", params=params, require_auth=True, require_project=True, @@ -270,7 +270,7 @@ def data_scada_query( end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None, ) -> None: - path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range" + path = "/timeseries/scada-readings/fields" if field else "/timeseries/scada-readings" params = { "device_ids": ",".join(device_id), "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), @@ -334,7 +334,7 @@ def data_timeseries_composite( ctx, summary="读取复合 SCADA-模拟数据成功", method="GET", - path="/composite/scada-simulation", + path="/timeseries/views/scada-simulations", params=params, require_auth=True, require_project=True, @@ -357,7 +357,7 @@ def data_timeseries_composite( ctx, summary="读取复合元素模拟数据成功", method="GET", - path="/composite/element-simulation", + path="/timeseries/views/element-simulations", params=params, require_auth=True, require_project=True, @@ -377,7 +377,7 @@ def data_timeseries_composite( ctx, summary="读取元素关联 SCADA 数据成功", method="GET", - path="/composite/element-scada", + path="/timeseries/views/element-scada-readings", params=params, require_auth=True, require_project=True, @@ -398,21 +398,19 @@ def data_composite_pipeline_health( ctx, summary="读取管道健康预测成功", method="GET", - path="/composite/pipeline-health-prediction", + path="/pipeline-health-predictions", params={ - "network_name": require_network(runtime_context(ctx)), "query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), }, require_auth=True, require_project=True, - require_network_ctx=True, ) def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: mapping = { - ("info", "get"): ("/getscadainfo/", {"id_param": "id"}), - ("info", "list"): ("/getallscadainfo/", {}), + ("info", "get"): ("/scada-info/detail", {"id_param": "id"}), + ("info", "list"): ("/scada-info", {}), } result = mapping.get((kind, action)) if result is None: @@ -433,7 +431,7 @@ def data_scada_get( ) -> None: runtime = runtime_context(ctx) path, meta = _scada_mapping(kind.value, "get") - params = {"network": require_network(runtime), meta["id_param"]: id} + params = {meta["id_param"]: id} emit_api( ctx, summary="读取 SCADA 数据成功", @@ -441,7 +439,6 @@ def data_scada_get( path=path, params=params, require_auth=True, - require_network_ctx=True, ) @@ -457,9 +454,7 @@ def data_scada_list( summary="读取 SCADA 列表成功", method="GET", path=path, - params={"network": require_network(runtime)}, require_auth=True, - require_network_ctx=True, ) @@ -470,10 +465,8 @@ def data_scheme_schema(ctx: typer.Context) -> None: ctx, summary="读取方案 schema 成功", method="GET", - path="/getschemeschema/", - params={"network": require_network(runtime)}, + path="/network-schemas/scheme", require_auth=True, - require_network_ctx=True, ) @@ -487,10 +480,9 @@ def data_scheme_get( ctx, summary="读取方案成功", method="GET", - path="/getscheme/", - params={"network": require_network(runtime), "schema_name": name}, + path="/schemes/detail", + params={"schema_name": name}, require_auth=True, - require_network_ctx=True, ) @@ -502,7 +494,5 @@ def data_scheme_list(ctx: typer.Context) -> None: summary="读取方案列表成功", method="GET", path="/schemes", - params={"network": require_network(runtime)}, require_auth=True, - require_network_ctx=True, ) diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py index c57a7d5..035fc41 100644 --- a/cli/tjwater_cli/commands_readonly.py +++ b/cli/tjwater_cli/commands_readonly.py @@ -5,8 +5,8 @@ from typing import Annotated import typer from .apps import component_option_app, network_app -from .common import emit_api, runtime_context -from .core import CLIError, require_network +from .common import emit_api +from .core import CLIError from .option_types import ComponentOptionKind @@ -15,15 +15,13 @@ def network_get_junction_properties( ctx: typer.Context, junction: Annotated[str, typer.Option("--junction", help="节点 ID")], ) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取节点属性成功", method="GET", - path="/getjunctionproperties/", - params={"network": require_network(runtime), "junction": junction}, + path="/junctions/properties", + params={"junction": junction}, require_auth=True, - require_network_ctx=True, ) @@ -32,29 +30,25 @@ def network_get_pipe_properties( ctx: typer.Context, pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], ) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取管道属性成功", method="GET", - path="/getpipeproperties/", - params={"network": require_network(runtime), "pipe": pipe}, + path="/pipes/properties", + params={"pipe": pipe}, require_auth=True, - require_network_ctx=True, ) @network_app.command("get-all-pipes-properties") def network_get_all_pipes_properties(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取全部管道属性成功", method="GET", - path="/getallpipeproperties/", - params={"network": require_network(runtime)}, + path="/pipes", + params={}, require_auth=True, - require_network_ctx=True, ) @@ -63,29 +57,25 @@ def network_get_reservoir_properties( ctx: typer.Context, reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")], ) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取水库属性成功", method="GET", - path="/getreservoirproperties/", - params={"network": require_network(runtime), "reservoir": reservoir}, + path="/reservoirs/properties", + params={"reservoir": reservoir}, require_auth=True, - require_network_ctx=True, ) @network_app.command("get-all-reservoirs-properties") def network_get_all_reservoir_properties(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取全部水库属性成功", method="GET", - path="/getallreservoirproperties/", - params={"network": require_network(runtime)}, + path="/reservoirs", + params={}, require_auth=True, - require_network_ctx=True, ) @@ -94,29 +84,25 @@ def network_get_tank_properties( ctx: typer.Context, tank: Annotated[str, typer.Option("--tank", help="水箱 ID")], ) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取水箱属性成功", method="GET", - path="/gettankproperties/", - params={"network": require_network(runtime), "tank": tank}, + path="/tanks/properties", + params={"tank": tank}, require_auth=True, - require_network_ctx=True, ) @network_app.command("get-all-tanks-properties") def network_get_all_tank_properties(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取全部水箱属性成功", method="GET", - path="/getalltankproperties/", - params={"network": require_network(runtime)}, + path="/tanks", + params={}, require_auth=True, - require_network_ctx=True, ) @@ -125,29 +111,25 @@ def network_get_pump_properties( ctx: typer.Context, pump: Annotated[str, typer.Option("--pump", help="水泵 ID")], ) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取水泵属性成功", method="GET", - path="/getpumpproperties/", - params={"network": require_network(runtime), "pump": pump}, + path="/pumps/properties", + params={"pump": pump}, require_auth=True, - require_network_ctx=True, ) @network_app.command("get-all-pumps-properties") def network_get_all_pump_properties(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取全部水泵属性成功", method="GET", - path="/getallpumpproperties/", - params={"network": require_network(runtime)}, + path="/pumps", + params={}, require_auth=True, - require_network_ctx=True, ) @@ -156,29 +138,25 @@ def network_get_valve_properties( ctx: typer.Context, valve: Annotated[str, typer.Option("--valve", help="阀门 ID")], ) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取阀门属性成功", method="GET", - path="/getvalveproperties/", - params={"network": require_network(runtime), "valve": valve}, + path="/valves/properties", + params={"valve": valve}, require_auth=True, - require_network_ctx=True, ) @network_app.command("get-all-valves-properties") def network_get_all_valve_properties(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) emit_api( ctx, summary="读取全部阀门属性成功", method="GET", - path="/getallvalveproperties/", - params={"network": require_network(runtime)}, + path="/valves", + params={}, require_auth=True, - require_network_ctx=True, ) @@ -188,9 +166,8 @@ def component_option_schema( kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")], pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, ) -> None: - runtime = runtime_context(ctx) path = _component_option_path(kind.value, schema=True) - params = {"network": require_network(runtime)} + params: dict[str, str] = {} if kind == ComponentOptionKind.PUMP_ENERGY and pump: params["pump"] = pump emit_api( @@ -200,7 +177,6 @@ def component_option_schema( path=path, params=params, require_auth=True, - require_network_ctx=True, ) @@ -210,9 +186,8 @@ def component_option_get( kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")], pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, ) -> None: - runtime = runtime_context(ctx) path = _component_option_path(kind.value, schema=False) - params = {"network": require_network(runtime)} + params: dict[str, str] = {} if kind == ComponentOptionKind.PUMP_ENERGY: if not pump: raise CLIError( @@ -229,20 +204,19 @@ def component_option_get( path=path, params=params, require_auth=True, - require_network_ctx=True, ) def _component_option_path(kind: str, *, schema: bool) -> str: routes = { - ("time", True): "/gettimeschema", - ("time", False): "/gettimeproperties/", - ("energy", True): "/getenergyschema/", - ("energy", False): "/getenergyproperties/", - ("pump-energy", True): "/getpumpenergyschema/", - ("pump-energy", False): "/getpumpenergyproperties//", - ("network", True): "/getoptionschema/", - ("network", False): "/getoptionproperties/", + ("time", True): "/network-schemas/time", + ("time", False): "/network-options/time", + ("energy", True): "/network-schemas/energy", + ("energy", False): "/network-options/energy", + ("pump-energy", True): "/network-schemas/pump-energy", + ("pump-energy", False): "/network-options/pump-energy", + ("network", True): "/network-schemas/option", + ("network", False): "/network-options", } path = routes.get((kind, schema)) if path is None: diff --git a/cli/tjwater_cli/common.py b/cli/tjwater_cli/common.py index fef8b64..7a1e9c2 100644 --- a/cli/tjwater_cli/common.py +++ b/cli/tjwater_cli/common.py @@ -38,8 +38,6 @@ def emit_api( json_body: Any = None, require_auth: bool = True, require_project: bool = False, - require_network_ctx: bool = False, - require_username_ctx: bool = False, next_commands: list[str] | None = None, ) -> None: runtime = runtime_context(ctx) @@ -51,8 +49,6 @@ def emit_api( json_body=json_body, require_auth=require_auth, require_project=require_project, - require_network_ctx=require_network_ctx, - require_username_ctx=require_username_ctx, ) emit_success( summary=summary, diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 2fb33da..eb5056b 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -17,8 +17,6 @@ SCHEMA_VERSION = "tjwater-cli/v1" CLI_NAME = "tjwater-cli" DEFAULT_TIMEOUT = 180 DEFAULT_SERVER = "http://192.168.1.114:8000" - - class CLIError(Exception): def __init__( self, @@ -46,8 +44,6 @@ class AuthContext: server: str | None = None access_token: str | None = None project_id: str | None = None - username: str | None = None - network: str | None = None headers: dict[str, str] = field(default_factory=dict) @@ -97,8 +93,6 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext: "server": os.getenv("TJWATER_SERVER"), "access_token": os.getenv("TJWATER_ACCESS_TOKEN"), "project_id": os.getenv("TJWATER_PROJECT_ID"), - "username": os.getenv("TJWATER_USERNAME"), - "network": os.getenv("TJWATER_NETWORK"), "headers": json.loads(extra_headers) if extra_headers else {}, } @@ -115,8 +109,6 @@ def load_auth_context(auth_stdin: bool = False) -> AuthContext: server=_pick(raw, "server", "base_url"), access_token=_pick(raw, "access_token", "token", "accessToken"), project_id=_pick(raw, "project_id", "projectId", "x_project_id"), - username=_pick(raw, "username", "preferred_username"), - network=_pick(raw, "network", "project_code", "projectCode", "project"), headers={str(key): str(value) for key, value in headers.items()}, ) @@ -175,30 +167,6 @@ def require_project_id(ctx: RuntimeContext) -> str: ) -def require_network(ctx: RuntimeContext) -> str: - if ctx.auth.network: - return ctx.auth.network - raise CLIError( - "认证失败", - code="NETWORK_CONTEXT_REQUIRED", - message="missing network in auth context for legacy network-based endpoints", - exit_code=3, - next_commands=["add network to auth context"], - ) - - -def require_username(ctx: RuntimeContext) -> str: - if ctx.auth.username: - return ctx.auth.username - raise CLIError( - "认证失败", - code="USERNAME_CONTEXT_REQUIRED", - message="missing username in auth context", - exit_code=3, - next_commands=["add username to auth context"], - ) - - def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None: scheme = explicit_scheme or ctx.scheme if required and not scheme: @@ -254,14 +222,14 @@ def parse_burst_file(path: Path) -> tuple[list[str], list[float]]: raw = read_json_input(path, label="burst") if isinstance(raw, dict) and "bursts" in raw: raw = raw["bursts"] - if isinstance(raw, dict) and "burst_ID" in raw and "burst_size" in raw: - ids = [str(item) for item in raw["burst_ID"]] + if isinstance(raw, dict) and "burst_id" in raw and "burst_size" in raw: + ids = [str(item) for item in raw["burst_id"]] sizes = [float(item) for item in raw["burst_size"]] if len(ids) != len(sizes): raise CLIError( "CLI 参数错误", code="BURST_FILE_INVALID", - message="burst file burst_ID and burst_size must have the same length", + message="burst file burst_id and burst_size must have the same length", exit_code=2, ) return ids, sizes @@ -282,7 +250,7 @@ def parse_burst_file(path: Path) -> tuple[list[str], list[float]]: raise CLIError( "CLI 参数错误", code="BURST_FILE_INVALID", - message="burst file must be a JSON array or object with burst_ID/burst_size", + message="burst file must be a JSON array or object with burst_id/burst_size", exit_code=2, ) @@ -404,12 +372,13 @@ def _parse_response_body(response: requests.Response) -> Any: return {} -def _with_network_param(params: dict[str, Any] | None, network: str) -> dict[str, Any]: - params = dict(params or {}) - if "network" in params or "network_name" in params or "name" in params: - return params - params["network"] = network - return params +def _prepare_public_request( + method: str, + path: str, + params: dict[str, Any] | None, + json_body: Any, +) -> tuple[str, str, dict[str, Any] | None, Any]: + return method.upper(), path.rstrip("/") or "/", params or None, json_body def request_json( @@ -421,18 +390,14 @@ def request_json( json_body: Any = None, require_auth: bool = True, require_project: bool = False, - require_network_ctx: bool = False, - require_username_ctx: bool = False, ) -> tuple[Any, int]: require_server(ctx) - network = None - if require_network_ctx: - network = require_network(ctx) - if require_username_ctx: - require_username(ctx) - if network and (params is not None or json_body is None): - params = _with_network_param(params, network) - + method, path, params, json_body = _prepare_public_request( + method, + path, + params, + json_body, + ) url = f"{require_server(ctx)}/api/v1{path}" headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) started = time.monotonic() @@ -482,15 +447,14 @@ def request_bytes( params: dict[str, Any] | None = None, require_auth: bool = True, require_project: bool = False, - require_network_ctx: bool = False, ) -> tuple[bytes, int]: require_server(ctx) - network = None - if require_network_ctx: - network = require_network(ctx) - if network: - params = _with_network_param(params, network) - + method, path, params, _ = _prepare_public_request( + method, + path, + params, + None, + ) url = f"{require_server(ctx)}/api/v1{path}" headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) started = time.monotonic() diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 14a9e37..288344f 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -35,73 +35,73 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("network", "get-junction-properties"): CommandDoc( path=("network", "get-junction-properties"), summary="读取节点属性", - description="调用 /getjunctionproperties/。", + description="调用 GET /api/v1/junctions/{junction_id}/properties。", options=(CommandOptionDoc("junction", "节点 ID", required=True),), examples=("tjwater-cli network get-junction-properties --junction J1",), ), ("network", "get-pipe-properties"): CommandDoc( path=("network", "get-pipe-properties"), summary="读取管道属性", - description="调用 /getpipeproperties/。", + description="调用 GET /api/v1/pipes/{pipe_id}/properties。", options=(CommandOptionDoc("pipe", "管道 ID", required=True),), examples=("tjwater-cli network get-pipe-properties --pipe P1",), ), ("network", "get-all-pipes-properties"): CommandDoc( path=("network", "get-all-pipes-properties"), summary="读取全部管道属性", - description="调用 /getallpipeproperties/。", + description="调用 GET /api/v1/pipes/properties。", examples=("tjwater-cli network get-all-pipes-properties",), ), ("network", "get-reservoir-properties"): CommandDoc( path=("network", "get-reservoir-properties"), summary="读取水库属性", - description="调用 /getreservoirproperties/。", + description="调用 GET /api/v1/reservoirs/{reservoir_id}/properties。", options=(CommandOptionDoc("reservoir", "水库 ID", required=True),), examples=("tjwater-cli network get-reservoir-properties --reservoir R1",), ), ("network", "get-all-reservoirs-properties"): CommandDoc( path=("network", "get-all-reservoirs-properties"), summary="读取全部水库属性", - description="调用 /getallreservoirproperties/。", + description="调用 GET /api/v1/reservoirs/properties。", examples=("tjwater-cli network get-all-reservoirs-properties",), ), ("network", "get-tank-properties"): CommandDoc( path=("network", "get-tank-properties"), summary="读取水箱属性", - description="调用 /gettankproperties/。", + description="调用 GET /api/v1/tanks/{tank_id}/properties。", options=(CommandOptionDoc("tank", "水箱 ID", required=True),), examples=("tjwater-cli network get-tank-properties --tank T1",), ), ("network", "get-all-tanks-properties"): CommandDoc( path=("network", "get-all-tanks-properties"), summary="读取全部水箱属性", - description="调用 /getalltankproperties/。", + description="调用 GET /api/v1/tanks/properties。", examples=("tjwater-cli network get-all-tanks-properties",), ), ("network", "get-pump-properties"): CommandDoc( path=("network", "get-pump-properties"), summary="读取水泵属性", - description="调用 /getpumpproperties/。", + description="调用 GET /api/v1/pumps/{pump_id}/properties。", options=(CommandOptionDoc("pump", "水泵 ID", required=True),), examples=("tjwater-cli network get-pump-properties --pump PU1",), ), ("network", "get-all-pumps-properties"): CommandDoc( path=("network", "get-all-pumps-properties"), summary="读取全部水泵属性", - description="调用 /getallpumpproperties/。", + description="调用 GET /api/v1/pumps/properties。", examples=("tjwater-cli network get-all-pumps-properties",), ), ("network", "get-valve-properties"): CommandDoc( path=("network", "get-valve-properties"), summary="读取阀门属性", - description="调用 /getvalveproperties/。", + description="调用 GET /api/v1/valves/{valve_id}/properties。", options=(CommandOptionDoc("valve", "阀门 ID", required=True),), examples=("tjwater-cli network get-valve-properties --valve V1",), ), ("network", "get-all-valves-properties"): CommandDoc( path=("network", "get-all-valves-properties"), summary="读取全部阀门属性", - description="调用 /getallvalveproperties/。", + description="调用 GET /api/v1/valves/properties。", examples=("tjwater-cli network get-all-valves-properties",), ), ("component", "option", "schema"): CommandDoc( @@ -137,7 +137,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("simulation", "run"): CommandDoc( path=("simulation", "run"), summary="触发指定绝对时间的模拟运行", - description="把显式带时区的 RFC3339 start-time 直接传给 /simulations/run-by-date;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", + description="把显式带时区的 RFC3339 start-time 直接传给 POST /api/v1/simulation-runs;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续分钟数", required=True), @@ -152,7 +152,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst"): CommandDoc( path=("analysis", "burst"), summary="执行爆管分析", - description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。", + description="读取 burst-file 的 burst_id[] / burst_size[] 并调用 POST /api/v1/burst-analyses;接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续秒数", required=True), @@ -201,7 +201,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "age"): CommandDoc( path=("analysis", "age"), summary="执行水龄分析", - description="调用 /age_analysis/。duration 单位为秒。", + description="调用 POST /api/v1/water-age-analyses。duration 单位为秒。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续秒数", required=True), @@ -211,7 +211,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "contaminant"): CommandDoc( path=("analysis", "contaminant"), summary="执行污染物模拟", - description="调用 /contaminant-simulation。duration 单位为秒。", + description="调用 POST /api/v1/contaminant-simulations。duration 单位为秒。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("duration", "持续秒数", required=True), @@ -247,19 +247,19 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "leakage", "schemes", "list"): CommandDoc( path=("analysis", "leakage", "schemes", "list"), summary="列出漏损方案", - description="调用 /schemes,并传入 scheme_type=dma_leak_identification。", + description="调用 GET /api/v1/schemes,并传入 scheme_type=dma_leak_identification。", examples=("tjwater-cli analysis leakage schemes list",), ), ("analysis", "leakage", "schemes", "get"): CommandDoc( path=("analysis", "leakage", "schemes", "get"), summary="读取漏损方案详情", - description="调用 /schemes/{scheme_name},并传入 scheme_type=dma_leak_identification。", + description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=dma_leak_identification。", examples=("tjwater-cli analysis leakage schemes get my_scheme",), ), ("analysis", "burst-detection", "detect"): CommandDoc( path=("analysis", "burst-detection", "detect"), summary="执行爆管检测", - description="调用 /burst-detection/detect/。", + description="调用 POST /api/v1/burst-detections。", options=( CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), @@ -270,19 +270,19 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst-detection", "schemes", "list"): CommandDoc( path=("analysis", "burst-detection", "schemes", "list"), summary="列出爆管检测方案", - description="调用 /schemes,并传入 scheme_type=burst_detection。", + description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_detection。", examples=("tjwater-cli analysis burst-detection schemes list",), ), ("analysis", "burst-detection", "schemes", "get"): CommandDoc( path=("analysis", "burst-detection", "schemes", "get"), summary="读取爆管检测方案详情", - description="调用 /schemes/{scheme_name},并传入 scheme_type=burst_detection。", + description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_detection。", examples=("tjwater-cli analysis burst-detection schemes get my_scheme",), ), ("analysis", "burst-location", "locate"): CommandDoc( path=("analysis", "burst-location", "locate"), summary="执行爆管定位", - description="调用 /burst-location/locate/;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。", + description="调用 POST /api/v1/burst-locations;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。", options=( CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), @@ -303,26 +303,26 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("analysis", "burst-location", "schemes", "list"): CommandDoc( path=("analysis", "burst-location", "schemes", "list"), summary="列出爆管定位方案", - description="调用 /schemes,并传入 scheme_type=burst_location。", + description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_location。", examples=("tjwater-cli analysis burst-location schemes list",), ), ("analysis", "burst-location", "schemes", "get"): CommandDoc( path=("analysis", "burst-location", "schemes", "get"), summary="读取爆管定位方案详情", - description="调用 /schemes/{scheme_name},并传入 scheme_type=burst_location。", + description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_location。", examples=("tjwater-cli analysis burst-location schemes get my_scheme",), ), ("analysis", "risk", "pipe-now"): CommandDoc( path=("analysis", "risk", "pipe-now"), summary="读取单条管道当前风险", - description="调用 /getpiperiskprobabilitynow/。", + description="调用 GET /api/v1/pipes/risk-probability-now。", options=(CommandOptionDoc("pipe", "管道 ID", required=True),), examples=("tjwater-cli analysis risk pipe-now --pipe P1",), ), ("analysis", "risk", "pipe-history"): CommandDoc( path=("analysis", "risk", "pipe-history"), summary="读取单条管道历史风险", - description="调用 /getpiperiskprobability/。", + description="调用 GET /api/v1/pipes/risk-probability。", options=(CommandOptionDoc("pipe", "管道 ID", required=True),), examples=("tjwater-cli analysis risk pipe-history --pipe P1",), ), @@ -335,7 +335,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "realtime", "links"): CommandDoc( path=("data", "timeseries", "realtime", "links"), summary="查询实时管道时序", - description="调用 /realtime/links。", + description="调用 GET /api/v1/timeseries/realtime/links。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), @@ -345,7 +345,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "realtime", "nodes"): CommandDoc( path=("data", "timeseries", "realtime", "nodes"), summary="查询实时节点时序", - description="调用 /realtime/nodes。", + description="调用 GET /api/v1/timeseries/realtime/nodes。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), @@ -355,7 +355,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-id-time"), summary="按元素和时间查询实时模拟结果", - description="调用 /realtime/query/by-id-time。", + description="调用 GET /api/v1/timeseries/realtime/by-element。", options=( CommandOptionDoc("id", "元素 ID", required=True), CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), @@ -369,7 +369,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( path=("data", "timeseries", "realtime", "simulation-by-time-property"), summary="按时间和属性查询实时模拟结果", - description="调用 /realtime/query/by-time-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", + description="调用 GET /api/v1/timeseries/realtime/by-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", options=( CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), CommandOptionDoc("time", "显式带时区的查询时间", required=True), @@ -380,7 +380,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scheme", "links"): CommandDoc( path=("data", "timeseries", "scheme", "links"), summary="查询方案管道时序", - description="调用 /scheme/links。", + description="调用 GET /api/v1/timeseries/schemes/links。", options=( CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), @@ -392,7 +392,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "scheme", "node-field"): CommandDoc( path=("data", "timeseries", "scheme", "node-field"), summary="查询方案节点字段时序", - description="调用 /scheme/nodes/{node_id}/field。field 仅支持 actual_demand、total_head、pressure、quality。", + description="调用 GET /api/v1/timeseries/schemes/nodes/{node_id}/{field}。field 仅支持 actual_demand、total_head、pressure、quality。", options=( CommandOptionDoc("node", "节点 ID", required=True), CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True), @@ -458,7 +458,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "timeseries", "composite", "pipeline-health"): CommandDoc( path=("data", "timeseries", "composite", "pipeline-health"), summary="查询管道健康预测", - description="调用 /composite/pipeline-health-prediction。", + description="调用 GET /api/v1/pipeline-health-predictions。", options=( CommandOptionDoc("pipe", "管道 ID", required=True), CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), @@ -486,20 +486,20 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { ("data", "scheme", "schema"): CommandDoc( path=("data", "scheme", "schema"), summary="读取方案 schema", - description="调用 /getschemeschema/。", + description="调用 GET /api/v1/network-schemas/scheme。", examples=("tjwater-cli data scheme schema",), ), ("data", "scheme", "get"): CommandDoc( path=("data", "scheme", "get"), summary="读取单条方案", - description="调用 /getscheme/。", + description="调用 GET /api/v1/schemes/detail。", options=(CommandOptionDoc("name", "方案名称", required=True),), examples=("tjwater-cli data scheme get --name my_scheme",), ), ("data", "scheme", "list"): CommandDoc( path=("data", "scheme", "list"), summary="列出方案", - description="调用 /schemes。", + description="调用 GET /api/v1/schemes。", examples=("tjwater-cli data scheme list",), ), } diff --git a/contracts/manifest.json b/contracts/manifest.json new file mode 100644 index 0000000..719a09e --- /dev/null +++ b/contracts/manifest.json @@ -0,0 +1,9 @@ +{ + "contract_version": "1.0.0", + "contracts": { + "server": { + "file": "server-v1.openapi.json", + "sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c" + } + } +} diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json new file mode 100644 index 0000000..15f2abe --- /dev/null +++ b/contracts/server-v1.openapi.json @@ -0,0 +1,51672 @@ +{ + "components": { + "schemas": { + "AccessContextResponse": { + "properties": { + "is_system_admin": { + "title": "Is System Admin", + "type": "boolean" + }, + "permissions": { + "items": { + "type": "string" + }, + "title": "Permissions", + "type": "array" + }, + "project_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Id" + }, + "project_role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Role" + }, + "system_role": { + "title": "System Role", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "user_id", + "username", + "system_role", + "is_system_admin", + "permissions" + ], + "title": "AccessContextResponse", + "type": "object" + }, + "AdminProjectCreateRequest": { + "properties": { + "code": { + "maxLength": 50, + "minLength": 1, + "title": "Code", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "gs_workspace": { + "maxLength": 100, + "minLength": 1, + "title": "Gs Workspace", + "type": "string" + }, + "map_extent": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Map Extent" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "status": { + "default": "active", + "enum": [ + "active", + "inactive", + "archived" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "name", + "code", + "gs_workspace" + ], + "title": "AdminProjectCreateRequest", + "type": "object" + }, + "AdminProjectResponse": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "gs_workspace": { + "title": "Gs Workspace", + "type": "string" + }, + "map_extent": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Map Extent" + }, + "name": { + "title": "Name", + "type": "string" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "project_id", + "name", + "code", + "gs_workspace", + "status", + "created_at", + "updated_at" + ], + "title": "AdminProjectResponse", + "type": "object" + }, + "AdminProjectUpdateRequest": { + "properties": { + "code": { + "anyOf": [ + { + "maxLength": 50, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "gs_workspace": { + "anyOf": [ + { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gs Workspace" + }, + "map_extent": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Map Extent" + }, + "name": { + "anyOf": [ + { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "status": { + "anyOf": [ + { + "enum": [ + "active", + "inactive", + "archived" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "title": "AdminProjectUpdateRequest", + "type": "object" + }, + "AgentAuthContextResponse": { + "properties": { + "is_superuser": { + "title": "Is Superuser", + "type": "boolean" + }, + "keycloak_sub": { + "title": "Keycloak Sub", + "type": "string" + }, + "network": { + "title": "Network", + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "title": "Permissions", + "type": "array" + }, + "project_id": { + "title": "Project Id", + "type": "string" + }, + "project_role": { + "title": "Project Role", + "type": "string" + }, + "role": { + "title": "Role", + "type": "string" + }, + "token_expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Expires At" + }, + "user_id": { + "title": "User Id", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "user_id", + "keycloak_sub", + "username", + "role", + "is_superuser", + "project_id", + "network", + "project_role", + "permissions" + ], + "title": "AgentAuthContextResponse", + "type": "object" + }, + "AuditLogResponse": { + "description": "审计日志响应", + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ip Address" + }, + "project_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Id" + }, + "request_data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Request Data" + }, + "request_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Method" + }, + "request_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Path" + }, + "resource_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resource Id" + }, + "resource_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resource Type" + }, + "response_status": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Response Status" + }, + "timestamp": { + "format": "date-time", + "title": "Timestamp", + "type": "string" + }, + "user_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Id" + } + }, + "required": [ + "id", + "user_id", + "project_id", + "action", + "resource_type", + "resource_id", + "ip_address", + "request_method", + "request_path", + "request_data", + "response_status", + "timestamp" + ], + "title": "AuditLogResponse", + "type": "object" + }, + "Body_patch_admin_projects_project_id_model_imports": { + "properties": { + "file": { + "description": "桌面端导出的 INP 模型文件", + "format": "binary", + "title": "File", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_patch_admin_projects_project_id_model_imports", + "type": "object" + }, + "Body_post_admin_projects_project_id_model_imports": { + "properties": { + "file": { + "description": "桌面端导出的 INP 模型文件", + "format": "binary", + "title": "File", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_post_admin_projects_project_id_model_imports", + "type": "object" + }, + "Body_post_timeseries_realtime_simulation_results": { + "properties": { + "link_result_list": { + "description": "管道模拟结果列表", + "items": { + "type": "object" + }, + "title": "Link Result List", + "type": "array" + }, + "node_result_list": { + "description": "节点模拟结果列表", + "items": { + "type": "object" + }, + "title": "Node Result List", + "type": "array" + } + }, + "required": [ + "node_result_list", + "link_result_list" + ], + "title": "Body_post_timeseries_realtime_simulation_results", + "type": "object" + }, + "Body_post_timeseries_schemes_simulation_results": { + "properties": { + "link_result_list": { + "description": "管道模拟结果列表", + "items": { + "type": "object" + }, + "title": "Link Result List", + "type": "array" + }, + "node_result_list": { + "description": "节点模拟结果列表", + "items": { + "type": "object" + }, + "title": "Node Result List", + "type": "array" + } + }, + "required": [ + "node_result_list", + "link_result_list" + ], + "title": "Body_post_timeseries_schemes_simulation_results", + "type": "object" + }, + "BurstDetectionRequestRest": { + "properties": { + "data_source": { + "default": "monitoring", + "description": "数据来源:monitoring(监测)或simulation(模拟)", + "title": "Data Source", + "type": "string" + }, + "iforest_params": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "description": "隔离森林算法参数", + "title": "Iforest Params" + }, + "mu": { + "default": 100, + "description": "异常值检测的参数", + "title": "Mu", + "type": "integer" + }, + "observed_pressure_data": { + "anyOf": [ + { + "additionalProperties": { + "items": {}, + "type": "array" + }, + "type": "object" + }, + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "items": { + "items": {}, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "压力观测数据。支持列式字典 {sensor_id: [values,...]}、逐时刻对象数组 [{sensor_id: value,...}, ...]、或二维数组 [[t1_s1, t1_s2], [t2_s1, t2_s2], ...]。", + "title": "Observed Pressure Data" + }, + "points_per_day": { + "default": 1440, + "description": "每天的数据点数", + "title": "Points Per Day", + "type": "integer" + }, + "sampling_interval_minutes": { + "anyOf": [ + { + "maximum": 1440.0, + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "采样间隔(分钟);为空时根据压力 SCADA 传输频率自动推断", + "title": "Sampling Interval Minutes" + }, + "scada_end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "SCADA数据结束时间", + "title": "Scada End" + }, + "scada_start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "SCADA数据起始时间", + "title": "Scada Start" + }, + "scheme_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "方案名称", + "title": "Scheme Name" + }, + "sensor_nodes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "传感器节点列表", + "title": "Sensor Nodes" + }, + "simulation_scheme_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "模拟方案名称", + "title": "Simulation Scheme Name" + }, + "simulation_scheme_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "模拟方案类型", + "title": "Simulation Scheme Type" + }, + "target_time": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "目标侦测时刻;为空时自动使用最近一个完整的监测时刻", + "title": "Target Time" + } + }, + "title": "BurstDetectionRequestRest", + "type": "object" + }, + "BurstLocationRequestRest": { + "properties": { + "basic_pressure": { + "default": 10.0, + "description": "基准压力(bar)", + "title": "Basic Pressure", + "type": "number" + }, + "burst_flow": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "爆管时的流量数据", + "title": "Burst Flow" + }, + "burst_leakage": { + "description": "爆管时的漏水量", + "title": "Burst Leakage", + "type": "number" + }, + "burst_pressure": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "爆管时的压力数据", + "title": "Burst Pressure" + }, + "data_source": { + "default": "monitoring", + "description": "数据来源:monitoring(监测)或simulation(模拟)", + "enum": [ + "monitoring", + "simulation" + ], + "title": "Data Source", + "type": "string" + }, + "flow_scada_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "流量SCADA传感器ID列表", + "title": "Flow Scada Ids" + }, + "min_dpressure": { + "default": 2.0, + "description": "最小压力差(bar)", + "title": "Min Dpressure", + "type": "number" + }, + "normal_flow": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "正常时的流量数据", + "title": "Normal Flow" + }, + "normal_pressure": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "正常时的压力数据", + "title": "Normal Pressure" + }, + "pressure_scada_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "压力SCADA传感器ID列表", + "title": "Pressure Scada Ids" + }, + "scada_burst_end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "爆管/模拟方案结束时间", + "title": "Scada Burst End" + }, + "scada_burst_start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "爆管/模拟方案开始时间", + "title": "Scada Burst Start" + }, + "scada_normal_end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "监测数据正常工况结束时间", + "title": "Scada Normal End" + }, + "scada_normal_start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "监测数据正常工况开始时间", + "title": "Scada Normal Start" + }, + "scheme_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "方案名称", + "title": "Scheme Name" + }, + "simulation_scheme_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "模拟方案名称", + "title": "Simulation Scheme Name" + }, + "simulation_scheme_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "模拟方案类型", + "title": "Simulation Scheme Type" + }, + "use_scada_flow": { + "default": false, + "description": "是否使用SCADA流量数据", + "title": "Use Scada Flow", + "type": "boolean" + } + }, + "required": [ + "burst_leakage" + ], + "title": "BurstLocationRequestRest", + "type": "object" + }, + "DailySchedulingAnalysisRest": { + "properties": { + "pump_control": { + "description": "泵控制策略", + "title": "Pump Control", + "type": "object" + }, + "reservoir_id": { + "description": "水库ID", + "title": "Reservoir Id", + "type": "string" + }, + "start_time": { + "description": "开始时间", + "title": "Start Time", + "type": "string" + }, + "tank_id": { + "description": "水箱ID", + "title": "Tank Id", + "type": "string" + }, + "time_delta": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 300, + "description": "时间步长 (秒)", + "title": "Time Delta" + }, + "water_plant_output_id": { + "description": "水厂出水ID", + "title": "Water Plant Output Id", + "type": "string" + } + }, + "required": [ + "start_time", + "pump_control", + "reservoir_id", + "tank_id", + "water_plant_output_id" + ], + "title": "DailySchedulingAnalysisRest", + "type": "object" + }, + "JsonValue": {}, + "LeakageIdentifyRequestRest": { + "properties": { + "dma_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "DMA区域数量", + "title": "Dma Count" + }, + "duration": { + "default": 24, + "description": "持续时间(小时)", + "title": "Duration", + "type": "number" + }, + "max_gen": { + "default": 100, + "description": "最大代数", + "title": "Max Gen", + "type": "integer" + }, + "n_workers": { + "default": 4, + "description": "工作线程数", + "title": "N Workers", + "type": "integer" + }, + "observed_pressure_data": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": { + "items": {}, + "type": "array" + }, + "type": "object" + }, + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "观测的压力数据", + "title": "Observed Pressure Data" + }, + "output_dir": { + "default": "db_inp", + "description": "输出目录", + "title": "Output Dir", + "type": "string" + }, + "output_flow_unit": { + "default": "m3/s", + "description": "输出流量单位", + "title": "Output Flow Unit", + "type": "string" + }, + "pop_size": { + "default": 50, + "description": "种群大小", + "title": "Pop Size", + "type": "integer" + }, + "q_sum": { + "default": 0.2, + "description": "总流量(m3/s)", + "title": "Q Sum", + "type": "number" + }, + "q_sum_unit": { + "default": "m3/s", + "description": "流量单位", + "title": "Q Sum Unit", + "type": "string" + }, + "scada_end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "SCADA数据结束时间", + "title": "Scada End" + }, + "scada_start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "SCADA数据起始时间", + "title": "Scada Start" + }, + "scheme_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "方案名称", + "title": "Scheme Name" + }, + "sensor_nodes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "传感器节点列表", + "title": "Sensor Nodes" + }, + "start_time": { + "default": 0, + "description": "起始时间(小时)", + "title": "Start Time", + "type": "number" + }, + "timestep": { + "default": 5, + "description": "时间步长(分钟)", + "title": "Timestep", + "type": "number" + } + }, + "title": "LeakageIdentifyRequestRest", + "type": "object" + }, + "MetadataUserResponse": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "email": { + "title": "Email", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "is_superuser": { + "title": "Is Superuser", + "type": "boolean" + }, + "keycloak_id": { + "format": "uuid", + "title": "Keycloak Id", + "type": "string" + }, + "last_login_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Login At" + }, + "role": { + "title": "Role", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "id", + "keycloak_id", + "username", + "email", + "role", + "is_active", + "is_superuser", + "created_at", + "updated_at" + ], + "title": "MetadataUserResponse", + "type": "object" + }, + "MetadataUserSyncRequest": { + "properties": { + "email": { + "maxLength": 100, + "minLength": 1, + "title": "Email", + "type": "string" + }, + "is_active": { + "default": true, + "title": "Is Active", + "type": "boolean" + }, + "keycloak_id": { + "format": "uuid", + "title": "Keycloak Id", + "type": "string" + }, + "role": { + "default": "user", + "enum": [ + "admin", + "user" + ], + "title": "Role", + "type": "string" + }, + "username": { + "maxLength": 50, + "minLength": 1, + "title": "Username", + "type": "string" + } + }, + "required": [ + "keycloak_id", + "username", + "email" + ], + "title": "MetadataUserSyncRequest", + "type": "object" + }, + "MetadataUserSyncResult": { + "properties": { + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "keycloak_id": { + "format": "uuid", + "title": "Keycloak Id", + "type": "string" + }, + "success": { + "title": "Success", + "type": "boolean" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataUserResponse" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "keycloak_id", + "success" + ], + "title": "MetadataUserSyncResult", + "type": "object" + }, + "MetadataUserUpdateRequest": { + "properties": { + "is_active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Active" + }, + "role": { + "anyOf": [ + { + "enum": [ + "admin", + "user" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Role" + } + }, + "title": "MetadataUserUpdateRequest", + "type": "object" + }, + "MetadataUsersBatchSyncRequest": { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/MetadataUserSyncRequest" + }, + "maxItems": 500, + "minItems": 1, + "title": "Users", + "type": "array" + } + }, + "required": [ + "users" + ], + "title": "MetadataUsersBatchSyncRequest", + "type": "object" + }, + "Page_AdminProjectResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AdminProjectResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[AdminProjectResponse]", + "type": "object" + }, + "Page_AuditLogResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AuditLogResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[AuditLogResponse]", + "type": "object" + }, + "Page_MetadataUserResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MetadataUserResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[MetadataUserResponse]", + "type": "object" + }, + "Page_MetadataUserSyncResult_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MetadataUserSyncResult" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[MetadataUserSyncResult]", + "type": "object" + }, + "Page_ProjectDatabaseResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ProjectDatabaseResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[ProjectDatabaseResponse]", + "type": "object" + }, + "Page_ProjectMemberResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ProjectMemberResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[ProjectMemberResponse]", + "type": "object" + }, + "Page_ProjectSummaryResponse_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ProjectSummaryResponse" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[ProjectSummaryResponse]", + "type": "object" + }, + "Page_dict_Any__Any__": { + "properties": { + "items": { + "items": { + "type": "object" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[dict[Any, Any]]", + "type": "object" + }, + "Page_dict_str__Any__": { + "properties": { + "items": { + "items": { + "type": "object" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[dict[str, Any]]", + "type": "object" + }, + "Page_dict_str__list_str___": { + "properties": { + "items": { + "items": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[dict[str, list[str]]]", + "type": "object" + }, + "Page_list_str__": { + "properties": { + "items": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[list[str]]", + "type": "object" + }, + "Page_str_": { + "properties": { + "items": { + "items": { + "type": "string" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[str]", + "type": "object" + }, + "Page_tuple_int__str__": { + "properties": { + "items": { + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "type": "array" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "Page[tuple[int, str]]", + "type": "object" + }, + "PressureRegulationRest": { + "properties": { + "duration": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 900, + "description": "持续时间 (秒)", + "title": "Duration" + }, + "pump_control": { + "description": "泵控制策略", + "title": "Pump Control", + "type": "object" + }, + "scheme_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "方案名称", + "title": "Scheme Name" + }, + "start_time": { + "description": "开始时间", + "title": "Start Time", + "type": "string" + }, + "tank_init_level": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "description": "水箱初始水位", + "title": "Tank Init Level" + } + }, + "required": [ + "start_time", + "pump_control" + ], + "title": "PressureRegulationRest", + "type": "object" + }, + "PressureSensorPlacement": { + "properties": { + "min_diameter": { + "default": 0, + "description": "最小管径限制", + "title": "Min Diameter", + "type": "integer" + }, + "name": { + "description": "管网名称(或数据库名称)", + "title": "Name", + "type": "string" + }, + "scheme_name": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + }, + "sensor_number": { + "description": "传感器数量", + "title": "Sensor Number", + "type": "integer" + }, + "username": { + "description": "用户名", + "title": "Username", + "type": "string" + } + }, + "required": [ + "name", + "scheme_name", + "sensor_number", + "username" + ], + "title": "PressureSensorPlacement", + "type": "object" + }, + "ProblemDetails": { + "description": "RFC 9457 compatible error response used by the REST contract.", + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "detail": { + "title": "Detail", + "type": "string" + }, + "errors": { + "items": { + "type": "object" + }, + "title": "Errors", + "type": "array" + }, + "instance": { + "title": "Instance", + "type": "string" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "trace_id": { + "title": "Trace Id", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "instance", + "code", + "trace_id" + ], + "title": "ProblemDetails", + "type": "object" + }, + "ProjectDatabaseHealthRequest": { + "properties": { + "dsn": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dsn" + } + }, + "title": "ProjectDatabaseHealthRequest", + "type": "object" + }, + "ProjectDatabaseHealthResponse": { + "properties": { + "db_role": { + "title": "Db Role", + "type": "string" + }, + "db_type": { + "title": "Db Type", + "type": "string" + }, + "detail": { + "title": "Detail", + "type": "string" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + "required": [ + "project_id", + "db_role", + "db_type", + "ok", + "detail" + ], + "title": "ProjectDatabaseHealthResponse", + "type": "object" + }, + "ProjectDatabaseResponse": { + "properties": { + "db_role": { + "title": "Db Role", + "type": "string" + }, + "db_type": { + "title": "Db Type", + "type": "string" + }, + "has_dsn": { + "title": "Has Dsn", + "type": "boolean" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "pool_max_size": { + "title": "Pool Max Size", + "type": "integer" + }, + "pool_min_size": { + "title": "Pool Min Size", + "type": "integer" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "db_role", + "db_type", + "pool_min_size", + "pool_max_size", + "has_dsn" + ], + "title": "ProjectDatabaseResponse", + "type": "object" + }, + "ProjectDatabaseUpsertRequest": { + "properties": { + "db_role": { + "enum": [ + "biz_data", + "iot_data" + ], + "title": "Db Role", + "type": "string" + }, + "dsn": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dsn" + }, + "pool_max_size": { + "default": 10, + "minimum": 1.0, + "title": "Pool Max Size", + "type": "integer" + }, + "pool_min_size": { + "default": 2, + "minimum": 1.0, + "title": "Pool Min Size", + "type": "integer" + } + }, + "required": [ + "db_role" + ], + "title": "ProjectDatabaseUpsertRequest", + "type": "object" + }, + "ProjectManagementRest": { + "properties": { + "pump_control": { + "description": "泵控制策略", + "title": "Pump Control", + "type": "object" + }, + "region_demand": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "description": "区域需水量控制", + "title": "Region Demand" + }, + "start_time": { + "description": "开始时间", + "title": "Start Time", + "type": "string" + }, + "tank_init_level": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "description": "水箱初始水位", + "title": "Tank Init Level" + } + }, + "required": [ + "start_time", + "pump_control" + ], + "title": "ProjectManagementRest", + "type": "object" + }, + "ProjectMemberCreateRequest": { + "properties": { + "project_role": { + "default": "viewer", + "enum": [ + "member", + "viewer" + ], + "title": "Project Role", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "title": "ProjectMemberCreateRequest", + "type": "object" + }, + "ProjectMemberResponse": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "project_role": { + "title": "Project Role", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "id", + "user_id", + "project_id", + "project_role", + "username", + "email", + "is_active" + ], + "title": "ProjectMemberResponse", + "type": "object" + }, + "ProjectMemberUpdateRequest": { + "properties": { + "project_role": { + "enum": [ + "member", + "viewer" + ], + "title": "Project Role", + "type": "string" + } + }, + "required": [ + "project_role" + ], + "title": "ProjectMemberUpdateRequest", + "type": "object" + }, + "ProjectMetaResponse": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "gs_workspace": { + "title": "Gs Workspace", + "type": "string" + }, + "map_extent": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Map Extent" + }, + "name": { + "title": "Name", + "type": "string" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "project_role": { + "title": "Project Role", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "project_id", + "name", + "code", + "gs_workspace", + "status", + "project_role" + ], + "title": "ProjectMetaResponse", + "type": "object" + }, + "ProjectSummaryResponse": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "gs_workspace": { + "title": "Gs Workspace", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "project_role": { + "title": "Project Role", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "project_id", + "name", + "code", + "gs_workspace", + "status", + "project_role" + ], + "title": "ProjectSummaryResponse", + "type": "object" + }, + "PumpFailureState": { + "properties": { + "pump_status": { + "description": "泵状态字典", + "title": "Pump Status", + "type": "object" + }, + "time": { + "description": "故障发生时间", + "title": "Time", + "type": "string" + } + }, + "required": [ + "time", + "pump_status" + ], + "title": "PumpFailureState", + "type": "object" + }, + "RunSimulationManuallyByDateRest": { + "properties": { + "duration": { + "description": "持续时间 (分钟)", + "exclusiveMinimum": 0.0, + "title": "Duration", + "type": "integer" + }, + "start_time": { + "description": "开始时间 (ISO 8601 / RFC3339,必须显式带时区)", + "title": "Start Time", + "type": "string" + } + }, + "required": [ + "start_time", + "duration" + ], + "title": "RunSimulationManuallyByDateRest", + "type": "object" + }, + "SchedulingAnalysisRest": { + "properties": { + "pump_control": { + "description": "泵控制策略", + "title": "Pump Control", + "type": "object" + }, + "start_time": { + "description": "开始时间", + "title": "Start Time", + "type": "string" + }, + "tank_id": { + "description": "水箱ID", + "title": "Tank Id", + "type": "string" + }, + "time_delta": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 300, + "description": "时间步长 (秒)", + "title": "Time Delta" + }, + "water_plant_output_id": { + "description": "水厂出水ID", + "title": "Water Plant Output Id", + "type": "string" + } + }, + "required": [ + "start_time", + "pump_control", + "tank_id", + "water_plant_output_id" + ], + "title": "SchedulingAnalysisRest", + "type": "object" + }, + "SensorPlacementExportRequest": { + "properties": { + "adjustment_status": { + "additionalProperties": { + "enum": [ + "current", + "original", + "added", + "replaced" + ], + "type": "string" + }, + "maxProperties": 200, + "title": "Adjustment Status", + "type": "object" + }, + "sensor_location": { + "items": { + "type": "string" + }, + "maxItems": 200, + "minItems": 1, + "title": "Sensor Location", + "type": "array" + } + }, + "required": [ + "sensor_location" + ], + "title": "SensorPlacementExportRequest", + "type": "object" + }, + "SensorPlacementOptimizeRequestRest": { + "properties": { + "method": { + "enum": [ + "sensitivity", + "kmeans" + ], + "title": "Method", + "type": "string" + }, + "min_diameter": { + "default": 0, + "minimum": 0.0, + "title": "Min Diameter", + "type": "integer" + }, + "scheme_name": { + "maxLength": 32, + "minLength": 1, + "title": "Scheme Name", + "type": "string" + }, + "sensor_count": { + "exclusiveMinimum": 0.0, + "maximum": 200.0, + "title": "Sensor Count", + "type": "integer" + }, + "sensor_type": { + "const": "pressure", + "title": "Sensor Type", + "type": "string" + } + }, + "required": [ + "scheme_name", + "sensor_type", + "method", + "sensor_count" + ], + "title": "SensorPlacementOptimizeRequestRest", + "type": "object" + }, + "SensorPlacementSchemeResponse": { + "properties": { + "can_edit": { + "default": false, + "title": "Can Edit", + "type": "boolean" + }, + "create_time": { + "format": "date-time", + "title": "Create Time", + "type": "string" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "min_diameter": { + "title": "Min Diameter", + "type": "integer" + }, + "scheme_name": { + "title": "Scheme Name", + "type": "string" + }, + "sensor_location": { + "items": { + "type": "string" + }, + "title": "Sensor Location", + "type": "array" + }, + "sensor_number": { + "title": "Sensor Number", + "type": "integer" + }, + "sensor_points": { + "items": { + "$ref": "#/components/schemas/SensorPointResponse" + }, + "title": "Sensor Points", + "type": "array" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "id", + "scheme_name", + "sensor_number", + "min_diameter", + "username", + "create_time", + "sensor_location", + "sensor_points" + ], + "title": "SensorPlacementSchemeResponse", + "type": "object" + }, + "SensorPlacementUpdateRequest": { + "properties": { + "expected_sensor_location": { + "items": { + "type": "string" + }, + "maxItems": 200, + "minItems": 1, + "title": "Expected Sensor Location", + "type": "array" + }, + "sensor_location": { + "items": { + "type": "string" + }, + "maxItems": 200, + "minItems": 1, + "title": "Sensor Location", + "type": "array" + } + }, + "required": [ + "expected_sensor_location", + "sensor_location" + ], + "title": "SensorPlacementUpdateRequest", + "type": "object" + }, + "SensorPointResponse": { + "properties": { + "elevation": { + "title": "Elevation", + "type": "number" + }, + "latitude": { + "title": "Latitude", + "type": "number" + }, + "longitude": { + "title": "Longitude", + "type": "number" + }, + "map_x": { + "title": "Map X", + "type": "number" + }, + "map_y": { + "title": "Map Y", + "type": "number" + }, + "node_id": { + "title": "Node Id", + "type": "string" + }, + "project_x": { + "title": "Project X", + "type": "number" + }, + "project_y": { + "title": "Project Y", + "type": "number" + } + }, + "required": [ + "node_id", + "project_x", + "project_y", + "map_x", + "map_y", + "longitude", + "latitude", + "elevation" + ], + "title": "SensorPointResponse", + "type": "object" + }, + "SessionAuditEventRequest": { + "properties": { + "event": { + "enum": [ + "login", + "logout" + ], + "title": "Event", + "type": "string" + } + }, + "required": [ + "event" + ], + "title": "SessionAuditEventRequest", + "type": "object" + }, + "TiandituGeocodeRequest": { + "properties": { + "keyword": { + "description": "地理编码地址关键字", + "minLength": 1, + "title": "Keyword", + "type": "string" + } + }, + "required": [ + "keyword" + ], + "title": "TiandituGeocodeRequest", + "type": "object" + }, + "WebSearchRequest": { + "properties": { + "count": { + "default": 10, + "description": "返回结果数量", + "maximum": 50.0, + "minimum": 1.0, + "title": "Count", + "type": "integer" + }, + "exclude": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "排除搜索域名", + "title": "Exclude" + }, + "freshness": { + "anyOf": [ + { + "enum": [ + "noLimit", + "oneDay", + "oneWeek", + "oneMonth", + "oneYear" + ], + "type": "string" + }, + { + "type": "string" + } + ], + "default": "noLimit", + "description": "时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围", + "title": "Freshness" + }, + "include": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "限定搜索域名", + "title": "Include" + }, + "query": { + "description": "搜索关键词", + "minLength": 1, + "title": "Query", + "type": "string" + }, + "summary": { + "default": true, + "description": "是否返回网页摘要", + "title": "Summary", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "title": "WebSearchRequest", + "type": "object" + } + }, + "securitySchemes": { + "OAuth2PasswordBearer": { + "flows": { + "password": { + "scopes": {}, + "tokenUrl": "keycloak" + } + }, + "type": "oauth2" + } + } + }, + "info": { + "description": "TJWater Server - 供水管网智能管理系统", + "title": "TJWater Server", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/access-context": { + "get": { + "operationId": "get_access_context", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessContextResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Access Context", + "tags": [ + "Access Control" + ] + } + }, + "/api/v1/admin/projects": { + "get": { + "operationId": "get_admin_projects", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_AdminProjectResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Admin Projects", + "tags": [ + "Metadata Admin" + ] + }, + "post": { + "operationId": "post_admin_projects", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminProjectCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminProjectResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Create Admin Project", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}": { + "patch": { + "operationId": "patch_admin_projects_project_id", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminProjectUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminProjectResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Update Admin Project", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}/databases": { + "get": { + "operationId": "get_admin_projects_project_id_databases", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ProjectDatabaseResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Project Databases", + "tags": [ + "Metadata Admin" + ] + }, + "put": { + "operationId": "put_admin_projects_project_id_databases", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDatabaseUpsertRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDatabaseResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Upsert Project Database", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}/databases/{db_role}": { + "delete": { + "operationId": "delete_admin_projects_project_id_databases_db_role", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "db_role", + "required": true, + "schema": { + "enum": [ + "biz_data", + "iot_data" + ], + "title": "Db Role", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Delete Project Database", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}/databases/{db_role}/health-checks": { + "post": { + "operationId": "post_admin_projects_project_id_databases_db_role_health_checks", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "db_role", + "required": true, + "schema": { + "enum": [ + "biz_data", + "iot_data" + ], + "title": "Db Role", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectDatabaseHealthRequest" + }, + { + "type": "null" + } + ], + "title": "Payload" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDatabaseHealthResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Check Project Database Health", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}/members": { + "get": { + "operationId": "get_admin_projects_project_id_members", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ProjectMemberResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Project Members", + "tags": [ + "Metadata Admin" + ] + }, + "post": { + "operationId": "post_admin_projects_project_id_members", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMemberCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMemberResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Add Project Member", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}/members/{user_id}": { + "delete": { + "operationId": "delete_admin_projects_project_id_members_user_id", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Remove Project Member", + "tags": [ + "Metadata Admin" + ] + }, + "patch": { + "operationId": "patch_admin_projects_project_id_members_user_id", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMemberUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMemberResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Update Project Member", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/projects/{project_id}/model-imports": { + "patch": { + "operationId": "patch_admin_projects_project_id_model_imports", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_patch_admin_projects_project_id_model_imports" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Patch Admin Projects Project Id Model Imports", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新桌面端水力模型", + "tags": [ + "Model Administration" + ] + }, + "post": { + "operationId": "post_admin_projects_project_id_model_imports", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_post_admin_projects_project_id_model_imports" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Admin Projects Project Id Model Imports", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "导入桌面端水力模型", + "tags": [ + "Model Administration" + ] + } + }, + "/api/v1/admin/user-syncs": { + "post": { + "operationId": "post_admin_user_syncs", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUserSyncRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUserResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Sync Metadata User", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/user-syncs/batches": { + "post": { + "operationId": "post_admin_user_syncs_batches", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUsersBatchSyncRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_MetadataUserSyncResult_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Sync Metadata Users Batch", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/users": { + "get": { + "operationId": "get_admin_users", + "parameters": [ + { + "in": "query", + "name": "skip", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Skip", + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_MetadataUserResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Metadata Users", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/users/me": { + "get": { + "operationId": "get_admin_users_me", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUserResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Metadata Admin Me", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/admin/users/{user_id}": { + "get": { + "operationId": "get_admin_users_user_id", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUserResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Metadata User", + "tags": [ + "Metadata Admin" + ] + }, + "patch": { + "operationId": "patch_admin_users_user_id", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUserUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataUserResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Update Metadata User", + "tags": [ + "Metadata Admin" + ] + } + }, + "/api/v1/agent-auth-context": { + "get": { + "operationId": "get_agent_auth_context", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentAuthContextResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Agent Auth Context", + "tags": [ + "Agent Auth" + ] + } + }, + "/api/v1/all-extension-data-keys": { + "get": { + "description": "获取指定网络的所有扩展数据的键列表", + "operationId": "get_all_extension_data_keys", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有扩展数据键", + "tags": [ + "Extension" + ] + } + }, + "/api/v1/all-extension-datas": { + "get": { + "description": "获取指定网络的所有扩展数据", + "operationId": "get_all_extension_datas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get All Extension Datas", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有扩展数据", + "tags": [ + "Extension" + ] + } + }, + "/api/v1/all-redis": { + "delete": { + "description": "清空整个Redis数据库的所有缓存", + "operationId": "delete_all_redis", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清除所有缓存", + "tags": [ + "Cache" + ] + } + }, + "/api/v1/all-scada-properties": { + "get": { + "description": "获取指定水网中所有SCADA点的属性信息", + "operationId": "get_all_scada_properties", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有SCADA点属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/all-vertices": { + "get": { + "description": "获取网络中的所有图形元素详细信息", + "operationId": "get_all_vertices", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有图形元素", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/audit-events": { + "post": { + "operationId": "post_audit_events", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionAuditEventRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Record Session Event", + "tags": [ + "Audit Logs" + ] + } + }, + "/api/v1/audit-logs": { + "get": { + "description": "查询审计日志(仅管理员)", + "operationId": "get_audit_logs", + "parameters": [ + { + "description": "按用户ID过滤", + "in": "query", + "name": "user_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按用户ID过滤", + "title": "User Id" + } + }, + { + "description": "按项目ID过滤", + "in": "query", + "name": "project_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按项目ID过滤", + "title": "Project Id" + } + }, + { + "description": "按操作类型过滤", + "in": "query", + "name": "action", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按操作类型过滤", + "title": "Action" + } + }, + { + "description": "按资源类型过滤", + "in": "query", + "name": "resource_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按资源类型过滤", + "title": "Resource Type" + } + }, + { + "description": "开始时间", + "in": "query", + "name": "start_time", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "开始时间", + "title": "Start Time" + } + }, + { + "description": "结束时间", + "in": "query", + "name": "end_time", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "结束时间", + "title": "End Time" + } + }, + { + "description": "跳过记录数", + "in": "query", + "name": "skip", + "required": false, + "schema": { + "default": 0, + "description": "跳过记录数", + "minimum": 0, + "title": "Skip", + "type": "integer" + } + }, + { + "description": "限制记录数", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "description": "限制记录数", + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_AuditLogResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询审计日志", + "tags": [ + "Audit Logs" + ] + } + }, + "/api/v1/audit-logs/count": { + "get": { + "description": "获取审计日志总数(仅管理员)", + "operationId": "get_audit_logs_count", + "parameters": [ + { + "description": "按用户ID过滤", + "in": "query", + "name": "user_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按用户ID过滤", + "title": "User Id" + } + }, + { + "description": "按项目ID过滤", + "in": "query", + "name": "project_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按项目ID过滤", + "title": "Project Id" + } + }, + { + "description": "按操作类型过滤", + "in": "query", + "name": "action", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按操作类型过滤", + "title": "Action" + } + }, + { + "description": "按资源类型过滤", + "in": "query", + "name": "resource_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按资源类型过滤", + "title": "Resource Type" + } + }, + { + "description": "开始时间", + "in": "query", + "name": "start_time", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "开始时间", + "title": "Start Time" + } + }, + { + "description": "结束时间", + "in": "query", + "name": "end_time", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "结束时间", + "title": "End Time" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Audit Logs Count", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取审计日志总数", + "tags": [ + "Audit Logs" + ] + } + }, + "/api/v1/audit-logs/mine": { + "get": { + "description": "查询当前用户的审计日志", + "operationId": "get_audit_logs_mine", + "parameters": [ + { + "description": "按操作类型过滤", + "in": "query", + "name": "action", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "按操作类型过滤", + "title": "Action" + } + }, + { + "description": "开始时间", + "in": "query", + "name": "start_time", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "开始时间", + "title": "Start Time" + } + }, + { + "description": "结束时间", + "in": "query", + "name": "end_time", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "结束时间", + "title": "End Time" + } + }, + { + "description": "跳过记录数", + "in": "query", + "name": "skip", + "required": false, + "schema": { + "default": 0, + "description": "跳过记录数", + "minimum": 0, + "title": "Skip", + "type": "integer" + } + }, + { + "description": "限制记录数", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "description": "限制记录数", + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_AuditLogResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询我的审计日志", + "tags": [ + "Audit Logs" + ] + } + }, + "/api/v1/backdrops/properties": { + "get": { + "description": "获取指定网络的背景属性信息", + "operationId": "get_backdrops_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Backdrops Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取背景属性", + "tags": [ + "Visuals" + ] + }, + "patch": { + "description": "更新指定网络的背景属性", + "operationId": "patch_backdrops_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置背景属性", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/burst-analyses": { + "post": { + "description": "高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。", + "operationId": "post_burst_analyses", + "parameters": [ + { + "description": "模式修改开始时间(ISO 8601格式)", + "in": "query", + "name": "modify_pattern_start_time", + "required": true, + "schema": { + "description": "模式修改开始时间(ISO 8601格式)", + "title": "Modify Pattern Start Time", + "type": "string" + } + }, + { + "description": "爆管节点/管段ID列表", + "in": "query", + "name": "burst_id", + "required": true, + "schema": { + "description": "爆管节点/管段ID列表", + "items": { + "type": "string" + }, + "title": "Burst Id", + "type": "array" + } + }, + { + "description": "对应各爆管点的爆管流量大小列表(L/s)", + "in": "query", + "name": "burst_size", + "required": true, + "schema": { + "description": "对应各爆管点的爆管流量大小列表(L/s)", + "items": { + "type": "number" + }, + "title": "Burst Size", + "type": "array" + } + }, + { + "description": "模拟总时长(秒)", + "in": "query", + "name": "modify_total_duration", + "required": true, + "schema": { + "description": "模拟总时长(秒)", + "title": "Modify Total Duration", + "type": "integer" + } + }, + { + "description": "分析方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "分析方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Burst Analyses", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "爆管分析(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/burst-detections": { + "post": { + "description": "基于压力观测数据和其他参数执行爆管检测分析", + "operationId": "post_burst_detections", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BurstDetectionRequestRest", + "description": "爆管检测请求数据" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Burst Detections", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "执行爆管检测", + "tags": [ + "Burst Detection" + ] + } + }, + "/api/v1/burst-locations": { + "get": { + "description": "获取网络中所有爆管定位的分析结果", + "operationId": "get_burst_locations", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_Any__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有爆管定位结果", + "tags": [ + "Misc" + ] + }, + "post": { + "description": "基于压力和流量数据定位管网中的爆管位置", + "operationId": "post_burst_locations", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BurstLocationRequestRest", + "description": "爆管定位请求数据" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Burst Locations", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "执行爆管定位", + "tags": [ + "Burst Location" + ] + } + }, + "/api/v1/burst-locations/database-view": { + "get": { + "description": "使用连接池查询所有爆管定位结果", + "operationId": "get_burst_locations_database_view", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取爆管定位结果", + "tags": [ + "Project Data" + ] + } + }, + "/api/v1/burst-locations/{burst_incident}": { + "get": { + "description": "根据爆管事件ID查询对应的爆管定位结果", + "operationId": "get_burst_locations_burst_incident", + "parameters": [ + { + "description": "爆管事件ID", + "in": "path", + "name": "burst_incident", + "required": true, + "schema": { + "description": "爆管事件ID", + "title": "Burst Incident", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按事件查询爆管定位结果", + "tags": [ + "Project Data" + ] + } + }, + "/api/v1/contaminant-simulations": { + "post": { + "description": "对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。", + "operationId": "post_contaminant_simulations", + "parameters": [ + { + "description": "污染开始时间(ISO 8601格式)", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "污染开始时间(ISO 8601格式)", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "污染源节点ID", + "in": "query", + "name": "source", + "required": true, + "schema": { + "description": "污染源节点ID", + "title": "Source", + "type": "string" + } + }, + { + "description": "污染浓度(mg/L)", + "in": "query", + "name": "concentration", + "required": true, + "schema": { + "description": "污染浓度(mg/L)", + "title": "Concentration", + "type": "number" + } + }, + { + "description": "模拟持续时间(秒)", + "in": "query", + "name": "duration", + "required": true, + "schema": { + "description": "模拟持续时间(秒)", + "title": "Duration", + "type": "integer" + } + }, + { + "description": "模拟方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "模拟方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "污染源模式ID(可选)", + "in": "query", + "name": "pattern", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "污染源模式ID(可选)", + "title": "Pattern" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "污染物模拟", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/controls/properties": { + "get": { + "description": "获取指定网络中的控制属性信息", + "operationId": "get_controls_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Controls Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取控制属性", + "tags": [ + "Controls & Rules" + ] + }, + "patch": { + "description": "更新指定网络中的控制属性", + "operationId": "patch_controls_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置控制属性", + "tags": [ + "Controls & Rules" + ] + } + }, + "/api/v1/current-operation-ids": { + "get": { + "description": "获取网络当前的操作ID", + "operationId": "get_current_operation_ids", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Current Operation Ids", + "type": "integer" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取当前操作ID", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/curves": { + "delete": { + "description": "从网络中删除指定的曲线", + "operationId": "delete_curves", + "parameters": [ + { + "description": "曲线ID", + "in": "query", + "name": "curve", + "required": true, + "schema": { + "description": "曲线ID", + "title": "Curve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除曲线", + "tags": [ + "Curves" + ] + }, + "get": { + "description": "获取网络中的所有曲线列表", + "operationId": "get_curves", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有曲线", + "tags": [ + "Curves" + ] + }, + "post": { + "description": "在网络中添加一条新的曲线", + "operationId": "post_curves", + "parameters": [ + { + "description": "曲线ID", + "in": "query", + "name": "curve", + "required": true, + "schema": { + "description": "曲线ID", + "title": "Curve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加曲线", + "tags": [ + "Curves" + ] + } + }, + "/api/v1/curves/existence": { + "get": { + "description": "检查指定的曲线是否存在", + "operationId": "get_curves_existence", + "parameters": [ + { + "description": "曲线ID", + "in": "query", + "name": "curve", + "required": true, + "schema": { + "description": "曲线ID", + "title": "Curve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Curves Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查曲线存在性", + "tags": [ + "Curves" + ] + } + }, + "/api/v1/curves/properties": { + "get": { + "description": "获取指定曲线的属性信息", + "operationId": "get_curves_properties", + "parameters": [ + { + "description": "曲线ID", + "in": "query", + "name": "curve", + "required": true, + "schema": { + "description": "曲线ID", + "title": "Curve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Curves Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取曲线属性", + "tags": [ + "Curves" + ] + }, + "patch": { + "description": "更新指定曲线的属性", + "operationId": "patch_curves_properties", + "parameters": [ + { + "description": "曲线ID", + "in": "query", + "name": "curve", + "required": true, + "schema": { + "description": "曲线ID", + "title": "Curve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置曲线属性", + "tags": [ + "Curves" + ] + } + }, + "/api/v1/daily-scheduling-analyses": { + "post": { + "description": "对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。", + "operationId": "post_daily_scheduling_analyses", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailySchedulingAnalysisRest", + "description": "日排程分析参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Daily Scheduling Analyses", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "日排程分析", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/demands/properties": { + "get": { + "description": "获取指定水网中节点的需水量属性信息", + "operationId": "get_demands_properties", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Demands Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取需水量属性", + "tags": [ + "Demands" + ] + }, + "patch": { + "description": "设置指定水网中节点的需水量属性信息", + "operationId": "patch_demands_properties", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置需水量属性", + "tags": [ + "Demands" + ] + } + }, + "/api/v1/demands/to-network": { + "post": { + "description": "将需水量均匀分配到整个水网的所有需水节点", + "operationId": "post_demands_to_network", + "parameters": [ + { + "description": "总需水量(m³/h)", + "in": "query", + "name": "demand", + "required": true, + "schema": { + "description": "总需水量(m³/h)", + "exclusiveMinimum": 0.0, + "title": "Demand", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "number" + }, + "title": "Response Post Demands To Network", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算需水量到整网分配", + "tags": [ + "Demands" + ] + } + }, + "/api/v1/demands/to-nodes": { + "post": { + "description": "将总需水量按指定方式分配到多个节点", + "operationId": "post_demands_to_nodes", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "number" + }, + "title": "Response Post Demands To Nodes", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算需水量到节点分配", + "tags": [ + "Demands" + ] + } + }, + "/api/v1/demands/to-region": { + "post": { + "description": "将总需水量按区域特征分配到该区域内的节点", + "operationId": "post_demands_to_region", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "number" + }, + "title": "Response Post Demands To Region", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算需水量到区域分配", + "tags": [ + "Demands" + ] + } + }, + "/api/v1/district-metering-area-generation-runs": { + "post": { + "description": "根据参数自动生成水网的DMA分区方案", + "operationId": "post_district_metering_area_generation_runs", + "parameters": [ + { + "description": "分区数量", + "in": "query", + "name": "part_count", + "required": true, + "schema": { + "description": "分区数量", + "exclusiveMinimum": 0, + "title": "Part Count", + "type": "integer" + } + }, + { + "description": "分区类型", + "in": "query", + "name": "part_type", + "required": true, + "schema": { + "description": "分区类型", + "title": "Part Type", + "type": "integer" + } + }, + { + "description": "膨胀参数", + "in": "query", + "name": "inflate_delta", + "required": true, + "schema": { + "description": "膨胀参数", + "title": "Inflate Delta", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "生成DMA分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/district-metering-areas": { + "delete": { + "description": "删除指定的区域计量(DMA)", + "operationId": "delete_district_metering_areas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除DMA", + "tags": [ + "Regions & DMAs" + ] + }, + "get": { + "description": "获取指定水网中所有DMA的详细信息", + "operationId": "get_district_metering_areas", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有DMA", + "tags": [ + "Regions & DMAs" + ] + }, + "patch": { + "description": "修改指定DMA的属性信息", + "operationId": "patch_district_metering_areas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置DMA属性", + "tags": [ + "Regions & DMAs" + ] + }, + "post": { + "description": "向水网添加一个新的区域计量(DMA)", + "operationId": "post_district_metering_areas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加新DMA", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/district-metering-areas/detail": { + "get": { + "description": "获取指定ID的区域计量(DMA)详细信息", + "operationId": "get_district_metering_areas_detail", + "parameters": [ + { + "description": "DMA ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "DMA ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get District Metering Areas Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取DMA信息", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/district-metering-areas/for-network": { + "post": { + "description": "为整个水网计算区域计量(DMA)分区方案", + "operationId": "post_district_metering_areas_for_network", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_list_str__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算整网DMA分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/district-metering-areas/for-nodes": { + "post": { + "description": "为指定节点集计算区域计量(DMA)分区方案", + "operationId": "post_district_metering_areas_for_nodes", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_list_str__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算节点DMA分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/district-metering-areas/for-region": { + "post": { + "description": "为指定区域计算区域计量(DMA)分区方案", + "operationId": "post_district_metering_areas_for_region", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_list_str__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算区域内DMA分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/district-metering-areas/ids": { + "get": { + "description": "获取指定水网中所有DMA的ID列表", + "operationId": "get_district_metering_areas_ids", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有DMA ID", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/element-properties": { + "get": { + "description": "获取指定元素的属性信息", + "operationId": "get_element_properties", + "parameters": [ + { + "description": "元素ID", + "in": "query", + "name": "element", + "required": true, + "schema": { + "description": "元素ID", + "title": "Element", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Element Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取元素属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/element-properties-with-types": { + "get": { + "description": "获取指定类型的元素属性信息", + "operationId": "get_element_properties_with_types", + "parameters": [ + { + "description": "元素类型", + "in": "query", + "name": "elementtype", + "required": true, + "schema": { + "description": "元素类型", + "title": "Elementtype", + "type": "string" + } + }, + { + "description": "元素ID", + "in": "query", + "name": "element", + "required": true, + "schema": { + "description": "元素ID", + "title": "Element", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Element Properties With Types", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取指定类型元素属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/element-type-values": { + "get": { + "description": "获取指定元素的类型数值标识", + "operationId": "get_element_type_values", + "parameters": [ + { + "description": "元素ID", + "in": "query", + "name": "element", + "required": true, + "schema": { + "description": "元素ID", + "title": "Element", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Element Type Values", + "type": "integer" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取元素类型值", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/element-types": { + "get": { + "description": "获取指定元素的类型(节点或管线)", + "operationId": "get_element_types", + "parameters": [ + { + "description": "元素ID", + "in": "query", + "name": "element", + "required": true, + "schema": { + "description": "元素ID", + "title": "Element", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Element Types", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取元素类型", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/emitters/properties": { + "get": { + "description": "获取指定连接点的发射器属性信息", + "operationId": "get_emitters_properties", + "parameters": [ + { + "description": "连接点ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "连接点ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Emitters Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取发射器属性", + "tags": [ + "Quality" + ] + }, + "patch": { + "description": "更新指定连接点的发射器属性", + "operationId": "patch_emitters_properties", + "parameters": [ + { + "description": "连接点ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "连接点ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置发射器属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/energy-properties": { + "patch": { + "description": "更新指定网络中的能耗选项属性", + "operationId": "patch_energy_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置能耗选项属性", + "tags": [ + "Options" + ] + } + }, + "/api/v1/extension-datas": { + "get": { + "description": "获取指定网络中指定键的扩展数据值", + "operationId": "get_extension_datas", + "parameters": [ + { + "description": "扩展数据键", + "in": "query", + "name": "key", + "required": true, + "schema": { + "description": "扩展数据键", + "title": "Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Extension Datas" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取指定扩展数据", + "tags": [ + "Extension" + ] + }, + "patch": { + "description": "设置指定网络中的扩展数据", + "operationId": "patch_extension_datas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置扩展数据", + "tags": [ + "Extension" + ] + } + }, + "/api/v1/flushing-analyses": { + "post": { + "description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", + "operationId": "post_flushing_analyses", + "parameters": [ + { + "description": "冲洗开始时间(ISO 8601格式)", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "冲洗开始时间(ISO 8601格式)", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "要开启的阀门ID列表", + "in": "query", + "name": "valves", + "required": true, + "schema": { + "description": "要开启的阀门ID列表", + "items": { + "type": "string" + }, + "title": "Valves", + "type": "array" + } + }, + { + "description": "对应各阀门的开度列表(0-1)", + "in": "query", + "name": "valves_k", + "required": true, + "schema": { + "description": "对应各阀门的开度列表(0-1)", + "items": { + "type": "number" + }, + "title": "Valves K", + "type": "array" + } + }, + { + "description": "排污节点ID", + "in": "query", + "name": "drainage_node_id", + "required": true, + "schema": { + "description": "排污节点ID", + "title": "Drainage Node Id", + "type": "string" + } + }, + { + "description": "冲洗流量(L/s),0表示自动计算", + "in": "query", + "name": "flush_flow", + "required": false, + "schema": { + "default": 0, + "description": "冲洗流量(L/s),0表示自动计算", + "title": "Flush Flow", + "type": "number" + } + }, + { + "description": "模拟持续时间(秒),默认900秒", + "in": "query", + "name": "duration", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "模拟持续时间(秒),默认900秒", + "title": "Duration" + } + }, + { + "description": "冲洗方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "冲洗方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "冲洗分析(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/geocoding-requests": { + "post": { + "description": "调用天地图地理编码服务,将结构化地址转换为经纬度", + "operationId": "post_geocoding_requests", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TiandituGeocodeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Geocoding Requests", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Tianditu Geocoding", + "tags": [ + "Geocoding" + ] + } + }, + "/api/v1/inp-runs": { + "post": { + "description": "运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。", + "operationId": "post_inp_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Inp Runs", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "运行INP文件", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/junctions": { + "delete": { + "description": "从供水网络中删除指定的节点。", + "operationId": "delete_junctions", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除节点", + "tags": [ + "Junctions" + ] + }, + "get": { + "description": "获取指定项目中所有节点的属性信息。", + "operationId": "get_junctions", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有节点属性", + "tags": [ + "Junctions" + ] + }, + "post": { + "description": "在供水网络中添加新的节点,指定节点ID和空间坐标。", + "operationId": "post_junctions", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "X 坐标", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "X 坐标", + "title": "X", + "type": "number" + } + }, + { + "description": "Y 坐标", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "Y 坐标", + "title": "Y", + "type": "number" + } + }, + { + "description": "标高(海拔高度)", + "in": "query", + "name": "z", + "required": true, + "schema": { + "description": "标高(海拔高度)", + "title": "Z", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加节点", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/coord": { + "get": { + "description": "获取指定节点的 X 和 Y 坐标。", + "operationId": "get_junctions_coord", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "number" + }, + "title": "Response Get Junctions Coord", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点坐标", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "设置指定节点的 X 和 Y 坐标。", + "operationId": "patch_junctions_coord", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "X 坐标值", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "X 坐标值", + "title": "X", + "type": "number" + } + }, + { + "description": "Y 坐标值", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "Y 坐标值", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置节点坐标", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/demand": { + "get": { + "description": "获取指定节点的需水量。", + "operationId": "get_junctions_demand", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions Demand", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点需水量", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "设置指定节点的需水量。", + "operationId": "patch_junctions_demand", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "需水量值", + "in": "query", + "name": "demand", + "required": true, + "schema": { + "description": "需水量值", + "title": "Demand", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置节点需水量", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/elevation": { + "get": { + "description": "获取指定节点的标高(海拔高度)。", + "operationId": "get_junctions_elevation", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions Elevation", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点标高", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "设置指定节点的标高值。", + "operationId": "patch_junctions_elevation", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "标高(海拔高度)", + "in": "query", + "name": "elevation", + "required": true, + "schema": { + "description": "标高(海拔高度)", + "title": "Elevation", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置节点标高", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/existence": { + "get": { + "description": "检查指定ID是否为水网中的接点(需求点)", + "operationId": "get_junctions_existence", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查是否为接点", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/junctions/pattern": { + "get": { + "description": "获取指定节点的需水模式标识。", + "operationId": "get_junctions_pattern", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions Pattern", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点需水模式", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "设置指定节点的需水模式标识。", + "operationId": "patch_junctions_pattern", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "需水模式标识", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "需水模式标识", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置节点需水模式", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/properties": { + "get": { + "description": "获取指定节点的所有属性信息。", + "operationId": "get_junctions_properties", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点属性", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "批量设置指定节点的多个属性。", + "operationId": "patch_junctions_properties", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量设置节点属性", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/x": { + "get": { + "description": "获取指定节点的 X 坐标值。", + "operationId": "get_junctions_x", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions X", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点 X 坐标", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "设置指定节点的 X 坐标值。", + "operationId": "patch_junctions_x", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "X 坐标值", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "X 坐标值", + "title": "X", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置节点 X 坐标", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/junctions/y": { + "get": { + "description": "获取指定节点的 Y 坐标值。", + "operationId": "get_junctions_y", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Junctions Y", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点 Y 坐标", + "tags": [ + "Junctions" + ] + }, + "patch": { + "description": "设置指定节点的 Y 坐标值。", + "operationId": "patch_junctions_y", + "parameters": [ + { + "description": "节点 ID", + "in": "query", + "name": "junction", + "required": true, + "schema": { + "description": "节点 ID", + "title": "Junction", + "type": "string" + } + }, + { + "description": "Y 坐标值", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "Y 坐标值", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置节点 Y 坐标", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/labels": { + "delete": { + "description": "从网络中删除指定的标签", + "operationId": "delete_labels", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除标签", + "tags": [ + "Visuals" + ] + }, + "post": { + "description": "在网络中添加一个新的标签", + "operationId": "post_labels", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加标签", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/labels/properties": { + "get": { + "description": "获取指定坐标处的标签属性信息", + "operationId": "get_labels_properties", + "parameters": [ + { + "description": "X坐标", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "X坐标", + "title": "X", + "type": "number" + } + }, + { + "description": "Y坐标", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "Y坐标", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Labels Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取标签属性", + "tags": [ + "Visuals" + ] + }, + "patch": { + "description": "更新指定标签的属性", + "operationId": "patch_labels_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置标签属性", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/leakage-identifications": { + "post": { + "description": "基于压力观测数据和遗传算法识别管网中的漏损位置和大小", + "operationId": "post_leakage_identifications", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeakageIdentifyRequestRest", + "description": "漏损识别请求数据" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Leakage Identifications", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "执行漏损识别", + "tags": [ + "Leakage" + ] + } + }, + "/api/v1/link-properties": { + "get": { + "description": "获取指定管线的所有属性信息", + "operationId": "get_link_properties", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Link Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管线属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/link-types": { + "get": { + "description": "获取指定管线的类型(管道/泵/阀门)", + "operationId": "get_link_types", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Link Types", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管线类型", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/links": { + "delete": { + "description": "删除指定的管线(管道/泵/阀门)", + "operationId": "delete_links", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除管线", + "tags": [ + "Network General" + ] + }, + "get": { + "description": "获取指定水网中的所有管线ID列表", + "operationId": "get_links", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有管线", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/links/existence": { + "get": { + "description": "检查指定ID是否为水网中的有效管线", + "operationId": "get_links_existence", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Links Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查管线有效性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/major-pipe-nodes": { + "get": { + "description": "获取直径大于等于指定值的管道的节点ID", + "operationId": "get_major_pipe_nodes", + "parameters": [ + { + "description": "最小直径(mm)", + "in": "query", + "name": "diameter", + "required": true, + "schema": { + "description": "最小直径(mm)", + "exclusiveMinimum": 0, + "title": "Diameter", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Response Get Major Pipe Nodes" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取主要管道节点", + "tags": [ + "Geometry & Coordinates" + ] + } + }, + "/api/v1/majornode-coords": { + "get": { + "description": "获取直径大于等于指定值的节点坐标", + "operationId": "get_majornode_coords", + "parameters": [ + { + "description": "最小直径(mm)", + "in": "query", + "name": "diameter", + "required": true, + "schema": { + "description": "最小直径(mm)", + "exclusiveMinimum": 0, + "title": "Diameter", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + "title": "Response Get Majornode Coords", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取主要节点坐标", + "tags": [ + "Geometry & Coordinates" + ] + } + }, + "/api/v1/mixing-configurations": { + "delete": { + "description": "从网络中删除指定的混合", + "operationId": "delete_mixing_configurations", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除混合", + "tags": [ + "Quality" + ] + }, + "patch": { + "description": "更新指定水池的混合属性", + "operationId": "patch_mixing_configurations", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置混合属性", + "tags": [ + "Quality" + ] + }, + "post": { + "description": "在网络中添加一个新的混合", + "operationId": "post_mixing_configurations", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加混合", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/mixing-configurations/detail": { + "get": { + "description": "获取指定水池的混合属性信息", + "operationId": "get_mixing_configurations_detail", + "parameters": [ + { + "description": "水池ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水池ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Mixing Configurations Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取混合属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-command-batches": { + "post": { + "description": "执行多个网络操作命令", + "operationId": "post_network_command_batches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "执行批量命令", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/network-command-batches/compressed": { + "post": { + "description": "执行压缩的批量命令", + "operationId": "post_network_command_batches_compressed", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "执行压缩批量命令", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/network-in-extents": { + "get": { + "description": "获取指定地理范围内的网络节点和管线", + "operationId": "get_network_in_extents", + "parameters": [ + { + "description": "范围左下角X坐标", + "in": "query", + "name": "x1", + "required": true, + "schema": { + "description": "范围左下角X坐标", + "title": "X1", + "type": "number" + } + }, + { + "description": "范围左下角Y坐标", + "in": "query", + "name": "y1", + "required": true, + "schema": { + "description": "范围左下角Y坐标", + "title": "Y1", + "type": "number" + } + }, + { + "description": "范围右上角X坐标", + "in": "query", + "name": "x2", + "required": true, + "schema": { + "description": "范围右上角X坐标", + "title": "X2", + "type": "number" + } + }, + { + "description": "范围右上角Y坐标", + "in": "query", + "name": "y2", + "required": true, + "schema": { + "description": "范围右上角Y坐标", + "title": "Y2", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Network In Extents", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取范围内的网络元素", + "tags": [ + "Geometry & Coordinates" + ] + } + }, + "/api/v1/network-link-nodes": { + "get": { + "description": "获取指定水网所有管线的起点和终点节点", + "operationId": "get_network_link_nodes", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Response Get Network Link Nodes" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取网络管线节点", + "tags": [ + "Geometry & Coordinates" + ] + } + }, + "/api/v1/network-options": { + "get": { + "description": "获取指定网络中的选项属性信息", + "operationId": "get_network_options", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Network Options", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取选项属性", + "tags": [ + "Options" + ] + }, + "patch": { + "description": "更新指定网络中的选项属性", + "operationId": "patch_network_options", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置选项属性", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-options/energy": { + "get": { + "description": "获取指定网络中的能耗选项属性信息", + "operationId": "get_network_options_energy", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Network Options Energy", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取能耗选项属性", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-options/pump-energy": { + "get": { + "description": "获取指定泵的能耗属性信息", + "operationId": "get_network_options_pump_energy", + "parameters": [ + { + "description": "泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Network Options Pump Energy", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取泵能耗属性", + "tags": [ + "Options" + ] + }, + "patch": { + "description": "更新指定泵的能耗属性", + "operationId": "patch_network_options_pump_energy", + "parameters": [ + { + "description": "泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置泵能耗属性", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-options/time": { + "get": { + "description": "获取指定网络中的时间选项属性信息", + "operationId": "get_network_options_time", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Network Options Time", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取时间选项属性", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-pipe-risk-probability-nows": { + "get": { + "description": "获取指定网络中所有管道的当前风险概率值", + "operationId": "get_network_pipe_risk_probability_nows", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取整个网络的管道风险概率", + "tags": [ + "Risk" + ] + } + }, + "/api/v1/network-schemas/backdrop": { + "get": { + "description": "获取网络中背景对象的架构定义", + "operationId": "get_network_schemas_backdrop", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Backdrop", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取背景架构", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/network-schemas/control": { + "get": { + "description": "获取网络中控制对象的架构定义", + "operationId": "get_network_schemas_control", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Control", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取控制架构", + "tags": [ + "Controls & Rules" + ] + } + }, + "/api/v1/network-schemas/curve": { + "get": { + "description": "获取网络中曲线对象的架构定义", + "operationId": "get_network_schemas_curve", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Curve", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取曲线架构", + "tags": [ + "Curves" + ] + } + }, + "/api/v1/network-schemas/demand": { + "get": { + "description": "获取指定水网中需水量(Demand)的属性架构定义", + "operationId": "get_network_schemas_demand", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Demand", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取需水量属性架构", + "tags": [ + "Demands" + ] + } + }, + "/api/v1/network-schemas/district-metering-area": { + "get": { + "description": "获取指定水网的区域计量(DMA)属性架构定义", + "operationId": "get_network_schemas_district_metering_area", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas District Metering Area", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取DMA属性架构", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/network-schemas/emitter": { + "get": { + "description": "获取网络中发射器对象的架构定义", + "operationId": "get_network_schemas_emitter", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Emitter", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取发射器架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/energy": { + "get": { + "description": "获取网络中能耗选项的架构定义", + "operationId": "get_network_schemas_energy", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Energy", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取能耗选项架构", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-schemas/junction": { + "get": { + "description": "获取指定项目的节点属性架构和数据类型定义。", + "operationId": "get_network_schemas_junction", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Junction", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点架构", + "tags": [ + "Junctions" + ] + } + }, + "/api/v1/network-schemas/label": { + "get": { + "description": "获取网络中标签对象的架构定义", + "operationId": "get_network_schemas_label", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Label", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取标签架构", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/network-schemas/mixing": { + "get": { + "description": "获取网络中混合对象的架构定义", + "operationId": "get_network_schemas_mixing", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Mixing", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取混合架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/option": { + "get": { + "description": "获取网络中选项对象的架构定义", + "operationId": "get_network_schemas_option", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Option", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取选项架构", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-schemas/pattern": { + "get": { + "description": "获取网络中模式对象的架构定义", + "operationId": "get_network_schemas_pattern", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Pattern", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取模式架构", + "tags": [ + "Patterns" + ] + } + }, + "/api/v1/network-schemas/pipe": { + "get": { + "description": "获取管道对象的模式定义,包含所有可用字段及其类型", + "operationId": "get_network_schemas_pipe", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Pipe", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道模式", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/network-schemas/pipe-reaction": { + "get": { + "description": "获取网络中管道反应对象的架构定义", + "operationId": "get_network_schemas_pipe_reaction", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Pipe Reaction", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道反应架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/pump": { + "get": { + "description": "获取水泵对象的模式定义,包含所有可用字段及其类型", + "operationId": "get_network_schemas_pump", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Pump", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水泵模式", + "tags": [ + "Pumps" + ] + } + }, + "/api/v1/network-schemas/pump-energy": { + "get": { + "description": "获取网络中泵能耗选项的架构定义", + "operationId": "get_network_schemas_pump_energy", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Pump Energy", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取泵能耗选项架构", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-schemas/quality": { + "get": { + "description": "获取网络中水质对象的架构定义", + "operationId": "get_network_schemas_quality", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Quality", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水质架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/reaction": { + "get": { + "description": "获取网络中反应对象的架构定义", + "operationId": "get_network_schemas_reaction", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Reaction", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取反应架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/region": { + "get": { + "description": "获取指定水网的区域属性架构定义", + "operationId": "get_network_schemas_region", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Region", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取区域属性架构", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/network-schemas/reservoir": { + "get": { + "description": "获取指定供水网络中所有水库的模式/属性字段定义", + "operationId": "get_network_schemas_reservoir", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Reservoir", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库模式", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/network-schemas/scada-device": { + "get": { + "description": "获取SCADA设备的数据架构\n\n返回SCADA设备表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备的字段架构信息", + "operationId": "get_network_schemas_scada_device", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Scada Device", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA设备架构", + "tags": [ + "SCADA设备" + ] + } + }, + "/api/v1/network-schemas/scada-device-data": { + "get": { + "description": "获取SCADA设备数据的表结构\n\n返回SCADA设备数据表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备数据的字段架构信息", + "operationId": "get_network_schemas_scada_device_data", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Scada Device Data", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA设备数据架构", + "tags": [ + "SCADA设备数据" + ] + } + }, + "/api/v1/network-schemas/scada-element": { + "get": { + "description": "获取SCADA元素映射的表结构\n\n返回SCADA元素映射表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA元素映射的字段架构信息", + "operationId": "get_network_schemas_scada_element", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Scada Element", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA元素架构", + "tags": [ + "SCADA元素映射" + ] + } + }, + "/api/v1/network-schemas/scheme": { + "get": { + "description": "获取指定网络的方案模式定义", + "operationId": "get_network_schemas_scheme", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Scheme", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取方案模式", + "tags": [ + "Schemes" + ] + } + }, + "/api/v1/network-schemas/service-area": { + "get": { + "description": "获取指定水网的服务区属性架构定义", + "operationId": "get_network_schemas_service_area", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Service Area", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取服务区属性架构", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/network-schemas/source": { + "get": { + "description": "获取网络中水源对象的架构定义", + "operationId": "get_network_schemas_source", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Source", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水源架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/tag": { + "get": { + "description": "获取指定水网的标签(Tag)属性架构定义", + "operationId": "get_network_schemas_tag", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Tag", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取标签属性架构", + "tags": [ + "Tags" + ] + } + }, + "/api/v1/network-schemas/tank": { + "get": { + "description": "获取指定网络的水箱数据结构模式定义", + "operationId": "get_network_schemas_tank", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Tank", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱模式", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/network-schemas/tank-reaction": { + "get": { + "description": "获取网络中水池反应对象的架构定义", + "operationId": "get_network_schemas_tank_reaction", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Tank Reaction", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水池反应架构", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/network-schemas/time": { + "get": { + "description": "获取网络中时间选项的架构定义", + "operationId": "get_network_schemas_time", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Time", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取时间选项架构", + "tags": [ + "Options" + ] + } + }, + "/api/v1/network-schemas/user": { + "get": { + "description": "获取指定网络的用户模式定义", + "operationId": "get_network_schemas_user", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas User", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取用户模式", + "tags": [ + "Users" + ] + } + }, + "/api/v1/network-schemas/valve": { + "get": { + "description": "获取指定水网中所有阀门的架构和字段定义", + "operationId": "get_network_schemas_valve", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Valve", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门架构", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/network-schemas/vertex": { + "get": { + "description": "获取网络中图形元素对象的架构定义", + "operationId": "get_network_schemas_vertex", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Vertex", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取图形元素架构", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/network-schemas/virtual-district": { + "get": { + "description": "获取指定水网的虚拟分区属性架构定义", + "operationId": "get_network_schemas_virtual_district", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Network Schemas Virtual District", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取虚拟分区属性架构", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/node-coords": { + "get": { + "description": "获取指定节点的地理坐标(X, Y)", + "operationId": "get_node_coords", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Get Node Coords" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点坐标", + "tags": [ + "Geometry & Coordinates" + ] + } + }, + "/api/v1/node-links": { + "get": { + "description": "获取指定节点连接的所有管线ID列表", + "operationId": "get_node_links", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点的关联管线", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/node-properties": { + "get": { + "description": "获取指定节点的所有属性信息", + "operationId": "get_node_properties", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Node Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/node-types": { + "get": { + "description": "获取指定节点的类型(接点/水源/蓄水池)", + "operationId": "get_node_types", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Node Types", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取节点类型", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/nodes": { + "delete": { + "description": "删除指定的节点(接点/水源/蓄水池)", + "operationId": "delete_nodes", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除节点", + "tags": [ + "Network General" + ] + }, + "get": { + "description": "获取指定水网中的所有节点ID列表", + "operationId": "get_nodes", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有节点", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/nodes/existence": { + "get": { + "description": "检查指定ID是否为水网中的有效节点", + "operationId": "get_nodes_existence", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Nodes Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查节点有效性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/operations": { + "patch": { + "description": "选择并恢复到指定的操作", + "operationId": "patch_operations", + "parameters": [ + { + "description": "操作ID", + "in": "query", + "name": "operation", + "required": true, + "schema": { + "description": "操作ID", + "title": "Operation", + "type": "integer" + } + }, + { + "description": "是否丢弃当前更改", + "in": "query", + "name": "discard", + "required": false, + "schema": { + "default": false, + "description": "是否丢弃当前更改", + "title": "Discard", + "type": "boolean" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "选择操作", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/outputs": { + "get": { + "description": "导出指定路径的模拟输出文件内容。参数应为绝对路径。", + "operationId": "get_outputs", + "parameters": [ + { + "description": "模拟输出文件的绝对路径", + "in": "query", + "name": "output", + "required": true, + "schema": { + "description": "模拟输出文件的绝对路径", + "title": "Output", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Outputs", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "导出模拟输出", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/patterns": { + "delete": { + "description": "从网络中删除指定的模式", + "operationId": "delete_patterns", + "parameters": [ + { + "description": "模式ID", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "模式ID", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除模式", + "tags": [ + "Patterns" + ] + }, + "get": { + "description": "获取网络中的所有模式列表", + "operationId": "get_patterns", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有模式", + "tags": [ + "Patterns" + ] + }, + "post": { + "description": "在网络中添加一个新的模式", + "operationId": "post_patterns", + "parameters": [ + { + "description": "模式ID", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "模式ID", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加模式", + "tags": [ + "Patterns" + ] + } + }, + "/api/v1/patterns/existence": { + "get": { + "description": "检查指定的模式是否存在", + "operationId": "get_patterns_existence", + "parameters": [ + { + "description": "模式ID", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "模式ID", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Patterns Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查模式存在性", + "tags": [ + "Patterns" + ] + } + }, + "/api/v1/patterns/properties": { + "get": { + "description": "获取指定模式的属性信息", + "operationId": "get_patterns_properties", + "parameters": [ + { + "description": "模式ID", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "模式ID", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Patterns Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取模式属性", + "tags": [ + "Patterns" + ] + }, + "patch": { + "description": "更新指定模式的属性", + "operationId": "patch_patterns_properties", + "parameters": [ + { + "description": "模式ID", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "模式ID", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置模式属性", + "tags": [ + "Patterns" + ] + } + }, + "/api/v1/pipe-reactions": { + "patch": { + "description": "更新指定管道的反应属性", + "operationId": "patch_pipe_reactions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道反应属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/pipe-reactions/detail": { + "get": { + "description": "获取指定管道的反应属性信息", + "operationId": "get_pipe_reactions_detail", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pipe Reactions Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道反应属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/pipeline-health-predictions": { + "get": { + "description": "预测管道健康状况\n\n根据管网名称和当前时间,查询管道信息和实时数据,\n使用随机生存森林模型预测管道的生存概率。\n\nArgs:\n query_time: 查询时间\n network_name: 管网名称(或数据库名称)\n timescale_conn: TimescaleDB连接\n\nReturns:\n 预测结果列表,每个元素包含 link_id 和对应的生存函数\n\nRaises:\n HTTPException: 当模型文件不存在返回404错误,其他错误返回400或500错误", + "operationId": "get_pipeline_health_predictions", + "parameters": [ + { + "description": "查询时间", + "in": "query", + "name": "query_time", + "required": true, + "schema": { + "description": "查询时间", + "format": "date-time", + "title": "Query Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "预测管道健康状况", + "tags": [ + "TimescaleDB - Composite" + ] + } + }, + "/api/v1/pipes": { + "delete": { + "description": "从网络中删除指定的管道", + "operationId": "delete_pipes", + "parameters": [ + { + "description": "要删除的管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "要删除的管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除管道", + "tags": [ + "Pipes" + ] + }, + "get": { + "description": "获取网络中所有管道的属性信息列表", + "operationId": "get_pipes", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有管道属性", + "tags": [ + "Pipes" + ] + }, + "post": { + "description": "向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等", + "operationId": "post_pipes", + "parameters": [ + { + "description": "管道标识符", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道标识符", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "管道起始节点ID", + "in": "query", + "name": "node1", + "required": true, + "schema": { + "description": "管道起始节点ID", + "title": "Node1", + "type": "string" + } + }, + { + "description": "管道终止节点ID", + "in": "query", + "name": "node2", + "required": true, + "schema": { + "description": "管道终止节点ID", + "title": "Node2", + "type": "string" + } + }, + { + "description": "管道长度(单位:米)", + "in": "query", + "name": "length", + "required": false, + "schema": { + "default": 0, + "description": "管道长度(单位:米)", + "title": "Length", + "type": "number" + } + }, + { + "description": "管道管径(单位:毫米)", + "in": "query", + "name": "diameter", + "required": false, + "schema": { + "default": 0, + "description": "管道管径(单位:毫米)", + "title": "Diameter", + "type": "number" + } + }, + { + "description": "管道粗糙度", + "in": "query", + "name": "roughness", + "required": false, + "schema": { + "default": 0, + "description": "管道粗糙度", + "title": "Roughness", + "type": "number" + } + }, + { + "description": "管道局部阻力系数", + "in": "query", + "name": "minor_loss", + "required": false, + "schema": { + "default": 0, + "description": "管道局部阻力系数", + "title": "Minor Loss", + "type": "number" + } + }, + { + "description": "管道状态(开启/关闭)", + "in": "query", + "name": "status", + "required": false, + "schema": { + "default": "OPEN", + "description": "管道状态(开启/关闭)", + "title": "Status", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加管道", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes-risk-probabilities": { + "get": { + "description": "批量获取多条管道的风险概率值", + "operationId": "get_pipes_risk_probabilities", + "parameters": [ + { + "description": "逗号分隔的管道ID列表", + "in": "query", + "name": "pipe_ids", + "required": true, + "schema": { + "description": "逗号分隔的管道ID列表", + "title": "Pipe Ids", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量获取多条管道风险概率", + "tags": [ + "Risk" + ] + } + }, + "/api/v1/pipes/diameter": { + "get": { + "description": "获取指定管道的管径", + "operationId": "get_pipes_diameter", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Diameter" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道管径", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的管径", + "operationId": "patch_pipes_diameter", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的管道管径(单位:毫米)", + "in": "query", + "name": "diameter", + "required": true, + "schema": { + "description": "新的管道管径(单位:毫米)", + "title": "Diameter", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道管径", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/existence": { + "get": { + "description": "检查指定ID是否为水网中的管道", + "operationId": "get_pipes_existence", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pipes Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查是否为管道", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/pipes/length": { + "get": { + "description": "获取指定管道的长度", + "operationId": "get_pipes_length", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Length" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道长度", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的长度", + "operationId": "patch_pipes_length", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的管道长度(单位:米)", + "in": "query", + "name": "length", + "required": true, + "schema": { + "description": "新的管道长度(单位:米)", + "title": "Length", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道长度", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/minor-loss": { + "get": { + "description": "获取指定管道的局部阻力系数", + "operationId": "get_pipes_minor_loss", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Minor Loss" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道局部阻力系数", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的局部阻力系数", + "operationId": "patch_pipes_minor_loss", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的局部阻力系数值", + "in": "query", + "name": "minor_loss", + "required": true, + "schema": { + "description": "新的局部阻力系数值", + "title": "Minor Loss", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道局部阻力系数", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/node1": { + "get": { + "description": "获取指定管道的起始节点ID", + "operationId": "get_pipes_node1", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Node1" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道起始节点", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的起始节点", + "operationId": "patch_pipes_node1", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的起始节点ID", + "in": "query", + "name": "node1", + "required": true, + "schema": { + "description": "新的起始节点ID", + "title": "Node1", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道起始节点", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/node2": { + "get": { + "description": "获取指定管道的终止节点ID", + "operationId": "get_pipes_node2", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Node2" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道终止节点", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的终止节点", + "operationId": "patch_pipes_node2", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的终止节点ID", + "in": "query", + "name": "node2", + "required": true, + "schema": { + "description": "新的终止节点ID", + "title": "Node2", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道终止节点", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/properties": { + "get": { + "description": "获取指定管道的所有属性信息", + "operationId": "get_pipes_properties", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pipes Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道属性", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "批量设置指定管道的多个属性", + "operationId": "patch_pipes_properties", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道属性", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/risk-probability": { + "get": { + "description": "获取指定管道的风险概率历史数据", + "operationId": "get_pipes_risk_probability", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe_id", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pipes Risk Probability", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道风险概率历史", + "tags": [ + "Risk" + ] + } + }, + "/api/v1/pipes/risk-probability-geometries": { + "get": { + "description": "获取指定网络中管道的风险相关几何数据", + "operationId": "get_pipes_risk_probability_geometries", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pipes Risk Probability Geometries", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道风险几何信息", + "tags": [ + "Risk" + ] + } + }, + "/api/v1/pipes/risk-probability-now": { + "get": { + "description": "获取指定管道当前时刻的风险概率值", + "operationId": "get_pipes_risk_probability_now", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe_id", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pipes Risk Probability Now", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道当前风险概率", + "tags": [ + "Risk" + ] + } + }, + "/api/v1/pipes/roughness": { + "get": { + "description": "获取指定管道的粗糙度", + "operationId": "get_pipes_roughness", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Roughness" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道粗糙度", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的粗糙度", + "operationId": "patch_pipes_roughness", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的管道粗糙度值", + "in": "query", + "name": "roughness", + "required": true, + "schema": { + "description": "新的管道粗糙度值", + "title": "Roughness", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道粗糙度", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pipes/status": { + "get": { + "description": "获取指定管道的状态(开启或关闭)", + "operationId": "get_pipes_status", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Pipes Status" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管道状态", + "tags": [ + "Pipes" + ] + }, + "patch": { + "description": "设置指定管道的状态(开启或关闭)", + "operationId": "patch_pipes_status", + "parameters": [ + { + "description": "管道ID", + "in": "query", + "name": "pipe", + "required": true, + "schema": { + "description": "管道ID", + "title": "Pipe", + "type": "string" + } + }, + { + "description": "新的管道状态(开启/关闭)", + "in": "query", + "name": "status", + "required": true, + "schema": { + "description": "新的管道状态(开启/关闭)", + "title": "Status", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管道状态", + "tags": [ + "Pipes" + ] + } + }, + "/api/v1/pressure-regulation-analyses": { + "post": { + "description": "高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。", + "operationId": "post_pressure_regulation_analyses", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PressureRegulationRest", + "description": "压力调节控制参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Pressure Regulation Analyses", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "压力调节(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/pressure-regulation-calculations": { + "post": { + "description": "对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。", + "operationId": "post_pressure_regulation_calculations", + "parameters": [ + { + "description": "目标节点ID", + "in": "query", + "name": "target_node", + "required": true, + "schema": { + "description": "目标节点ID", + "title": "Target Node", + "type": "string" + } + }, + { + "description": "目标压力值(kPa)", + "in": "query", + "name": "target_pressure", + "required": true, + "schema": { + "description": "目标压力值(kPa)", + "title": "Target Pressure", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "压力调节(基础)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/pressure-sensor-placement-kmeans": { + "post": { + "description": "高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。", + "operationId": "post_pressure_sensor_placement_kmeans", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PressureSensorPlacement", + "description": "传感器放置分析参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "压力传感器放置-KMeans聚类分析(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/pressure-sensor-placement-kmeans-calculations": { + "post": { + "description": "基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。", + "operationId": "post_pressure_sensor_placement_kmeans_calculations", + "parameters": [ + { + "description": "放置方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "放置方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "传感器数量", + "in": "query", + "name": "sensor_number", + "required": true, + "schema": { + "description": "传感器数量", + "title": "Sensor Number", + "type": "integer" + } + }, + { + "description": "最小管径限制(毫米)", + "in": "query", + "name": "min_diameter", + "required": true, + "schema": { + "description": "最小管径限制(毫米)", + "title": "Min Diameter", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "压力传感器放置-KMeans聚类分析(基础)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/pressure-sensor-placement-sensitivities": { + "post": { + "description": "高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。", + "operationId": "post_pressure_sensor_placement_sensitivities", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PressureSensorPlacement", + "description": "传感器放置分析参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "压力传感器放置-灵敏度分析(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/pressure-sensor-placement-sensitivity-calculations": { + "post": { + "description": "基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。", + "operationId": "post_pressure_sensor_placement_sensitivity_calculations", + "parameters": [ + { + "description": "放置方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "放置方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "传感器数量", + "in": "query", + "name": "sensor_number", + "required": true, + "schema": { + "description": "传感器数量", + "title": "Sensor Number", + "type": "integer" + } + }, + { + "description": "最小管径限制(毫米)", + "in": "query", + "name": "min_diameter", + "required": true, + "schema": { + "description": "最小管径限制(毫米)", + "title": "Min Diameter", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "压力传感器放置-灵敏度分析(基础)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/project-codes": { + "get": { + "description": "获取服务器上所有可用的供水管网项目名称列表。", + "operationId": "get_project_codes", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取项目列表", + "tags": [ + "Project" + ] + } + }, + "/api/v1/project-conversions": { + "post": { + "description": "将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。", + "operationId": "post_project_conversions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "转换 INP V3 为 V2", + "tags": [ + "Project" + ] + } + }, + "/api/v1/project-copies": { + "post": { + "description": "将现有项目复制为新项目。", + "operationId": "post_project_copies", + "parameters": [ + { + "description": "管网名称(或数据库名称)", + "in": "query", + "name": "source", + "required": true, + "schema": { + "description": "管网名称(或数据库名称)", + "title": "Source", + "type": "string" + } + }, + { + "description": "管网名称(或数据库名称)", + "in": "query", + "name": "target", + "required": true, + "schema": { + "description": "管网名称(或数据库名称)", + "title": "Target", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "复制项目", + "tags": [ + "Project" + ] + } + }, + "/api/v1/project-managements": { + "post": { + "description": "高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。", + "operationId": "post_project_managements", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectManagementRest", + "description": "项目管理控制参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Project Managements", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "项目管理(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/project-return-dict-runs": { + "post": { + "description": "基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。", + "operationId": "post_project_return_dict_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Project Return Dict Runs", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "运行项目模拟(返回字典)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/project-runs": { + "post": { + "description": "基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。", + "operationId": "post_project_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "运行项目模拟", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/projects": { + "delete": { + "description": "永久删除指定的供水管网项目。此操作不可恢复。", + "operationId": "delete_projects", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除项目", + "tags": [ + "Project" + ] + }, + "get": { + "description": "获取当前用户有权限的所有项目列表", + "operationId": "get_projects", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ProjectSummaryResponse_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "列出用户项目", + "tags": [ + "Metadata" + ] + }, + "post": { + "description": "创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。", + "operationId": "post_projects", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "创建新项目", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current": { + "delete": { + "description": "将指定项目从内存中卸载,释放资源。", + "operationId": "delete_projects_current", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "关闭项目", + "tags": [ + "Project" + ] + }, + "get": { + "description": "从数据库获取项目的详细信息,包括地图范围等。", + "operationId": "get_projects_current", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMetaResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取项目信息", + "tags": [ + "Project" + ] + }, + "post": { + "description": "将指定项目加载到内存中,并初始化数据库连接池。", + "operationId": "post_projects_current", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "打开项目", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/database-health": { + "get": { + "description": "检查项目数据库连接的健康状况", + "operationId": "get_projects_current_database_health", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查数据库健康状态", + "tags": [ + "Metadata" + ] + } + }, + "/api/v1/projects/current/exports/change-set": { + "get": { + "description": "导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。", + "operationId": "get_projects_current_exports_change_set", + "parameters": [ + { + "description": "版本号 (通常用于增量更新)", + "in": "query", + "name": "version", + "required": true, + "schema": { + "description": "版本号 (通常用于增量更新)", + "title": "Version", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "导出项目为 ChangeSet", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/exports/inp": { + "post": { + "description": "将项目当前状态保存为 INP 文件到服务器文件系统。", + "operationId": "post_projects_current_exports_inp", + "parameters": [ + { + "description": "目标文件名", + "in": "query", + "name": "inp", + "required": true, + "schema": { + "description": "目标文件名", + "title": "Inp", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Projects Current Exports Inp", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "导出项目到 INP 文件", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/files/inp": { + "get": { + "description": "从服务器数据目录下载指定的 INP 文件。", + "operationId": "get_projects_current_files_inp", + "parameters": [ + { + "description": "文件名", + "in": "query", + "name": "name", + "required": true, + "schema": { + "description": "文件名", + "title": "Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "下载 INP 文件", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/imports": { + "post": { + "description": "从服务器文件系统中读取指定的 INP 文件并加载到项目中。", + "operationId": "post_projects_current_imports", + "parameters": [ + { + "description": "INP 文件名 (不包含路径)", + "in": "query", + "name": "inp", + "required": true, + "schema": { + "description": "INP 文件名 (不包含路径)", + "title": "Inp", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Projects Current Imports", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "读取 INP 文件到项目", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/lock": { + "delete": { + "description": "释放对项目的锁定。", + "operationId": "delete_projects_current_lock", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "解锁项目", + "tags": [ + "Project" + ] + }, + "get": { + "description": "检查指定项目是否处于锁定状态。", + "operationId": "get_projects_current_lock", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查项目是否被锁定", + "tags": [ + "Project" + ] + }, + "post": { + "description": "锁定指定项目以防止并发修改。", + "operationId": "post_projects_current_lock", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "锁定项目", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/lock/ownership": { + "get": { + "description": "检查指定项目是否被当前客户端 (IP) 锁定。", + "operationId": "get_projects_current_lock_ownership", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查项目是否被当前用户锁定", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/current/metadata": { + "get": { + "description": "获取当前项目的元数据和配置信息", + "operationId": "get_projects_current_metadata", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMetaResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取项目元数据", + "tags": [ + "Metadata" + ] + } + }, + "/api/v1/projects/current/status": { + "get": { + "description": "检查指定项目是否已被加载到内存中。", + "operationId": "get_projects_current_status", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查项目是否已打开", + "tags": [ + "Project" + ] + } + }, + "/api/v1/projects/existence": { + "get": { + "description": "检查指定名称的项目是否存在。", + "operationId": "get_projects_existence", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查项目是否存在", + "tags": [ + "Project" + ] + } + }, + "/api/v1/pump-failure-events": { + "post": { + "description": "记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。", + "operationId": "post_pump_failure_events", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PumpFailureState", + "description": "泵故障状态信息" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Pump Failure Events", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "泵故障管理", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/pumps": { + "delete": { + "description": "从网络中删除指定的水泵", + "operationId": "delete_pumps", + "parameters": [ + { + "description": "要删除的水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "要删除的水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除水泵", + "tags": [ + "Pumps" + ] + }, + "get": { + "description": "获取网络中所有水泵的属性信息列表", + "operationId": "get_pumps", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有水泵属性", + "tags": [ + "Pumps" + ] + }, + "post": { + "description": "向网络中添加新的水泵,需要提供水泵的基本参数如功率等", + "operationId": "post_pumps", + "parameters": [ + { + "description": "水泵标识符", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵标识符", + "title": "Pump", + "type": "string" + } + }, + { + "description": "水泵起始节点ID", + "in": "query", + "name": "node1", + "required": true, + "schema": { + "description": "水泵起始节点ID", + "title": "Node1", + "type": "string" + } + }, + { + "description": "水泵终止节点ID", + "in": "query", + "name": "node2", + "required": true, + "schema": { + "description": "水泵终止节点ID", + "title": "Node2", + "type": "string" + } + }, + { + "description": "水泵功率(单位:千瓦)", + "in": "query", + "name": "power", + "required": false, + "schema": { + "default": 0.0, + "description": "水泵功率(单位:千瓦)", + "title": "Power", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加水泵", + "tags": [ + "Pumps" + ] + } + }, + "/api/v1/pumps/existence": { + "get": { + "description": "检查指定ID是否为水网中的泵", + "operationId": "get_pumps_existence", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pumps Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查是否为泵", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/pumps/node1": { + "get": { + "description": "获取指定水泵的起始节点ID", + "operationId": "get_pumps_node1", + "parameters": [ + { + "description": "水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Pumps Node1" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水泵起始节点", + "tags": [ + "Pumps" + ] + }, + "patch": { + "description": "设置指定水泵的起始节点", + "operationId": "patch_pumps_node1", + "parameters": [ + { + "description": "水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "description": "新的起始节点ID", + "in": "query", + "name": "node1", + "required": true, + "schema": { + "description": "新的起始节点ID", + "title": "Node1", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水泵起始节点", + "tags": [ + "Pumps" + ] + } + }, + "/api/v1/pumps/node2": { + "get": { + "description": "获取指定水泵的终止节点ID", + "operationId": "get_pumps_node2", + "parameters": [ + { + "description": "水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Pumps Node2" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水泵终止节点", + "tags": [ + "Pumps" + ] + }, + "patch": { + "description": "设置指定水泵的终止节点", + "operationId": "patch_pumps_node2", + "parameters": [ + { + "description": "水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "description": "新的终止节点ID", + "in": "query", + "name": "node2", + "required": true, + "schema": { + "description": "新的终止节点ID", + "title": "Node2", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水泵终止节点", + "tags": [ + "Pumps" + ] + } + }, + "/api/v1/pumps/properties": { + "get": { + "description": "获取指定水泵的所有属性信息", + "operationId": "get_pumps_properties", + "parameters": [ + { + "description": "水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Pumps Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水泵属性", + "tags": [ + "Pumps" + ] + }, + "patch": { + "description": "批量设置指定水泵的多个属性", + "operationId": "patch_pumps_properties", + "parameters": [ + { + "description": "水泵ID", + "in": "query", + "name": "pump", + "required": true, + "schema": { + "description": "水泵ID", + "title": "Pump", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水泵属性", + "tags": [ + "Pumps" + ] + } + }, + "/api/v1/quality-configurations/properties": { + "get": { + "description": "获取指定节点的水质属性信息", + "operationId": "get_quality_configurations_properties", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Quality Configurations Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水质属性", + "tags": [ + "Quality" + ] + }, + "patch": { + "description": "更新指定节点的水质属性", + "operationId": "patch_quality_configurations_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水质属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/reactions": { + "patch": { + "description": "更新指定网络中的反应属性", + "operationId": "patch_reactions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置反应属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/reactions/detail": { + "get": { + "description": "获取指定网络中的反应属性信息", + "operationId": "get_reactions_detail", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Reactions Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取反应属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/redis": { + "get": { + "description": "获取Redis中所有的缓存键", + "operationId": "get_redis", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询缓存键列表", + "tags": [ + "Cache" + ] + } + }, + "/api/v1/redis-keys": { + "delete": { + "description": "根据模式清除匹配的Redis缓存键", + "operationId": "delete_redis_keys", + "parameters": [ + { + "description": "缓存键模式(支持通配符)", + "in": "query", + "name": "keys", + "required": true, + "schema": { + "description": "缓存键模式(支持通配符)", + "title": "Keys", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清除匹配的缓存键", + "tags": [ + "Cache" + ] + } + }, + "/api/v1/redis-keys/detail": { + "delete": { + "description": "根据键名清除单个Redis缓存", + "operationId": "delete_redis_keys_detail", + "parameters": [ + { + "description": "缓存键名", + "in": "query", + "name": "key", + "required": true, + "schema": { + "description": "缓存键名", + "title": "Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清除单个缓存键", + "tags": [ + "Cache" + ] + } + }, + "/api/v1/redos": { + "post": { + "description": "重做网络上被撤销的操作", + "operationId": "post_redos", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "重做操作", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/regions": { + "delete": { + "description": "删除指定的区域", + "operationId": "delete_regions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除区域", + "tags": [ + "Regions & DMAs" + ] + }, + "patch": { + "description": "修改指定区域的属性信息", + "operationId": "patch_regions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置区域属性", + "tags": [ + "Regions & DMAs" + ] + }, + "post": { + "description": "向水网添加一个新的区域", + "operationId": "post_regions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加新区域", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/regions/detail": { + "get": { + "description": "获取指定ID的区域详细信息", + "operationId": "get_regions_detail", + "parameters": [ + { + "description": "区域ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "区域ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Regions Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取区域信息", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/reservoirs": { + "delete": { + "description": "从指定供水网络中删除指定的水库/水源节点", + "operationId": "delete_reservoirs", + "parameters": [ + { + "description": "要删除的水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "要删除的水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除水库", + "tags": [ + "Reservoirs" + ] + }, + "get": { + "description": "获取指定供水网络中所有水库的属性", + "operationId": "get_reservoirs", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有水库属性", + "tags": [ + "Reservoirs" + ] + }, + "post": { + "description": "在指定供水网络中添加新的水库/水源节点", + "operationId": "post_reservoirs", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "description": "水库的X坐标", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "水库的X坐标", + "title": "X", + "type": "number" + } + }, + { + "description": "水库的Y坐标", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "水库的Y坐标", + "title": "Y", + "type": "number" + } + }, + { + "description": "水库的水头/总水头(米)", + "in": "query", + "name": "head", + "required": true, + "schema": { + "description": "水库的水头/总水头(米)", + "title": "Head", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加水库", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/reservoirs/coord": { + "get": { + "description": "获取指定水库的平面坐标(X和Y坐标)", + "operationId": "get_reservoirs_coord", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Get Reservoirs Coord" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库坐标", + "tags": [ + "Reservoirs" + ] + }, + "patch": { + "description": "更新指定水库的平面坐标(X和Y坐标)", + "operationId": "patch_reservoirs_coord", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "description": "新的X坐标值", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "新的X坐标值", + "title": "X", + "type": "number" + } + }, + { + "description": "新的Y坐标值", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "新的Y坐标值", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水库坐标", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/reservoirs/existence": { + "get": { + "description": "检查指定ID是否为水网中的水源(水库/河流)", + "operationId": "get_reservoirs_existence", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Reservoirs Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查是否为水源", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/reservoirs/head": { + "get": { + "description": "获取指定水库的供水水头/总水头值", + "operationId": "get_reservoirs_head", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Reservoirs Head" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库水头", + "tags": [ + "Reservoirs" + ] + }, + "patch": { + "description": "更新指定水库的供水水头/总水头值", + "operationId": "patch_reservoirs_head", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "description": "新的水头值(米)", + "in": "query", + "name": "head", + "required": true, + "schema": { + "description": "新的水头值(米)", + "title": "Head", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水库水头", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/reservoirs/pattern": { + "get": { + "description": "获取指定水库的运行模式/供水模式", + "operationId": "get_reservoirs_pattern", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Reservoirs Pattern" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库模式", + "tags": [ + "Reservoirs" + ] + }, + "patch": { + "description": "更新指定水库的运行模式/供水模式", + "operationId": "patch_reservoirs_pattern", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "description": "新的运行模式", + "in": "query", + "name": "pattern", + "required": true, + "schema": { + "description": "新的运行模式", + "title": "Pattern", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水库模式", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/reservoirs/properties": { + "get": { + "description": "获取指定水库的所有属性", + "operationId": "get_reservoirs_properties", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Reservoirs Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库属性", + "tags": [ + "Reservoirs" + ] + }, + "patch": { + "description": "批量更新指定水库的多个属性", + "operationId": "patch_reservoirs_properties", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水库属性", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/reservoirs/x": { + "get": { + "description": "获取指定水库的X坐标位置", + "operationId": "get_reservoirs_x", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Get Reservoirs X" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库X坐标", + "tags": [ + "Reservoirs" + ] + }, + "patch": { + "description": "更新指定水库的X坐标位置", + "operationId": "patch_reservoirs_x", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "description": "新的X坐标值", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "新的X坐标值", + "title": "X", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水库X坐标", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/reservoirs/y": { + "get": { + "description": "获取指定水库的Y坐标位置", + "operationId": "get_reservoirs_y", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Get Reservoirs Y" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水库Y坐标", + "tags": [ + "Reservoirs" + ] + }, + "patch": { + "description": "更新指定水库的Y坐标位置", + "operationId": "patch_reservoirs_y", + "parameters": [ + { + "description": "水库的唯一标识符", + "in": "query", + "name": "reservoir", + "required": true, + "schema": { + "description": "水库的唯一标识符", + "title": "Reservoir", + "type": "string" + } + }, + { + "description": "新的Y坐标值", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "新的Y坐标值", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水库Y坐标", + "tags": [ + "Reservoirs" + ] + } + }, + "/api/v1/restore-operations": { + "get": { + "description": "获取网络的恢复操作ID", + "operationId": "get_restore_operations", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Restore Operations", + "type": "integer" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取恢复操作ID", + "tags": [ + "Snapshots" + ] + }, + "patch": { + "description": "设置网络的恢复操作ID", + "operationId": "patch_restore_operations", + "parameters": [ + { + "description": "操作ID", + "in": "query", + "name": "operation", + "required": true, + "schema": { + "description": "操作ID", + "title": "Operation", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置恢复操作ID", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/rule-properties": { + "get": { + "description": "获取指定网络中的规则属性信息", + "operationId": "get_rule_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Rule Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取规则属性", + "tags": [ + "Controls & Rules" + ] + }, + "patch": { + "description": "更新指定网络中的规则属性", + "operationId": "patch_rule_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置规则属性", + "tags": [ + "Controls & Rules" + ] + } + }, + "/api/v1/rule-schemas": { + "get": { + "description": "获取网络中规则对象的架构定义", + "operationId": "get_rule_schemas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Rule Schemas", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取规则架构", + "tags": [ + "Controls & Rules" + ] + } + }, + "/api/v1/scada-device-cleaning-runs": { + "post": { + "description": "清空SCADA设备表\n\n删除指定管网中所有的SCADA设备。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n 变更集合信息", + "operationId": "post_scada_device_cleaning_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清空SCADA设备表", + "tags": [ + "SCADA设备" + ] + } + }, + "/api/v1/scada-device-data-cleaning-runs": { + "post": { + "description": "清空SCADA设备数据表\n\n删除指定管网中所有SCADA设备的数据。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n 变更集合信息", + "operationId": "post_scada_device_data_cleaning_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清空SCADA设备数据表", + "tags": [ + "SCADA设备数据" + ] + } + }, + "/api/v1/scada-device-datas": { + "delete": { + "description": "删除SCADA设备数据\n\n删除指定SCADA设备的数据记录。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要删除的数据ID\n \nReturns:\n 变更集合信息", + "operationId": "delete_scada_device_datas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除SCADA设备数据", + "tags": [ + "SCADA设备数据" + ] + }, + "patch": { + "description": "更新SCADA设备数据\n\n修改指定SCADA设备的数据。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要更新的数据\n \nReturns:\n 变更集合信息", + "operationId": "patch_scada_device_datas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新SCADA设备数据", + "tags": [ + "SCADA设备数据" + ] + }, + "post": { + "description": "添加新的SCADA设备数据\n\n为指定SCADA设备添加新的数据记录。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含新数据的内容\n \nReturns:\n 变更集合信息", + "operationId": "post_scada_device_datas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加SCADA设备数据", + "tags": [ + "SCADA设备数据" + ] + } + }, + "/api/v1/scada-device-datas/detail": { + "get": { + "description": "获取单个SCADA设备的数据\n\n查询指定设备的监测数据或配置数据。\n\nArgs:\n network: 管网名称(或数据库名称)\n device_id: SCADA设备ID\n \nReturns:\n SCADA设备数据", + "operationId": "get_scada_device_datas_detail", + "parameters": [ + { + "description": "SCADA设备ID", + "in": "query", + "name": "device_id", + "required": true, + "schema": { + "description": "SCADA设备ID", + "title": "Device Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Scada Device Datas Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA设备数据", + "tags": [ + "SCADA设备数据" + ] + } + }, + "/api/v1/scada-devices": { + "delete": { + "description": "删除SCADA设备\n\n从指定管网中删除一个SCADA设备。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要删除的设备ID\n \nReturns:\n 变更集合信息", + "operationId": "delete_scada_devices", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除SCADA设备", + "tags": [ + "SCADA设备" + ] + }, + "get": { + "description": "获取指定管网所有SCADA设备的完整信息\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备信息列表", + "operationId": "get_scada_devices", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有SCADA设备", + "tags": [ + "SCADA设备" + ] + }, + "patch": { + "description": "更新SCADA设备信息\n\n修改指定SCADA设备的属性。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要更新的设备属性\n \nReturns:\n 变更集合信息", + "operationId": "patch_scada_devices", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新SCADA设备", + "tags": [ + "SCADA设备" + ] + }, + "post": { + "description": "添加新的SCADA设备\n\n在指定管网中添加一个新的SCADA设备。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含新设备的属性\n \nReturns:\n 变更集合信息", + "operationId": "post_scada_devices", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加SCADA设备", + "tags": [ + "SCADA设备" + ] + } + }, + "/api/v1/scada-devices/detail": { + "get": { + "description": "获取单个SCADA设备的信息\n\n根据设备ID查询该设备的详细信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n id: SCADA设备ID\n \nReturns:\n SCADA设备信息", + "operationId": "get_scada_devices_detail", + "parameters": [ + { + "description": "SCADA设备ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "SCADA设备ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Scada Devices Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA设备", + "tags": [ + "SCADA设备" + ] + } + }, + "/api/v1/scada-devices/ids": { + "get": { + "description": "获取指定管网所有SCADA设备的ID列表\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA设备ID列表", + "operationId": "get_scada_devices_ids", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_str_" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有SCADA设备ID", + "tags": [ + "SCADA设备" + ] + } + }, + "/api/v1/scada-element-cleaning-runs": { + "post": { + "description": "清空SCADA元素映射表\n\n删除指定管网中所有的SCADA元素映射。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n 变更集合信息", + "operationId": "post_scada_element_cleaning_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清空SCADA元素映射表", + "tags": [ + "SCADA元素映射" + ] + } + }, + "/api/v1/scada-elements": { + "delete": { + "description": "删除SCADA元素映射\n\n移除SCADA设备与管网元素的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要删除的映射ID\n \nReturns:\n 变更集合信息", + "operationId": "delete_scada_elements", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除SCADA元素映射", + "tags": [ + "SCADA元素映射" + ] + }, + "get": { + "description": "获取指定管网所有SCADA元素映射\n\n查询所有SCADA设备与管网元素(节点/管道)的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA元素映射列表", + "operationId": "get_scada_elements", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有SCADA元素映射", + "tags": [ + "SCADA元素映射" + ] + }, + "patch": { + "description": "更新SCADA元素映射\n\n修改SCADA设备与管网元素的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含要更新的映射信息\n \nReturns:\n 变更集合信息", + "operationId": "patch_scada_elements", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新SCADA元素映射", + "tags": [ + "SCADA元素映射" + ] + }, + "post": { + "description": "添加新的SCADA元素映射\n\n创建SCADA设备与管网元素的新映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n req: 请求体,包含新映射的信息\n \nReturns:\n 变更集合信息", + "operationId": "post_scada_elements", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加SCADA元素映射", + "tags": [ + "SCADA元素映射" + ] + } + }, + "/api/v1/scada-elements/detail": { + "get": { + "description": "获取单个SCADA元素映射的信息\n\n根据ID查询特定的SCADA设备与管网元素的映射关系。\n\nArgs:\n network: 管网名称(或数据库名称)\n id: SCADA元素映射ID\n \nReturns:\n SCADA元素映射信息", + "operationId": "get_scada_elements_detail", + "parameters": [ + { + "description": "SCADA元素映射ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "SCADA元素映射ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Scada Elements Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取单个SCADA元素映射", + "tags": [ + "SCADA元素映射" + ] + } + }, + "/api/v1/scada-info": { + "get": { + "description": "获取指定管网所有SCADA的信息\n\n查询该管网下所有已配置的SCADA的完整信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA信息列表", + "operationId": "get_scada_info", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有SCADA信息", + "tags": [ + "SCADA信息" + ] + } + }, + "/api/v1/scada-info-schemas": { + "get": { + "description": "获取SCADA信息表的结构\n\n返回SCADA信息表的字段定义和类型信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n \nReturns:\n SCADA信息的字段架构信息", + "operationId": "get_scada_info_schemas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Scada Info Schemas", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA信息架构", + "tags": [ + "SCADA信息" + ] + } + }, + "/api/v1/scada-info/database-view": { + "get": { + "description": "使用连接池查询所有SCADA信息", + "operationId": "get_scada_info_database_view", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA信息", + "tags": [ + "Project Data" + ] + } + }, + "/api/v1/scada-info/detail": { + "get": { + "description": "获取单个SCADA信息\n\n根据ID查询SCADA的详细配置信息。\n\nArgs:\n network: 管网名称(或数据库名称)\n id: SCADA信息ID\n \nReturns:\n SCADA信息详情", + "operationId": "get_scada_info_detail", + "parameters": [ + { + "description": "SCADA信息ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "SCADA信息ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Scada Info Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA信息", + "tags": [ + "SCADA信息" + ] + } + }, + "/api/v1/scada-properties": { + "get": { + "description": "获取指定SCADA点的属性信息", + "operationId": "get_scada_properties", + "parameters": [ + { + "description": "SCADA点ID", + "in": "query", + "name": "scada", + "required": true, + "schema": { + "description": "SCADA点ID", + "title": "Scada", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Scada Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA点属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/scheduling-analyses": { + "post": { + "description": "对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。", + "operationId": "post_scheduling_analyses", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulingAnalysisRest", + "description": "排程分析参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Scheduling Analyses", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "排程分析", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/schemes": { + "get": { + "description": "获取指定网络的所有方案信息", + "operationId": "get_schemes", + "parameters": [ + { + "description": "方案类型;为空时返回全部类型", + "in": "query", + "name": "scheme_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "方案类型;为空时返回全部类型", + "title": "Scheme Type" + } + }, + { + "description": "查询日期(可选)", + "in": "query", + "name": "query_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "查询日期(可选)", + "title": "Query Date" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_Any__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有方案", + "tags": [ + "Schemes" + ] + } + }, + "/api/v1/schemes/detail": { + "get": { + "description": "根据名称获取指定的方案信息", + "operationId": "get_schemes_detail", + "parameters": [ + { + "description": "方案名称", + "in": "query", + "name": "schema_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Schema Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Schemes Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取单个方案", + "tags": [ + "Schemes" + ] + } + }, + "/api/v1/schemes/list-with-connection": { + "get": { + "description": "使用连接池查询所有方案信息", + "operationId": "get_schemes_list_with_connection", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取方案列表", + "tags": [ + "Project Data" + ] + } + }, + "/api/v1/schemes/{scheme_name}": { + "get": { + "description": "按方案类型获取指定方案详情", + "operationId": "get_schemes_scheme_name", + "parameters": [ + { + "description": "方案名称", + "in": "path", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "方案类型;为空时返回通用方案详情", + "in": "query", + "name": "scheme_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "方案类型;为空时返回通用方案详情", + "title": "Scheme Type" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Schemes Scheme Name", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取方案详情", + "tags": [ + "Schemes" + ] + } + }, + "/api/v1/sensor-placement-optimization-runs": { + "post": { + "operationId": "post_sensor_placement_optimization_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPlacementOptimizeRequestRest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPlacementSchemeResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "创建并返回监测点优化方案", + "tags": [ + "Sensor Placement" + ] + } + }, + "/api/v1/sensor-placement-schemes": { + "get": { + "description": "获取网络中所有传感器的放置位置信息", + "operationId": "get_sensor_placement_schemes", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_Any__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有传感器位置", + "tags": [ + "Misc" + ] + }, + "post": { + "description": "创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。", + "operationId": "post_sensor_placement_schemes", + "parameters": [ + { + "description": "放置方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "放置方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "传感器类型", + "in": "query", + "name": "sensor_type", + "required": true, + "schema": { + "description": "传感器类型", + "title": "Sensor Type", + "type": "string" + } + }, + { + "description": "放置方法('sensitivity'或'kmeans')", + "in": "query", + "name": "method", + "required": true, + "schema": { + "description": "放置方法('sensitivity'或'kmeans')", + "title": "Method", + "type": "string" + } + }, + { + "description": "传感器数量", + "in": "query", + "name": "sensor_count", + "required": true, + "schema": { + "description": "传感器数量", + "title": "Sensor Count", + "type": "integer" + } + }, + { + "description": "最小管径限制(毫米),默认0", + "in": "query", + "name": "min_diameter", + "required": false, + "schema": { + "default": 0, + "description": "最小管径限制(毫米),默认0", + "title": "Min Diameter", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Sensor Placement Schemes", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "传感器放置方案创建", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/sensor-placement-schemes/{scheme_id}": { + "get": { + "operationId": "get_sensor_placement_schemes_scheme_id", + "parameters": [ + { + "in": "path", + "name": "scheme_id", + "required": true, + "schema": { + "title": "Scheme Id", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPlacementSchemeResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取监测点方案详情", + "tags": [ + "Sensor Placement" + ] + }, + "put": { + "operationId": "put_sensor_placement_schemes_scheme_id", + "parameters": [ + { + "in": "path", + "name": "scheme_id", + "required": true, + "schema": { + "title": "Scheme Id", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPlacementUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPlacementSchemeResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "覆盖保存监测点方案", + "tags": [ + "Sensor Placement" + ] + } + }, + "/api/v1/sensor-placement-schemes/{scheme_id}/exports/excel": { + "post": { + "operationId": "post_sensor_placement_schemes_scheme_id_exports_excel", + "parameters": [ + { + "in": "path", + "name": "scheme_id", + "required": true, + "schema": { + "title": "Scheme Id", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPlacementExportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "导出监测点工程清单", + "tags": [ + "Sensor Placement" + ] + } + }, + "/api/v1/service-area-calculations": { + "post": { + "description": "计算指定水网的服务区分区,返回全部时间步结果", + "operationId": "post_service_area_calculations", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__list_str___" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算服务区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/service-area-generation-runs": { + "post": { + "description": "根据参数自动生成水网的服务区分区", + "operationId": "post_service_area_generation_runs", + "parameters": [ + { + "description": "膨胀参数", + "in": "query", + "name": "inflate_delta", + "required": true, + "schema": { + "description": "膨胀参数", + "title": "Inflate Delta", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "生成服务区分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/service-areas": { + "delete": { + "description": "删除指定的服务区", + "operationId": "delete_service_areas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除服务区", + "tags": [ + "Regions & DMAs" + ] + }, + "get": { + "description": "获取指定水网中的所有服务区信息", + "operationId": "get_service_areas", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有服务区", + "tags": [ + "Regions & DMAs" + ] + }, + "patch": { + "description": "修改指定服务区的属性信息", + "operationId": "patch_service_areas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置服务区属性", + "tags": [ + "Regions & DMAs" + ] + }, + "post": { + "description": "向水网添加一个新的服务区", + "operationId": "post_service_areas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加新服务区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/service-areas/detail": { + "get": { + "description": "获取指定ID的服务区详细信息", + "operationId": "get_service_areas_detail", + "parameters": [ + { + "description": "服务区ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "服务区ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Service Areas Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取服务区信息", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/simulation-runs": { + "post": { + "description": "根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。", + "operationId": "post_simulation_runs", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSimulationManuallyByDateRest", + "description": "模拟运行参数" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Post Simulation Runs", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "手动运行日期指定模拟", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/snapshot-for-current-operations": { + "get": { + "description": "检查当前操作的快照是否存在", + "operationId": "get_snapshot_for_current_operations", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Snapshot For Current Operations", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查当前操作快照是否存在", + "tags": [ + "Snapshots" + ] + }, + "post": { + "description": "为当前操作创建快照", + "operationId": "post_snapshot_for_current_operations", + "parameters": [ + { + "description": "快照标签", + "in": "query", + "name": "tag", + "required": true, + "schema": { + "description": "快照标签", + "title": "Tag", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "为当前操作创建快照", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/snapshot-for-operations": { + "get": { + "description": "检查指定操作ID的快照是否存在", + "operationId": "get_snapshot_for_operations", + "parameters": [ + { + "description": "操作ID", + "in": "query", + "name": "operation", + "required": true, + "schema": { + "description": "操作ID", + "title": "Operation", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Snapshot For Operations", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查操作快照是否存在", + "tags": [ + "Snapshots" + ] + }, + "post": { + "description": "为指定的操作创建快照", + "operationId": "post_snapshot_for_operations", + "parameters": [ + { + "description": "操作ID", + "in": "query", + "name": "operation", + "required": true, + "schema": { + "description": "操作ID", + "title": "Operation", + "type": "integer" + } + }, + { + "description": "快照标签", + "in": "query", + "name": "tag", + "required": true, + "schema": { + "description": "快照标签", + "title": "Tag", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "为操作创建快照", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/snapshots": { + "get": { + "description": "获取网络中的所有快照", + "operationId": "get_snapshots", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_tuple_int__str__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取快照列表", + "tags": [ + "Snapshots" + ] + }, + "patch": { + "description": "选择并恢复到指定的快照", + "operationId": "patch_snapshots", + "parameters": [ + { + "description": "快照标签", + "in": "query", + "name": "tag", + "required": true, + "schema": { + "description": "快照标签", + "title": "Tag", + "type": "string" + } + }, + { + "description": "是否丢弃当前更改", + "in": "query", + "name": "discard", + "required": false, + "schema": { + "default": false, + "description": "是否丢弃当前更改", + "title": "Discard", + "type": "boolean" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "选择快照", + "tags": [ + "Snapshots" + ] + }, + "post": { + "description": "为网络创建一个快照", + "operationId": "post_snapshots", + "parameters": [ + { + "description": "快照标签", + "in": "query", + "name": "tag", + "required": true, + "schema": { + "description": "快照标签", + "title": "Tag", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "创建快照", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/snapshots/existence": { + "get": { + "description": "检查指定标签的快照是否存在", + "operationId": "get_snapshots_existence", + "parameters": [ + { + "description": "快照标签", + "in": "query", + "name": "tag", + "required": true, + "schema": { + "description": "快照标签", + "title": "Tag", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Snapshots Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查快照是否存在", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/sources": { + "delete": { + "description": "从网络中删除指定节点的水源", + "operationId": "delete_sources", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除水源", + "tags": [ + "Quality" + ] + }, + "patch": { + "description": "更新指定节点的水源属性", + "operationId": "patch_sources", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水源属性", + "tags": [ + "Quality" + ] + }, + "post": { + "description": "在网络中添加一个新的水源", + "operationId": "post_sources", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加水源", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/sources/detail": { + "get": { + "description": "获取指定节点的水源属性信息", + "operationId": "get_sources_detail", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Sources Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水源属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/status": { + "get": { + "description": "获取指定管线的状态信息", + "operationId": "get_status", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Status", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管线状态", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/status-properties": { + "patch": { + "description": "设置指定管线的状态信息", + "operationId": "patch_status_properties", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置管线状态", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/status-schemas": { + "get": { + "description": "获取指定水网的状态(Status)属性架构定义", + "operationId": "get_status_schemas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Status Schemas", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取状态属性架构", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/sub-district-metering-areas": { + "post": { + "description": "为指定DMA生成子DMA分区", + "operationId": "post_sub_district_metering_areas", + "parameters": [ + { + "description": "DMA ID", + "in": "query", + "name": "dma", + "required": true, + "schema": { + "description": "DMA ID", + "title": "Dma", + "type": "string" + } + }, + { + "description": "分区数量", + "in": "query", + "name": "part_count", + "required": true, + "schema": { + "description": "分区数量", + "exclusiveMinimum": 0, + "title": "Part Count", + "type": "integer" + } + }, + { + "description": "分区类型", + "in": "query", + "name": "part_type", + "required": true, + "schema": { + "description": "分区类型", + "title": "Part Type", + "type": "integer" + } + }, + { + "description": "膨胀参数", + "in": "query", + "name": "inflate_delta", + "required": true, + "schema": { + "description": "膨胀参数", + "title": "Inflate Delta", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "生成DMA子分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/tags": { + "get": { + "description": "获取指定水网中的所有标签信息", + "operationId": "get_tags", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有标签", + "tags": [ + "Tags" + ] + }, + "patch": { + "description": "为指定元素设置或修改标签信息", + "operationId": "patch_tags", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置标签", + "tags": [ + "Tags" + ] + } + }, + "/api/v1/tags/detail": { + "get": { + "description": "获取指定类型和ID的标签信息", + "operationId": "get_tags_detail", + "parameters": [ + { + "description": "标签类型", + "in": "query", + "name": "t_type", + "required": true, + "schema": { + "description": "标签类型", + "title": "T Type", + "type": "string" + } + }, + { + "description": "元素ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "元素ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Tags Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取标签信息", + "tags": [ + "Tags" + ] + } + }, + "/api/v1/tank-reactions": { + "patch": { + "description": "更新指定水池的反应属性", + "operationId": "patch_tank_reactions", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水池反应属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/tank-reactions/detail": { + "get": { + "description": "获取指定水池的反应属性信息", + "operationId": "get_tank_reactions_detail", + "parameters": [ + { + "description": "水池ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水池ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Tank Reactions Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水池反应属性", + "tags": [ + "Quality" + ] + } + }, + "/api/v1/tanks": { + "delete": { + "description": "删除指定网络中的水箱", + "operationId": "delete_tanks", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除水箱", + "tags": [ + "Tanks" + ] + }, + "get": { + "description": "获取指定网络中所有水箱的属性", + "operationId": "get_tanks", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有水箱属性", + "tags": [ + "Tanks" + ] + }, + "post": { + "description": "向指定网络中新增一个水箱", + "operationId": "post_tanks", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "X坐标", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "X坐标", + "title": "X", + "type": "number" + } + }, + { + "description": "Y坐标", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "Y坐标", + "title": "Y", + "type": "number" + } + }, + { + "description": "标高", + "in": "query", + "name": "elevation", + "required": true, + "schema": { + "description": "标高", + "title": "Elevation", + "type": "number" + } + }, + { + "description": "初始水位", + "in": "query", + "name": "init_level", + "required": false, + "schema": { + "default": 0, + "description": "初始水位", + "title": "Init Level", + "type": "number" + } + }, + { + "description": "最小水位", + "in": "query", + "name": "min_level", + "required": false, + "schema": { + "default": 0, + "description": "最小水位", + "title": "Min Level", + "type": "number" + } + }, + { + "description": "最大水位", + "in": "query", + "name": "max_level", + "required": false, + "schema": { + "default": 0, + "description": "最大水位", + "title": "Max Level", + "type": "number" + } + }, + { + "description": "直径", + "in": "query", + "name": "diameter", + "required": false, + "schema": { + "default": 0, + "description": "直径", + "title": "Diameter", + "type": "number" + } + }, + { + "description": "最小体积", + "in": "query", + "name": "min_vol", + "required": false, + "schema": { + "default": 0, + "description": "最小体积", + "title": "Min Vol", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "新增水箱", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/coord": { + "get": { + "description": "获取指定水箱的X和Y坐标", + "operationId": "get_tanks_coord", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "number" + }, + "title": "Response Get Tanks Coord", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱坐标", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的X和Y坐标", + "operationId": "patch_tanks_coord", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的X坐标值", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "新的X坐标值", + "title": "X", + "type": "number" + } + }, + { + "description": "新的Y坐标值", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "新的Y坐标值", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱坐标", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/diameter": { + "get": { + "description": "获取指定水箱的直径值", + "operationId": "get_tanks_diameter", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Diameter" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱直径", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的直径值", + "operationId": "patch_tanks_diameter", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的直径值", + "in": "query", + "name": "diameter", + "required": true, + "schema": { + "description": "新的直径值", + "title": "Diameter", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱直径", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/elevation": { + "get": { + "description": "获取指定水箱的标高值", + "operationId": "get_tanks_elevation", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Elevation" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱标高", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的标高值", + "operationId": "patch_tanks_elevation", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的标高值", + "in": "query", + "name": "elevation", + "required": true, + "schema": { + "description": "新的标高值", + "title": "Elevation", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱标高", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/existence": { + "get": { + "description": "检查指定ID是否为水网中的蓄水池", + "operationId": "get_tanks_existence", + "parameters": [ + { + "description": "节点ID", + "in": "query", + "name": "node", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Tanks Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查是否为蓄水池", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/tanks/init-level": { + "get": { + "description": "获取指定水箱的初始水位值", + "operationId": "get_tanks_init_level", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Init Level" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱初始水位", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的初始水位值", + "operationId": "patch_tanks_init_level", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的初始水位值", + "in": "query", + "name": "init_level", + "required": true, + "schema": { + "description": "新的初始水位值", + "title": "Init Level", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱初始水位", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/max-level": { + "get": { + "description": "获取指定水箱的最大水位值", + "operationId": "get_tanks_max_level", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Max Level" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱最大水位", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的最大水位值", + "operationId": "patch_tanks_max_level", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的最大水位值", + "in": "query", + "name": "max_level", + "required": true, + "schema": { + "description": "新的最大水位值", + "title": "Max Level", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱最大水位", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/min-level": { + "get": { + "description": "获取指定水箱的最小水位值", + "operationId": "get_tanks_min_level", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Min Level" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱最小水位", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的最小水位值", + "operationId": "patch_tanks_min_level", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的最小水位值", + "in": "query", + "name": "min_level", + "required": true, + "schema": { + "description": "新的最小水位值", + "title": "Min Level", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱最小水位", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/min-vol": { + "get": { + "description": "获取指定水箱的最小体积值", + "operationId": "get_tanks_min_vol", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Min Vol" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱最小体积", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的最小体积值", + "operationId": "patch_tanks_min_vol", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的最小体积值", + "in": "query", + "name": "min_vol", + "required": true, + "schema": { + "description": "新的最小体积值", + "title": "Min Vol", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱最小体积", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/overflow": { + "get": { + "description": "获取指定水箱的溢流口配置", + "operationId": "get_tanks_overflow", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Overflow" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱溢流口", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的溢流口配置", + "operationId": "patch_tanks_overflow", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的溢流口配置", + "in": "query", + "name": "overflow", + "required": true, + "schema": { + "description": "新的溢流口配置", + "title": "Overflow", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱溢流口", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/properties": { + "get": { + "description": "获取指定水箱的所有属性", + "operationId": "get_tanks_properties", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Tanks Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱属性", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "批量设置指定水箱的多个属性", + "operationId": "patch_tanks_properties", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱属性", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/vol-curve": { + "get": { + "description": "获取指定水箱的容积曲线标识", + "operationId": "get_tanks_vol_curve", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Tanks Vol Curve" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱容积曲线", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的容积曲线标识", + "operationId": "patch_tanks_vol_curve", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的容积曲线标识", + "in": "query", + "name": "vol_curve", + "required": true, + "schema": { + "description": "新的容积曲线标识", + "title": "Vol Curve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱容积曲线", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/x": { + "get": { + "description": "获取指定水箱的X坐标值", + "operationId": "get_tanks_x", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Tanks X", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱X坐标", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的X坐标值", + "operationId": "patch_tanks_x", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的X坐标值", + "in": "query", + "name": "x", + "required": true, + "schema": { + "description": "新的X坐标值", + "title": "X", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱X坐标", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/tanks/y": { + "get": { + "description": "获取指定水箱的Y坐标值", + "operationId": "get_tanks_y", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Tanks Y", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水箱Y坐标", + "tags": [ + "Tanks" + ] + }, + "patch": { + "description": "设置指定水箱的Y坐标值", + "operationId": "patch_tanks_y", + "parameters": [ + { + "description": "水箱ID", + "in": "query", + "name": "tank", + "required": true, + "schema": { + "description": "水箱ID", + "title": "Tank", + "type": "string" + } + }, + { + "description": "新的Y坐标值", + "in": "query", + "name": "y", + "required": true, + "schema": { + "description": "新的Y坐标值", + "title": "Y", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水箱Y坐标", + "tags": [ + "Tanks" + ] + } + }, + "/api/v1/time-properties": { + "patch": { + "description": "更新指定网络中的时间选项属性", + "operationId": "patch_time_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置时间选项属性", + "tags": [ + "Options" + ] + } + }, + "/api/v1/timeseries/realtime/links": { + "delete": { + "description": "按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", + "operationId": "delete_timeseries_realtime_links", + "parameters": [ + { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除实时管道数据", + "tags": [ + "TimescaleDB - Realtime" + ] + }, + "get": { + "description": "按时间范围查询实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", + "operationId": "get_timeseries_realtime_links", + "parameters": [ + { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询实时管道数据", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/realtime/links/batches": { + "post": { + "description": "批量插入实时管道数据\n\n将管道的实时监测数据批量插入时间序列数据库。\n\nArgs:\n data: 管道数据列表\n \nReturns:\n 插入成功的记录数", + "operationId": "post_timeseries_realtime_links_batches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "管道数据列表,每项包含管道ID、时间戳等信息", + "items": { + "type": "object" + }, + "title": "Data", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量插入实时管道数据", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/realtime/links/{link_id}/field": { + "patch": { + "description": "更新指定管道的字段值\n\n更新实时管道在特定时间的某个字段数据。\n\nArgs:\n link_id: 管道ID\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n \nReturns:\n 更新结果信息\n \nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", + "operationId": "patch_timeseries_realtime_links_link_id_field", + "parameters": [ + { + "description": "管道ID", + "in": "path", + "name": "link_id", + "required": true, + "schema": { + "description": "管道ID", + "title": "Link Id", + "type": "string" + } + }, + { + "description": "要更新记录的时间戳。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "time", + "required": true, + "schema": { + "description": "要更新记录的时间戳。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "Time", + "type": "string" + } + }, + { + "description": "要更新的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要更新的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "description": "更新的字段值", + "in": "query", + "name": "value", + "required": true, + "schema": { + "description": "更新的字段值", + "title": "Value", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新实时管道字段", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/realtime/nodes": { + "delete": { + "description": "按时间范围删除实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。", + "operationId": "delete_timeseries_realtime_nodes", + "parameters": [ + { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除实时节点数据", + "tags": [ + "TimescaleDB - Realtime" + ] + }, + "get": { + "description": "按时间范围查询实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", + "operationId": "get_timeseries_realtime_nodes", + "parameters": [ + { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "时间范围开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "时间范围结束时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询实时节点数据", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/realtime/nodes/batches": { + "post": { + "description": "批量插入实时节点数据\n\n将节点的实时监测数据批量插入时间序列数据库。\n\nArgs:\n data: 节点数据列表\n \nReturns:\n 插入成功的记录数", + "operationId": "post_timeseries_realtime_nodes_batches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "节点数据列表,每项包含节点ID、时间戳等信息", + "items": { + "type": "object" + }, + "title": "Data", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量插入实时节点数据", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/realtime/records": { + "get": { + "description": "查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", + "operationId": "get_timeseries_realtime_records", + "parameters": [ + { + "description": "查询时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "query_time", + "required": true, + "schema": { + "description": "查询时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "title": "Query Time", + "type": "string" + } + }, + { + "description": "数据类型,pipe(管道)或 junction(节点)", + "in": "query", + "name": "type", + "required": true, + "schema": { + "description": "数据类型,pipe(管道)或 junction(节点)", + "title": "Type", + "type": "string" + } + }, + { + "description": "要查询的属性名称", + "in": "query", + "name": "property", + "required": true, + "schema": { + "description": "要查询的属性名称", + "title": "Property", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按时间和属性查询实时数据", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/realtime/simulation-results": { + "get": { + "description": "查询指定元素在某一时间点的实时模拟结果。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。", + "operationId": "get_timeseries_realtime_simulation_results", + "parameters": [ + { + "description": "元素ID(管道ID或节点ID)", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "元素ID(管道ID或节点ID)", + "title": "Id", + "type": "string" + } + }, + { + "description": "元素类型,pipe(管道)或 junction(节点)", + "in": "query", + "name": "type", + "required": true, + "schema": { + "description": "元素类型,pipe(管道)或 junction(节点)", + "title": "Type", + "type": "string" + } + }, + { + "description": "查询时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "query_time", + "required": true, + "schema": { + "description": "查询时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "title": "Query Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按ID和时间查询实时模拟数据", + "tags": [ + "TimescaleDB - Realtime" + ] + }, + "post": { + "description": "存储实时模拟结果到时间序列数据库\n\n将节点和管道的实时模拟计算结果批量存储到TimescaleDB数据库。\n\nArgs:\n node_result_list: 节点模拟结果列表\n link_result_list: 管道模拟结果列表\n result_start_time: 模拟结果对应的起始时间\n \nReturns:\n 存储结果信息", + "operationId": "post_timeseries_realtime_simulation_results", + "parameters": [ + { + "description": "模拟结果开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "in": "query", + "name": "result_start_time", + "required": true, + "schema": { + "description": "模拟结果开始时间。ISO 8601 / RFC 3339 时间,必须显式带时区;可直接传 UTC+8,服务端会先转换为 UTC 再处理。", + "title": "Result Start Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_post_timeseries_realtime_simulation_results" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "存储实时模拟结果", + "tags": [ + "TimescaleDB - Realtime" + ] + } + }, + "/api/v1/timeseries/scada-cleaning-runs": { + "post": { + "description": "清洗SCADA监测数据\n\n根据device_ids查询monitored_value,清洗后更新cleaned_value。\n支持清洗指定设备或所有设备的数据。\n\nArgs:\n device_ids: 设备ID列表,用逗号分隔,或 'all' 表示清洗所有设备\n start_time: 清洗数据的开始时间\n end_time: 清洗数据的结束时间\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n 清洗结果信息\n \nRaises:\n HTTPException: 当清洗过程出现错误时返回400错误", + "operationId": "post_timeseries_scada_cleaning_runs", + "parameters": [ + { + "description": "设备ID列表或 'all' 表示清洗所有设备", + "in": "query", + "name": "device_ids", + "required": true, + "schema": { + "description": "设备ID列表或 'all' 表示清洗所有设备", + "title": "Device Ids", + "type": "string" + } + }, + { + "description": "清洗数据的开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "清洗数据的开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "清洗数据的结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "清洗数据的结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "清洗SCADA监测数据", + "tags": [ + "TimescaleDB - Composite" + ] + } + }, + "/api/v1/timeseries/scada-readings": { + "delete": { + "description": "删除指定设备和时间范围内的SCADA数据\n\n删除在指定时间范围内的特定设备监测数据。\n\nArgs:\n device_id: 设备ID\n start_time: 删除开始时间\n end_time: 删除结束时间\n\nReturns:\n 删除结果信息", + "operationId": "delete_timeseries_scada_readings", + "parameters": [ + { + "description": "设备ID", + "in": "query", + "name": "device_id", + "required": true, + "schema": { + "description": "设备ID", + "title": "Device Id", + "type": "string" + } + }, + { + "description": "删除开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "删除开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "删除结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "删除结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按设备ID和时间范围删除SCADA数据", + "tags": [ + "TimescaleDB - SCADA" + ] + }, + "get": { + "description": "按设备ID和时间范围查询SCADA监测数据\n\n查询多个设备在指定时间范围内的所有监测数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n device_ids: 设备ID列表,用逗号分隔\n\nReturns:\n SCADA监测数据列表", + "operationId": "get_timeseries_scada_readings", + "parameters": [ + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "设备ID列表,逗号分隔,如 'device1,device2,device3'", + "in": "query", + "name": "device_ids", + "required": true, + "schema": { + "description": "设备ID列表,逗号分隔,如 'device1,device2,device3'", + "title": "Device Ids", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按设备ID和时间范围查询SCADA数据", + "tags": [ + "TimescaleDB - SCADA" + ] + } + }, + "/api/v1/timeseries/scada-readings/batches": { + "post": { + "description": "批量插入SCADA监测数据\n\n将多个设备的实时监测数据批量插入时间序列数据库。\n\nArgs:\n data: SCADA设备监测数据列表,每项包含device_id、时间戳和监测值等信息\n\nReturns:\n 插入成功的记录数", + "operationId": "post_timeseries_scada_readings_batches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "SCADA设备监测数据列表", + "items": { + "type": "object" + }, + "title": "Data", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量插入SCADA监测数据", + "tags": [ + "TimescaleDB - SCADA" + ] + } + }, + "/api/v1/timeseries/scada-readings/fields": { + "get": { + "description": "按设备ID、字段和时间范围查询特定SCADA数据\n\n查询多个设备在指定时间范围内的特定字段监测数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n field: 字段名称\n device_ids: 设备ID列表,用逗号分隔\n\nReturns:\n SCADA字段数据列表\n\nRaises:\n HTTPException: 当字段不存在或查询参数无效时返回400错误", + "operationId": "get_timeseries_scada_readings_fields", + "parameters": [ + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "要查询的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要查询的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "description": "设备ID列表,逗号分隔,如 'device1,device2,device3'", + "in": "query", + "name": "device_ids", + "required": true, + "schema": { + "description": "设备ID列表,逗号分隔,如 'device1,device2,device3'", + "title": "Device Ids", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按设备ID、字段和时间范围查询SCADA数据", + "tags": [ + "TimescaleDB - SCADA" + ] + } + }, + "/api/v1/timeseries/scada-readings/{device_id}/field": { + "patch": { + "description": "更新指定设备的字段值\n\n更新SCADA设备在特定时间的某个字段监测数据。\n\nArgs:\n device_id: 设备ID\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", + "operationId": "patch_timeseries_scada_readings_device_id_field", + "parameters": [ + { + "description": "设备ID", + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "description": "设备ID", + "title": "Device Id", + "type": "string" + } + }, + { + "description": "更新数据的时间戳", + "in": "query", + "name": "time", + "required": true, + "schema": { + "description": "更新数据的时间戳", + "format": "date-time", + "title": "Time", + "type": "string" + } + }, + { + "description": "要更新的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要更新的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "description": "更新的字段值", + "in": "query", + "name": "value", + "required": true, + "schema": { + "description": "更新的字段值", + "title": "Value", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新SCADA设备字段", + "tags": [ + "TimescaleDB - SCADA" + ] + } + }, + "/api/v1/timeseries/schemes/links": { + "delete": { + "description": "删除指定方案和时间范围内的管道数据\n\n删除在指定方案和时间范围内的所有管道模拟数据。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 删除开始时间\n end_time: 删除结束时间\n\nReturns:\n 删除结果信息", + "operationId": "delete_timeseries_schemes_links", + "parameters": [ + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "删除开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "删除开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "删除结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "删除结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除方案管道数据", + "tags": [ + "TimescaleDB - Scheme" + ] + }, + "get": { + "description": "查询指定方案和时间范围内的管道数据\n\n根据方案和时间范围查询管道的模拟值。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 查询开始时间\n end_time: 查询结束时间\n\nReturns:\n 方案管道数据列表", + "operationId": "get_timeseries_schemes_links", + "parameters": [ + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询方案管道数据", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/links/batches": { + "post": { + "description": "批量插入方案管道数据\n\n将特定方案的管道模拟数据批量插入时间序列数据库。\n\nArgs:\n data: 方案管道数据列表\n\nReturns:\n 插入成功的记录数", + "operationId": "post_timeseries_schemes_links_batches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "方案管道数据列表", + "items": { + "type": "object" + }, + "title": "Data", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量插入方案管道数据", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/links/{link_id}/field": { + "get": { + "description": "查询指定方案管道的特定字段数据\n\n查询特定方案中指定管道在时间范围内的特定字段值。\n\nArgs:\n link_id: 管道ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 查询开始时间\n end_time: 查询结束时间\n field: 字段名称\n\nReturns:\n 字段数据列表\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", + "operationId": "get_timeseries_schemes_links_link_id_field", + "parameters": [ + { + "description": "管道ID", + "in": "path", + "name": "link_id", + "required": true, + "schema": { + "description": "管道ID", + "title": "Link Id", + "type": "string" + } + }, + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "要查询的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要查询的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询方案管道字段数据", + "tags": [ + "TimescaleDB - Scheme" + ] + }, + "patch": { + "description": "更新指定方案管道的字段值\n\n更新特定方案中指定管道在某个时间的字段数据。\n\nArgs:\n link_id: 管道ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", + "operationId": "patch_timeseries_schemes_links_link_id_field", + "parameters": [ + { + "description": "管道ID", + "in": "path", + "name": "link_id", + "required": true, + "schema": { + "description": "管道ID", + "title": "Link Id", + "type": "string" + } + }, + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "更新数据的时间戳", + "in": "query", + "name": "time", + "required": true, + "schema": { + "description": "更新数据的时间戳", + "format": "date-time", + "title": "Time", + "type": "string" + } + }, + { + "description": "要更新的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要更新的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "description": "更新的字段值", + "in": "query", + "name": "value", + "required": true, + "schema": { + "description": "更新的字段值", + "title": "Value", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新方案管道字段", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/nodes": { + "delete": { + "description": "删除指定方案和时间范围内的节点数据\n\n删除在指定方案和时间范围内的所有节点模拟数据。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 删除开始时间\n end_time: 删除结束时间\n\nReturns:\n 删除结果信息", + "operationId": "delete_timeseries_schemes_nodes", + "parameters": [ + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "删除开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "删除开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "删除结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "删除结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除方案节点数据", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/nodes/batches": { + "post": { + "description": "批量插入方案节点数据\n\n将特定方案的节点模拟数据批量插入时间序列数据库。\n\nArgs:\n data: 方案节点数据列表\n\nReturns:\n 插入成功的记录数", + "operationId": "post_timeseries_schemes_nodes_batches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "方案节点数据列表", + "items": { + "type": "object" + }, + "title": "Data", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量插入方案节点数据", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/nodes/{node_id}/field": { + "get": { + "description": "查询指定方案节点的特定字段数据\n\n查询特定方案中指定节点在时间范围内的特定字段值。\n\nArgs:\n node_id: 节点ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n start_time: 查询开始时间\n end_time: 查询结束时间\n field: 字段名称\n\nReturns:\n 字段数据列表\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", + "operationId": "get_timeseries_schemes_nodes_node_id_field", + "parameters": [ + { + "description": "节点ID", + "in": "path", + "name": "node_id", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node Id", + "type": "string" + } + }, + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "要查询的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要查询的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "查询方案节点字段数据", + "tags": [ + "TimescaleDB - Scheme" + ] + }, + "patch": { + "description": "更新指定方案节点的字段值\n\n更新特定方案中指定节点在某个时间的字段数据。\n\nArgs:\n node_id: 节点ID\n scheme_type: 方案类型\n scheme_name: 方案名称\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", + "operationId": "patch_timeseries_schemes_nodes_node_id_field", + "parameters": [ + { + "description": "节点ID", + "in": "path", + "name": "node_id", + "required": true, + "schema": { + "description": "节点ID", + "title": "Node Id", + "type": "string" + } + }, + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "更新数据的时间戳", + "in": "query", + "name": "time", + "required": true, + "schema": { + "description": "更新数据的时间戳", + "format": "date-time", + "title": "Time", + "type": "string" + } + }, + { + "description": "要更新的字段名称", + "in": "query", + "name": "field", + "required": true, + "schema": { + "description": "要更新的字段名称", + "title": "Field", + "type": "string" + } + }, + { + "description": "更新的字段值", + "in": "query", + "name": "value", + "required": true, + "schema": { + "description": "更新的字段值", + "title": "Value", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "更新方案节点字段", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/records": { + "get": { + "description": "按指定方案、时间和属性查询所有方案数据\n\n查询在特定方案和时间点,所有指定类型元素的特定属性值。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n query_time: 查询时间\n type: 元素类型(pipe或junction)\n property: 属性名称\n\nReturns:\n 查询结果列表\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", + "operationId": "get_timeseries_schemes_records", + "parameters": [ + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "查询时间", + "in": "query", + "name": "query_time", + "required": true, + "schema": { + "description": "查询时间", + "title": "Query Time", + "type": "string" + } + }, + { + "description": "元素类型,pipe(管道)或 junction(节点)", + "in": "query", + "name": "type", + "required": true, + "schema": { + "description": "元素类型,pipe(管道)或 junction(节点)", + "title": "Type", + "type": "string" + } + }, + { + "description": "要查询的属性名称", + "in": "query", + "name": "property", + "required": true, + "schema": { + "description": "要查询的属性名称", + "title": "Property", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按方案、时间和属性查询数据", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/schemes/simulation-results": { + "get": { + "description": "按指定ID和时间查询方案模拟结果\n\n查询特定方案中的元素在某一时间点的模拟数据。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n id: 元素ID\n type: 元素类型(pipe或junction)\n query_time: 查询时间\n\nReturns:\n 模拟结果数据\n\nRaises:\n HTTPException: 当查询参数无效时返回400错误", + "operationId": "get_timeseries_schemes_simulation_results", + "parameters": [ + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "元素ID(管道ID或节点ID)", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "元素ID(管道ID或节点ID)", + "title": "Id", + "type": "string" + } + }, + { + "description": "元素类型,pipe(管道)或 junction(节点)", + "in": "query", + "name": "type", + "required": true, + "schema": { + "description": "元素类型,pipe(管道)或 junction(节点)", + "title": "Type", + "type": "string" + } + }, + { + "description": "查询时间", + "in": "query", + "name": "query_time", + "required": true, + "schema": { + "description": "查询时间", + "title": "Query Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "按ID和时间查询方案模拟数据", + "tags": [ + "TimescaleDB - Scheme" + ] + }, + "post": { + "description": "存储方案模拟结果到时间序列数据库\n\n将特定方案的节点和管道模拟计算结果批量存储到TimescaleDB数据库。\n\nArgs:\n scheme_type: 方案类型\n scheme_name: 方案名称\n node_result_list: 节点模拟结果列表\n link_result_list: 管道模拟结果列表\n result_start_time: 模拟结果对应的起始时间\n\nReturns:\n 存储结果信息", + "operationId": "post_timeseries_schemes_simulation_results", + "parameters": [ + { + "description": "方案类型", + "in": "query", + "name": "scheme_type", + "required": true, + "schema": { + "description": "方案类型", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "description": "模拟结果开始时间", + "in": "query", + "name": "result_start_time", + "required": true, + "schema": { + "description": "模拟结果开始时间", + "title": "Result Start Time", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_post_timeseries_schemes_simulation_results" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "存储方案模拟结果", + "tags": [ + "TimescaleDB - Scheme" + ] + } + }, + "/api/v1/timeseries/views/element-scada-readings": { + "get": { + "description": "获取link/node关联的SCADA监测值\n\n根据传入的link/node id,匹配SCADA信息,\n如果存在关联的SCADA device_id,获取实际的监测数据。\n\nArgs:\n element_id: 管网元素ID\n start_time: 查询开始时间\n end_time: 查询结束时间\n use_cleaned: 是否使用清洗后的数据,默认为False使用原始数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n 管网元素关联的SCADA监测数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误", + "operationId": "get_timeseries_views_element_scada_readings", + "parameters": [ + { + "description": "管网元素ID(管道或节点)", + "in": "query", + "name": "element_id", + "required": true, + "schema": { + "description": "管网元素ID(管道或节点)", + "title": "Element Id", + "type": "string" + } + }, + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "是否使用清洗后的数据", + "in": "query", + "name": "use_cleaned", + "required": false, + "schema": { + "default": false, + "description": "是否使用清洗后的数据", + "title": "Use Cleaned", + "type": "boolean" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管网元素关联的SCADA监测数据", + "tags": [ + "TimescaleDB - Composite" + ] + } + }, + "/api/v1/timeseries/views/element-simulations": { + "get": { + "description": "获取link/node模拟值\n\n根据传入的featureInfos,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或方案数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n feature_infos: 格式为 \"element_id1:type1,element_id2:type2\"\n 例如: \"P1:pipe,J1:junction\"\n scheme_type: 方案类型,若为空则查询实时数据\n scheme_name: 方案名称,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n \nReturns:\n 管网元素的模拟数据\n \nRaises:\n HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误", + "operationId": "get_timeseries_views_element_simulations", + "parameters": [ + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点)", + "in": "query", + "name": "feature_infos", + "required": true, + "schema": { + "description": "特征信息,格式: id1:type1,id2:type2,type为pipe(管道)或junction(节点)", + "title": "Feature Infos", + "type": "string" + } + }, + { + "description": "方案类型,若为空则查询实时数据", + "in": "query", + "name": "scheme_type", + "required": false, + "schema": { + "description": "方案类型,若为空则查询实时数据", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称,若为空则查询实时数据", + "in": "query", + "name": "scheme_name", + "required": false, + "schema": { + "description": "方案名称,若为空则查询实时数据", + "title": "Scheme Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取管网元素的模拟数据", + "tags": [ + "TimescaleDB - Composite" + ] + } + }, + "/api/v1/timeseries/views/scada-simulations": { + "get": { + "description": "获取SCADA关联的link/node模拟值\n\n根据传入的SCADA device_ids,找到关联的link/node,\n并根据对应的type,查询对应的模拟数据。支持查询实时或方案数据。\n\nArgs:\n start_time: 查询开始时间\n end_time: 查询结束时间\n device_ids: SCADA设备ID列表,用逗号分隔\n scheme_type: 方案类型,若为空则查询实时数据\n scheme_name: 方案名称,若为空则查询实时数据\n timescale_conn: TimescaleDB连接\n postgres_conn: PostgreSQL连接\n \nReturns:\n SCADA关联的模拟数据\n \nRaises:\n HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误", + "operationId": "get_timeseries_views_scada_simulations", + "parameters": [ + { + "description": "查询开始时间", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "查询开始时间", + "format": "date-time", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "查询结束时间", + "in": "query", + "name": "end_time", + "required": true, + "schema": { + "description": "查询结束时间", + "format": "date-time", + "title": "End Time", + "type": "string" + } + }, + { + "description": "SCADA设备ID列表,逗号分隔", + "in": "query", + "name": "device_ids", + "required": true, + "schema": { + "description": "SCADA设备ID列表,逗号分隔", + "title": "Device Ids", + "type": "string" + } + }, + { + "description": "方案类型,若为空则查询实时数据", + "in": "query", + "name": "scheme_type", + "required": false, + "schema": { + "description": "方案类型,若为空则查询实时数据", + "title": "Scheme Type", + "type": "string" + } + }, + { + "description": "方案名称,若为空则查询实时数据", + "in": "query", + "name": "scheme_name", + "required": false, + "schema": { + "description": "方案名称,若为空则查询实时数据", + "title": "Scheme Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA关联的模拟数据", + "tags": [ + "TimescaleDB - Composite" + ] + } + }, + "/api/v1/title-schemas": { + "get": { + "description": "获取指定水网的标题(标题)属性架构定义", + "operationId": "get_title_schemas", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "object" + }, + "title": "Response Get Title Schemas", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取标题属性架构", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/titles": { + "get": { + "description": "获取指定水网的标题(Title)信息", + "operationId": "get_titles", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Titles", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取水网标题属性", + "tags": [ + "Network General" + ] + }, + "patch": { + "description": "设置指定水网的标题(Title)信息", + "operationId": "patch_titles", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置水网标题属性", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/undos": { + "post": { + "description": "撤销网络上最后的一个操作", + "operationId": "post_undos", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "撤销操作", + "tags": [ + "Snapshots" + ] + } + }, + "/api/v1/users": { + "get": { + "description": "获取指定网络的所有用户列表", + "operationId": "get_users", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_Any__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有用户", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/detail": { + "get": { + "description": "获取指定网络中的单个用户信息", + "operationId": "get_users_detail", + "parameters": [ + { + "description": "用户名", + "in": "query", + "name": "user_name", + "required": true, + "schema": { + "description": "用户名", + "title": "User Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Users Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取单个用户", + "tags": [ + "Users" + ] + } + }, + "/api/v1/valve-closure-analyses": { + "post": { + "description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。", + "operationId": "post_valve_closure_analyses", + "parameters": [ + { + "description": "阀门关闭开始时间(ISO 8601格式)", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "阀门关闭开始时间(ISO 8601格式)", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "要关闭的阀门ID列表", + "in": "query", + "name": "valves", + "required": true, + "schema": { + "description": "要关闭的阀门ID列表", + "items": { + "type": "string" + }, + "title": "Valves", + "type": "array" + } + }, + { + "description": "模拟持续时间(秒),默认900秒", + "in": "query", + "name": "duration", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "模拟持续时间(秒),默认900秒", + "title": "Duration" + } + }, + { + "description": "阀门关闭方案名称", + "in": "query", + "name": "scheme_name", + "required": true, + "schema": { + "description": "阀门关闭方案名称", + "title": "Scheme Name", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "阀门关闭分析(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/valve-isolation-analyses": { + "post": { + "description": "分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。", + "operationId": "post_valve_isolation_analyses", + "parameters": [ + { + "description": "发生事故的管段/节点ID列表", + "in": "query", + "name": "accident_element", + "required": true, + "schema": { + "description": "发生事故的管段/节点ID列表", + "items": { + "type": "string" + }, + "title": "Accident Element", + "type": "array" + } + }, + { + "description": "已故障的阀门ID列表(可选)", + "in": "query", + "name": "disabled_valves", + "required": false, + "schema": { + "description": "已故障的阀门ID列表(可选)", + "items": { + "type": "string" + }, + "title": "Disabled Valves", + "type": "array" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "阀门隔离分析", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/valves": { + "delete": { + "description": "从指定的水网中删除指定的阀门", + "operationId": "delete_valves", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除阀门", + "tags": [ + "Valves" + ] + }, + "get": { + "description": "获取指定水网中所有阀门的属性", + "operationId": "get_valves", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有阀门属性", + "tags": [ + "Valves" + ] + }, + "post": { + "description": "在指定的水网中添加新的阀门", + "operationId": "post_valves", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "description": "起点节点ID", + "in": "query", + "name": "node1", + "required": true, + "schema": { + "description": "起点节点ID", + "title": "Node1", + "type": "string" + } + }, + { + "description": "终点节点ID", + "in": "query", + "name": "node2", + "required": true, + "schema": { + "description": "终点节点ID", + "title": "Node2", + "type": "string" + } + }, + { + "description": "阀门直径(mm)", + "in": "query", + "name": "diameter", + "required": false, + "schema": { + "default": 0, + "description": "阀门直径(mm)", + "title": "Diameter", + "type": "number" + } + }, + { + "description": "阀门类型", + "in": "query", + "name": "v_type", + "required": false, + "schema": { + "default": "PRV", + "description": "阀门类型", + "title": "V Type", + "type": "string" + } + }, + { + "description": "阀门开度/设置值", + "in": "query", + "name": "setting", + "required": false, + "schema": { + "default": 0, + "description": "阀门开度/设置值", + "title": "Setting", + "type": "number" + } + }, + { + "description": "损失系数", + "in": "query", + "name": "minor_loss", + "required": false, + "schema": { + "default": 0, + "description": "损失系数", + "title": "Minor Loss", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加阀门", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/diameter": { + "get": { + "description": "获取指定阀门的直径", + "operationId": "get_valves_diameter", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Valves Diameter" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门直径", + "tags": [ + "Valves" + ] + }, + "patch": { + "description": "设置指定阀门的直径", + "operationId": "patch_valves_diameter", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "description": "新的直径值(mm)", + "in": "query", + "name": "diameter", + "required": true, + "schema": { + "description": "新的直径值(mm)", + "title": "Diameter", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置阀门直径", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/existence": { + "get": { + "description": "检查指定ID是否为水网中的阀门", + "operationId": "get_valves_existence", + "parameters": [ + { + "description": "管线ID", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "管线ID", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Valves Existence", + "type": "boolean" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "检查是否为阀门", + "tags": [ + "Network General" + ] + } + }, + "/api/v1/valves/minor-loss": { + "get": { + "description": "获取指定阀门的损失系数", + "operationId": "get_valves_minor_loss", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Valves Minor Loss" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门损失系数", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/node1": { + "get": { + "description": "获取指定阀门连接的起点节点ID", + "operationId": "get_valves_node1", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Valves Node1" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门起点节点", + "tags": [ + "Valves" + ] + }, + "patch": { + "description": "设置指定阀门的起点节点", + "operationId": "patch_valves_node1", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "description": "新的起点节点ID", + "in": "query", + "name": "node1", + "required": true, + "schema": { + "description": "新的起点节点ID", + "title": "Node1", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置阀门起点节点", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/node2": { + "get": { + "description": "获取指定阀门连接的终点节点ID", + "operationId": "get_valves_node2", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Valves Node2" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门终点节点", + "tags": [ + "Valves" + ] + }, + "patch": { + "description": "设置指定阀门的终点节点", + "operationId": "patch_valves_node2", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "description": "新的终点节点ID", + "in": "query", + "name": "node2", + "required": true, + "schema": { + "description": "新的终点节点ID", + "title": "Node2", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置阀门终点节点", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/properties": { + "get": { + "description": "获取指定阀门的所有属性", + "operationId": "get_valves_properties", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Valves Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门所有属性", + "tags": [ + "Valves" + ] + }, + "patch": { + "description": "批量设置指定阀门的多个属性", + "operationId": "patch_valves_properties", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "批量设置阀门属性", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/setting": { + "get": { + "description": "获取指定阀门的开度/设置值", + "operationId": "get_valves_setting", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Get Valves Setting" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门开度", + "tags": [ + "Valves" + ] + }, + "patch": { + "description": "设置指定阀门的开度/设置值", + "operationId": "patch_valves_setting", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "description": "新的开度值", + "in": "query", + "name": "setting", + "required": true, + "schema": { + "description": "新的开度值", + "title": "Setting", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置阀门开度", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/valves/type": { + "get": { + "description": "获取指定阀门的类型", + "operationId": "get_valves_type", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Get Valves Type" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取阀门类型", + "tags": [ + "Valves" + ] + }, + "patch": { + "description": "设置指定阀门的类型", + "operationId": "patch_valves_type", + "parameters": [ + { + "description": "阀门ID", + "in": "query", + "name": "valve", + "required": true, + "schema": { + "description": "阀门ID", + "title": "Valve", + "type": "string" + } + }, + { + "description": "新的阀门类型", + "in": "query", + "name": "type", + "required": true, + "schema": { + "description": "新的阀门类型", + "title": "Type", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置阀门类型", + "tags": [ + "Valves" + ] + } + }, + "/api/v1/virtual-district-calculations": { + "post": { + "description": "根据指定的压力监测节点作为中心节点计算虚拟分区方案", + "operationId": "post_virtual_district_calculations", + "parameters": [ + { + "description": "压力监测节点ID列表", + "in": "query", + "name": "centers", + "required": true, + "schema": { + "description": "压力监测节点ID列表", + "items": { + "type": "string" + }, + "title": "Centers", + "type": "array" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "items": {}, + "type": "array" + }, + "title": "Response Post Virtual District Calculations", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "计算虚拟分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/virtual-district-generation-runs": { + "post": { + "description": "根据参数自动生成虚拟分区方案", + "operationId": "post_virtual_district_generation_runs", + "parameters": [ + { + "description": "膨胀参数", + "in": "query", + "name": "inflate_delta", + "required": true, + "schema": { + "description": "膨胀参数", + "title": "Inflate Delta", + "type": "number" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "生成虚拟分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/virtual-districts": { + "delete": { + "description": "删除指定的虚拟分区", + "operationId": "delete_virtual_districts", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除虚拟分区", + "tags": [ + "Regions & DMAs" + ] + }, + "get": { + "description": "获取指定水网中的所有虚拟分区信息", + "operationId": "get_virtual_districts", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_dict_str__Any__" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有虚拟分区", + "tags": [ + "Regions & DMAs" + ] + }, + "patch": { + "description": "修改指定虚拟分区的属性信息", + "operationId": "patch_virtual_districts", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置虚拟分区属性", + "tags": [ + "Regions & DMAs" + ] + }, + "post": { + "description": "向水网添加一个新的虚拟分区", + "operationId": "post_virtual_districts", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加新虚拟分区", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/virtual-districts/detail": { + "get": { + "description": "获取指定ID的虚拟分区详细信息", + "operationId": "get_virtual_districts_detail", + "parameters": [ + { + "description": "虚拟分区ID", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "虚拟分区ID", + "title": "Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Virtual Districts Detail", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取虚拟分区信息", + "tags": [ + "Regions & DMAs" + ] + } + }, + "/api/v1/visual-elements": { + "delete": { + "description": "从网络中删除指定的图形元素", + "operationId": "delete_visual_elements", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "删除图形元素", + "tags": [ + "Visuals" + ] + }, + "post": { + "description": "在网络中添加一个新的图形元素", + "operationId": "post_visual_elements", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "添加图形元素", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/visual-elements/links": { + "get": { + "description": "获取网络中的所有图形元素链接列表", + "operationId": "get_visual_elements_links", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取所有图形元素链接", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/visual-elements/properties": { + "get": { + "description": "获取指定图形元素的属性信息", + "operationId": "get_visual_elements_properties", + "parameters": [ + { + "description": "图形元素链接", + "in": "query", + "name": "link", + "required": true, + "schema": { + "description": "图形元素链接", + "title": "Link", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Get Visual Elements Properties", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取图形元素属性", + "tags": [ + "Visuals" + ] + }, + "patch": { + "description": "更新指定图形元素的属性", + "operationId": "patch_visual_elements_properties", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "设置图形元素属性", + "tags": [ + "Visuals" + ] + } + }, + "/api/v1/water-age-analyses": { + "post": { + "description": "高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。", + "operationId": "post_water_age_analyses", + "parameters": [ + { + "description": "分析开始时间(ISO 8601格式)", + "in": "query", + "name": "start_time", + "required": true, + "schema": { + "description": "分析开始时间(ISO 8601格式)", + "title": "Start Time", + "type": "string" + } + }, + { + "description": "模拟持续时间(秒)", + "in": "query", + "name": "duration", + "required": true, + "schema": { + "description": "模拟持续时间(秒)", + "title": "Duration", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "水龄分析(高级)", + "tags": [ + "Simulation Control" + ] + } + }, + "/api/v1/web-searches": { + "post": { + "description": "调用 Bocha Web Search API 获取实时网页搜索结果", + "operationId": "post_web_searches", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Post Web Searches", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Web Search", + "tags": [ + "Web Search" + ] + } + }, + "/api/v1/with-servers": { + "post": { + "description": "将网络与服务器同步到指定操作", + "operationId": "post_with_servers", + "parameters": [ + { + "description": "目标操作ID", + "in": "query", + "name": "operation", + "required": true, + "schema": { + "description": "目标操作ID", + "title": "Operation", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "与服务器同步", + "tags": [ + "Snapshots" + ] + } + } + } +} diff --git a/docs/api-style.md b/docs/api-style.md new file mode 100644 index 0000000..89351e2 --- /dev/null +++ b/docs/api-style.md @@ -0,0 +1,22 @@ +# TJWater REST API v1 + +This contract is the public API contract for coordinated TJWater +Server, Agent, Frontend, and CLI releases. + +- Paths use lowercase kebab-case, have no trailing slash, and identify + resources rather than handler actions. +- JSON fields, query parameters, and path parameter names use snake_case. +- Project-scoped requests use `X-Project-Id`; `network` query parameters + are not part of the public contract. +- `GET` is read-only. Synchronous analysis and simulation requests use + `POST` and clients must not retry them automatically. +- JSON errors use `application/problem+json`. +- The static OpenAPI file and `contracts/manifest.json` are release + artifacts even when production runtime documentation is disabled. + +Generate and validate the contract with: + +```bash +conda run -n server python scripts/export_openapi.py +conda run -n server python scripts/check_openapi.py +``` diff --git a/infra/docker/keycloak/README.md b/infra/docker/keycloak/README.md new file mode 100644 index 0000000..fab9ddd --- /dev/null +++ b/infra/docker/keycloak/README.md @@ -0,0 +1,104 @@ +# Keycloak 登录主题 + +`themes/tjwater` 是 TJWater 智慧水务平台的 Keycloak 登录主题。主题继承 +`keycloak.v2`,只覆盖样式、消息和本地 SVG 资源,不修改认证模板或认证流程。 + +## 在管理控制台切换登录主题 + +确认 `themes/tjwater` 已挂载到容器的 +`/opt/keycloak/themes/tjwater`,然后按以下步骤切换: + +1. 打开 Keycloak 管理控制台: + `http://:<端口>/admin/`。 +2. 使用管理员账号登录。 +3. 在左上角选择需要应用主题的 realm,例如 `tjwater`。不要停留在 + `master`,除非确实要修改 `master` realm。 +4. 在左侧菜单进入 `Realm settings`,打开 `Themes` 标签页。 +5. 在 `Login theme` 下拉框中选择 `tjwater`。 +6. 点击 `Save` 保存。 +7. 继续检查业务客户端是否单独指定了登录主题,再从业务前端重新进入登录页。 + +切回 Keycloak 默认登录页时,将 `Login theme` 改为 `keycloak` 并保存。 + +### 检查业务客户端的主题配置 + +Keycloak 的 realm 和 client 都可以设置登录主题。client 的配置优先于 realm。 +因此,即使 `Realm settings > Themes > Login theme` 已选择 `tjwater`,业务 +客户端如果仍指定 `keycloak`,从业务系统跳转后看到的还是默认登录页。 + +以授权地址中包含 `client_id=tjwater` 的业务系统为例: + +1. 确认左上角当前 realm 是 `tjwater`。 +2. 在左侧菜单进入 `Clients`。 +3. 打开 `Client ID` 为 `tjwater` 的客户端。 +4. 在 `Settings` 页面找到 `Login settings > Login theme`。 +5. 将该字段设置为以下任一选项: + - `Choose...`:不在 client 层指定主题,继承 realm 的 `tjwater` 主题, + 推荐使用此方式。 + - `tjwater`:在 client 层明确指定 `tjwater` 主题。 +6. 不要保留 `keycloak`,否则它会覆盖 realm 的主题。 +7. 点击 `Save`,关闭旧登录页,再从业务前端重新发起一次登录。 + +`Choose...` 不是未配置完成,而是表示当前 client 继承 realm 配置。管理控制台 +登录、账户中心和业务系统可能使用不同的 client。某一个入口已经显示 +`tjwater` 主题,并不能证明业务 client 也已正确配置。 + +验证时以业务系统实际生成的 OpenID Connect 授权地址为准,并检查其中的 +`client_id`。浏览器加载的主题资源路径应包含 +`/resources/<版本>/login/tjwater/`;如果路径仍包含 +`/resources/<版本>/login/keycloak/`,说明该 client 仍在使用默认主题。 + +如果 `Login theme` 下拉框中没有 `tjwater`,先检查容器内的主题文件: + +```bash +docker compose \ + --env-file .env \ + -f infra/docker/docker-compose.yml \ + exec -T keycloak \ + test -f /opt/keycloak/themes/tjwater/login/theme.properties +``` + +命令成功但控制台仍未显示主题时,重新创建 Keycloak 容器后再检查: + +```bash +docker compose \ + --env-file .env \ + -f infra/docker/docker-compose.yml \ + up -d --force-recreate keycloak +``` + +主题名称已经正确,但页面仍显示旧样式时,也执行上述命令,并在容器启动后使用 +`Ctrl+F5` 强制刷新登录页,避免继续使用浏览器缓存的 CSS。 + +## 启用 + +先启动 `infra/docker/docker-compose.yml` 中的 Keycloak,再从仓库根目录执行: + +```bash +bash infra/docker/keycloak/configure-theme.sh apply +``` + +脚本默认配置 `tjwater` realm、简体中文默认语言、中英文切换和 +`TJWater 智慧水务平台` 品牌名,并清除 `tjwater` client 对登录主题的覆盖, +使其继承 realm 主题。其他环境可临时覆盖: + +```bash +TJWATER_KEYCLOAK_REALM=example \ +TJWATER_KEYCLOAK_CLIENT_ID=example-web \ +TJWATER_KEYCLOAK_DISPLAY_NAME="示例智慧水务平台" \ +bash infra/docker/keycloak/configure-theme.sh apply +``` + +管理员凭据继续使用 Compose 已注入的 `KC_BOOTSTRAP_ADMIN_USERNAME` / +`KC_BOOTSTRAP_ADMIN_PASSWORD`,并兼容现有的 `KEYCLOAK_ADMIN` / +`KEYCLOAK_ADMIN_PASSWORD`。 + +## 验证与回滚 + +```bash +bash infra/docker/keycloak/configure-theme.sh verify +bash infra/docker/keycloak/configure-theme.sh rollback +``` + +使用 `latest` 镜像时,每次重新拉取 Keycloak 后都应重新执行 `verify`,并在 +1280px、375px 和 320px 视口检查登录、错误提示、忘记密码和 OTP 页面。 diff --git a/infra/docker/keycloak/configure-theme.sh b/infra/docker/keycloak/configure-theme.sh new file mode 100644 index 0000000..2f8f6eb --- /dev/null +++ b/infra/docker/keycloak/configure-theme.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +action="${1:-apply}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/../../.." && pwd)" +compose_file="${repo_root}/infra/docker/docker-compose.yml" +realm="${TJWATER_KEYCLOAK_REALM:-tjwater}" +client_id="${TJWATER_KEYCLOAK_CLIENT_ID:-tjwater}" +display_name="${TJWATER_KEYCLOAK_DISPLAY_NAME:-TJWater 智慧水务平台}" + +case "${action}" in + apply|verify|rollback) ;; + *) + echo "用法: bash infra/docker/keycloak/configure-theme.sh [apply|verify|rollback]" >&2 + exit 2 + ;; +esac + +compose_args=(docker compose) +if [[ -f "${repo_root}/.env" ]]; then + compose_args+=(--env-file "${repo_root}/.env") +fi +compose_args+=(-f "${compose_file}") + +"${compose_args[@]}" exec -T \ + -e TJWATER_KEYCLOAK_ACTION="${action}" \ + -e TJWATER_KEYCLOAK_REALM="${realm}" \ + -e TJWATER_KEYCLOAK_CLIENT_ID="${client_id}" \ + -e TJWATER_KEYCLOAK_DISPLAY_NAME="${display_name}" \ + keycloak sh -s <<'KEYCLOAK_SCRIPT' +set -eu + +action="${TJWATER_KEYCLOAK_ACTION}" +realm="${TJWATER_KEYCLOAK_REALM}" +client_id="${TJWATER_KEYCLOAK_CLIENT_ID}" +display_name="${TJWATER_KEYCLOAK_DISPLAY_NAME}" +server_url="${TJWATER_KEYCLOAK_SERVER_URL:-http://127.0.0.1:8080}" +admin_user="${KC_BOOTSTRAP_ADMIN_USERNAME:-${KEYCLOAK_ADMIN:-}}" +admin_password="${KC_BOOTSTRAP_ADMIN_PASSWORD:-${KEYCLOAK_ADMIN_PASSWORD:-}}" +config_file="/tmp/tjwater-kcadm-$$.config" +kcadm="/opt/keycloak/bin/kcadm.sh" + +cleanup() { + rm -f "${config_file}" +} +trap cleanup EXIT + +if [ -z "${admin_user}" ] || [ -z "${admin_password}" ]; then + echo "缺少 Keycloak 管理员用户名或密码环境变量。" >&2 + exit 1 +fi + +if [ "${action}" = "apply" ] && [ ! -f /opt/keycloak/themes/tjwater/login/theme.properties ]; then + echo "未找到 tjwater 登录主题,请检查主题目录挂载。" >&2 + exit 1 +fi + +"${kcadm}" config credentials \ + --config "${config_file}" \ + --server "${server_url}" \ + --realm master \ + --user "${admin_user}" \ + --password "${admin_password}" >/dev/null + +client_uuid="$( + "${kcadm}" get clients \ + --config "${config_file}" \ + --target-realm "${realm}" \ + --query "clientId=${client_id}" \ + --fields id \ + --format csv \ + --noquotes | + sed -n '1p' +)" + +if [ -z "${client_uuid}" ]; then + echo "realm ${realm} 中未找到 client ${client_id}。" >&2 + echo "可通过 TJWATER_KEYCLOAK_CLIENT_ID 指定实际的 client ID。" >&2 + exit 1 +fi + +case "${action}" in + apply) + "${kcadm}" update "realms/${realm}" \ + --config "${config_file}" \ + -s "displayName=${display_name}" \ + -s "displayNameHtml=${display_name}" \ + -s "loginTheme=tjwater" \ + -s "internationalizationEnabled=true" \ + -s 'supportedLocales=["zh-CN","en"]' \ + -s "defaultLocale=zh-CN" >/dev/null + "${kcadm}" update "clients/${client_uuid}" \ + --config "${config_file}" \ + --target-realm "${realm}" \ + --set attributes.login_theme= >/dev/null + echo "已为 realm ${realm} 启用 tjwater 登录主题。" + echo "client ${client_id} 已改为继承 realm 登录主题。" + ;; + rollback) + "${kcadm}" update "realms/${realm}" \ + --config "${config_file}" \ + -s "loginTheme=keycloak" >/dev/null + echo "已将 realm ${realm} 恢复为 Keycloak 默认登录主题。" + ;; +esac + +"${kcadm}" get "realms/${realm}" \ + --config "${config_file}" \ + --fields realm,displayName,loginTheme,internationalizationEnabled,supportedLocales,defaultLocale +"${kcadm}" get "clients/${client_uuid}" \ + --config "${config_file}" \ + --target-realm "${realm}" \ + --fields 'clientId,attributes(login_theme)' +KEYCLOAK_SCRIPT diff --git a/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties b/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties new file mode 100644 index 0000000..b7790c0 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties @@ -0,0 +1,3 @@ +loginAccountTitle=Account sign in +doLogIn=Sign in +doForgotPassword=Forgot password diff --git a/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties b/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties new file mode 100644 index 0000000..30bd332 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties @@ -0,0 +1,9 @@ +loginAccountTitle=账号登录 +usernameOrEmail=用户名或邮箱 +doLogIn=登录 +doForgotPassword=忘记密码 +rememberMe=记住我 +invalidUserMessage=用户名或密码错误 +invalidUsernameOrPasswordMessage=用户名或密码错误 +expiredCodeMessage=登录已超时,请重新登录 +loginTimeout=登录已超时,请重新开始登录 diff --git a/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css b/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css new file mode 100644 index 0000000..9d84925 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css @@ -0,0 +1,535 @@ +:root { + --tjwater-canvas: oklch(0.965 0.014 205); + --tjwater-surface: oklch(0.995 0.004 205); + --tjwater-surface-soft: oklch(0.982 0.008 205); + --tjwater-ink: oklch(0.3 0.055 215); + --tjwater-muted: oklch(0.52 0.035 215); + --tjwater-line: oklch(0.86 0.025 210); + --tjwater-blue: oklch(0.57 0.16 242); + --tjwater-blue-dark: oklch(0.49 0.15 242); + --tjwater-teal: oklch(0.58 0.12 180); + --tjwater-danger: oklch(0.55 0.19 27); + --tjwater-radius-sm: 6px; + --tjwater-radius-md: 12px; + --tjwater-radius-lg: 18px; +} + +html.login-pf { + height: 100%; + min-height: 100%; + overflow-x: hidden; + background: var(--tjwater-canvas); +} + +body#keycloak-bg, +.login-pf body { + min-height: 100%; + margin: 0; + padding: 0; + color: var(--tjwater-ink); + background: var(--tjwater-canvas); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", + "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.pf-v5-c-login, +.pf-v5-c-login * { + box-sizing: border-box; +} + +.pf-v5-c-login { + min-height: 100svh; + padding: 0; + background-color: var(--tjwater-canvas); + background-image: + linear-gradient( + 90deg, + transparent 0%, + transparent 50%, + oklch(0.975 0.01 205 / 62%) 68%, + oklch(0.975 0.01 205 / 82%) 100% + ), + url("../img/network-blueprint.svg"); + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +.pf-v5-c-login__container { + display: grid; + width: 100%; + max-width: 1720px; + min-height: 100svh; + margin: 0 auto; + padding: clamp(32px, 4.5vw, 76px) clamp(40px, 5vw, 88px); + grid-template-columns: minmax(360px, 1fr) minmax(400px, 460px); + grid-template-areas: "header main"; + align-items: center; + gap: clamp(64px, 8vw, 152px); +} + +#kc-header { + position: relative; + z-index: 0; + grid-area: header; + width: fit-content; + max-width: 100%; + align-self: center; + justify-self: start; + margin: 0; + padding: 0; + isolation: isolate; + animation: tjwater-enter 480ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +#kc-header::before { + position: absolute; + z-index: -1; + inset: -54px -72px; + background: radial-gradient( + ellipse at center, + oklch(0.925 0.018 205 / 98%) 0%, + oklch(0.925 0.018 205 / 94%) 48%, + oklch(0.925 0.018 205 / 62%) 65%, + transparent 82% + ); + pointer-events: none; + content: ""; +} + +#kc-header-wrapper { + display: flex; + max-width: 680px; + margin: 0; + padding: 0; + flex-direction: column; + align-items: flex-start; + color: var(--tjwater-ink) !important; + font-size: clamp(34px, 3vw, 46px); + font-weight: 720; + line-height: 1.28; + letter-spacing: 0; + text-align: left; + text-transform: none; + text-wrap: balance; +} + +#kc-header-wrapper::before { + width: 56px; + height: 56px; + margin-bottom: 24px; + background: url("../img/logo-mark.svg") center / contain no-repeat; + content: ""; +} + +#kc-header-wrapper::after { + width: 64px; + height: 3px; + margin-top: 26px; + border-radius: 999px; + background: var(--tjwater-teal); + content: ""; +} + +.pf-v5-c-login__main { + grid-area: main; + width: 100%; + max-width: 460px; + margin: 0; + align-self: center; + justify-self: stretch; + overflow: hidden; + border: 0; + border-radius: var(--tjwater-radius-lg); + background: oklch(0.995 0.004 205 / 97%); + box-shadow: + 0 32px 80px rgb(22 65 75 / 16%), + 0 5px 18px rgb(22 65 75 / 9%); + animation: tjwater-enter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.pf-v5-c-login__main-header { + display: grid; + margin: 0; + padding: 36px 36px 18px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 20px; + align-items: center; + border-top: 0; +} + +#kc-page-title { + margin: 0; + color: var(--tjwater-ink); + font-size: 26px; + font-weight: 720; + line-height: 1.4; + letter-spacing: 0; + text-wrap: balance; +} + +.pf-v5-c-login__main-header-utilities { + margin: 0; +} + +.pf-v5-c-login__main-body { + margin: 0; + padding: 0 36px 38px; +} + +.pf-v5-c-form { + gap: 20px; +} + +.pf-v5-c-form__group { + margin: 0; +} + +.pf-v5-c-form__group-label { + padding-bottom: 8px; +} + +.pf-v5-c-form__label-text { + color: var(--tjwater-ink); + font-size: 14px; + font-weight: 650; + line-height: 1.6; +} + +.pf-v5-c-form-control { + min-height: 48px; + overflow: hidden; + border: 1px solid var(--tjwater-line); + border-radius: var(--tjwater-radius-sm); + background: var(--tjwater-surface-soft); + box-shadow: none; + transition-property: border-color, box-shadow, background-color; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.pf-v5-c-form-control::before, +.pf-v5-c-form-control::after { + border: 0; +} + +.pf-v5-c-form-control:focus-within { + border-color: var(--tjwater-blue); + background: var(--tjwater-surface); + box-shadow: 0 0 0 3px oklch(0.78 0.1 235 / 28%); +} + +.pf-v5-c-form-control > input, +.pf-v5-c-form-control > select { + min-height: 46px; + padding-inline: 14px; + color: var(--tjwater-ink); + font-size: 16px; + outline: 0; +} + +.pf-v5-c-login__main-header-utilities .pf-v5-c-form-control { + width: 116px; + min-height: 40px; + background: var(--tjwater-surface); +} + +#login-select-toggle { + width: 100%; + min-width: 0; + min-height: 38px; + padding-inline: 12px 32px; + color: var(--tjwater-muted); + font-size: 14px; + cursor: pointer; +} + +.pf-v5-c-form-control.pf-m-error { + border-color: var(--tjwater-danger); +} + +.pf-v5-c-input-group { + gap: 8px; +} + +.pf-v5-c-input-group__item.pf-m-fill { + min-width: 0; +} + +.pf-v5-c-button.pf-m-control { + min-width: 48px; + min-height: 48px; + border: 1px solid var(--tjwater-line); + border-radius: var(--tjwater-radius-sm); + color: var(--tjwater-muted); + background: var(--tjwater-surface-soft); + touch-action: manipulation; + transition-property: color, border-color, background-color, transform; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.pf-v5-c-button.pf-m-control:active { + transform: scale(0.96); +} + +.pf-v5-c-button.pf-m-control:focus-visible, +.pf-v5-c-button.pf-m-primary:focus-visible, +.pf-v5-c-button.pf-m-secondary:focus-visible, +a:focus-visible { + outline: 3px solid oklch(0.74 0.12 235 / 60%); + outline-offset: 2px; +} + +.pf-v5-c-form__helper-text { + margin-top: 8px; +} + +.pf-v5-c-helper-text { + min-height: 22px; +} + +.pf-v5-c-helper-text__item-text { + color: var(--tjwater-muted); + line-height: 1.7; +} + +.pf-v5-c-helper-text__item-text a, +#kc-registration a, +.pf-v5-c-login__main-footer a { + color: var(--tjwater-blue-dark); + font-weight: 600; + text-decoration: none; + text-underline-offset: 3px; +} + +.kc-feedback-text.pf-m-error, +.pf-v5-c-helper-text__item.pf-m-error .kc-feedback-text { + color: var(--tjwater-danger); +} + +.pf-v5-c-check__input { + accent-color: var(--tjwater-blue); +} + +.pf-v5-c-check__label { + color: var(--tjwater-muted); + line-height: 1.7; +} + +.pf-v5-c-form__actions { + padding-top: 6px; +} + +.pf-v5-c-button.pf-m-primary { + min-height: 48px; + border: 0; + border-radius: var(--tjwater-radius-md); + color: oklch(0.99 0.004 230); + background: var(--tjwater-blue); + font-size: 16px; + font-weight: 700; + box-shadow: 0 8px 18px oklch(0.48 0.15 242 / 20%); + touch-action: manipulation; + transition-property: transform, background-color, box-shadow; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.pf-v5-c-button.pf-m-primary:active { + transform: scale(0.96); + background: var(--tjwater-blue-dark); + box-shadow: 0 4px 10px oklch(0.48 0.15 242 / 18%); +} + +.pf-v5-c-button.pf-m-secondary { + min-height: 44px; + border-radius: var(--tjwater-radius-md); + color: var(--tjwater-blue-dark); + border-color: var(--tjwater-line); +} + +.pf-v5-c-alert { + border-radius: var(--tjwater-radius-md); +} + +.pf-v5-c-login__main-footer { + color: var(--tjwater-muted); + line-height: 1.7; +} + +.pf-v5-c-login__main-footer-band { + margin-top: 26px; + padding: 18px 0 0; + border-top: 1px solid var(--tjwater-line); + background: transparent; +} + +@keyframes tjwater-enter { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (hover: hover) { + .pf-v5-c-button.pf-m-primary:hover { + background: var(--tjwater-blue-dark); + box-shadow: 0 10px 22px oklch(0.48 0.15 242 / 25%); + transform: translateY(-1px); + } + + .pf-v5-c-button.pf-m-control:hover { + color: var(--tjwater-blue-dark); + border-color: oklch(0.69 0.08 230); + background: var(--tjwater-surface); + } + + .pf-v5-c-helper-text__item-text a:hover, + #kc-registration a:hover, + .pf-v5-c-login__main-footer a:hover { + text-decoration: underline; + } +} + +@media (max-width: 900px) { + .pf-v5-c-login { + background-image: + linear-gradient(oklch(0.965 0.014 205 / 34%), oklch(0.965 0.014 205 / 34%)), + url("../img/network-blueprint.svg"); + background-position: 34% center; + } + + .pf-v5-c-login__container { + max-width: 560px; + padding: + max(28px, env(safe-area-inset-top)) + max(24px, env(safe-area-inset-right)) + max(32px, env(safe-area-inset-bottom)) + max(24px, env(safe-area-inset-left)); + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "header" + "main"; + align-content: center; + gap: 24px; + } + + #kc-header-wrapper { + max-width: none; + flex-direction: row; + align-items: center; + gap: 14px; + font-size: clamp(22px, 5vw, 28px); + line-height: 1.4; + } + + #kc-header::before { + inset: -24px -20px; + background: radial-gradient( + ellipse at center, + oklch(0.965 0.014 205 / 98%) 0%, + oklch(0.965 0.014 205 / 88%) 58%, + transparent 84% + ); + } + + #kc-header-wrapper::before { + width: 46px; + height: 46px; + margin: 0; + flex: 0 0 46px; + } + + #kc-header-wrapper::after { + display: none; + } + + .pf-v5-c-login__main { + max-width: none; + } +} + +@media (max-width: 520px) { + .pf-v5-c-login__container { + gap: 18px; + padding-inline: + max(14px, env(safe-area-inset-left)) + max(14px, env(safe-area-inset-right)); + } + + #kc-header-wrapper { + gap: 12px; + font-size: 21px; + } + + #kc-header-wrapper::before { + width: 42px; + height: 42px; + flex-basis: 42px; + } + + .pf-v5-c-login__main { + border-radius: 16px; + } + + .pf-v5-c-login__main-header { + gap: 12px; + padding: 26px 22px 15px; + } + + #kc-page-title { + font-size: 23px; + } + + .pf-v5-c-login__main-header-utilities .pf-v5-c-form-control { + width: 108px; + } + + .pf-v5-c-login__main-body { + padding: 0 22px 28px; + } +} + +@media (max-height: 680px) and (min-width: 901px) { + .pf-v5-c-login__container { + padding-block: 24px; + } + + #kc-header-wrapper::before { + width: 48px; + height: 48px; + margin-bottom: 18px; + } + + #kc-header-wrapper::after { + margin-top: 20px; + } + + .pf-v5-c-login__main-header { + padding-top: 28px; + } + + .pf-v5-c-login__main-body { + padding-bottom: 30px; + } +} + +@media (prefers-reduced-motion: reduce) { + #kc-header, + .pf-v5-c-login__main { + animation: none; + } + + .pf-v5-c-button, + .pf-v5-c-form-control { + transition-duration: 0.01ms; + } +} diff --git a/infra/docker/keycloak/themes/tjwater/login/resources/img/logo-mark.svg b/infra/docker/keycloak/themes/tjwater/login/resources/img/logo-mark.svg new file mode 100644 index 0000000..2fc0336 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/resources/img/logo-mark.svg @@ -0,0 +1,8 @@ + + TJWater + + + + + + diff --git a/infra/docker/keycloak/themes/tjwater/login/resources/img/network-blueprint.svg b/infra/docker/keycloak/themes/tjwater/login/resources/img/network-blueprint.svg new file mode 100644 index 0000000..52038f1 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/resources/img/network-blueprint.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/infra/docker/keycloak/themes/tjwater/login/resources/js/locale-labels.js b/infra/docker/keycloak/themes/tjwater/login/resources/js/locale-labels.js new file mode 100644 index 0000000..259f93f --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/resources/js/locale-labels.js @@ -0,0 +1,21 @@ +const localizeLocaleOptions = () => { + const localeSelect = document.querySelector("#login-select-toggle"); + + if (!(localeSelect instanceof HTMLSelectElement)) return; + + for (const option of localeSelect.options) { + const optionUrl = new URL(option.value, window.location.origin); + const locale = optionUrl.searchParams.get("kc_locale"); + + if (locale === "zh-CN") option.textContent = "简体中文"; + if (locale === "en") option.textContent = "English"; + } +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", localizeLocaleOptions, { + once: true, + }); +} else { + localizeLocaleOptions(); +} diff --git a/infra/docker/keycloak/themes/tjwater/login/theme.properties b/infra/docker/keycloak/themes/tjwater/login/theme.properties new file mode 100644 index 0000000..d00d8b0 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/theme.properties @@ -0,0 +1,7 @@ +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css css/tjwater-login.css +scripts=js/locale-labels.js +locales=zh-CN,en +darkMode=false diff --git a/scripts/check_openapi.py b/scripts/check_openapi.py new file mode 100644 index 0000000..f01b970 --- /dev/null +++ b/scripts/check_openapi.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"} +KEBAB_SEGMENT = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +SNAKE_PARAMETER = re.compile(r"^[a-z][a-z0-9_]*$") + + +def canonical_json(document: dict[str, Any]) -> bytes: + return ( + json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + + b"\n" + ) + + +def current_contract_bytes() -> bytes: + os.environ.setdefault("ENVIRONMENT", "development") + from app.main import app + + document = app.openapi() + document["info"]["version"] = "1.0.0" + return canonical_json(document) + + +def _iter_operations(document: dict[str, Any]): + for path, path_item in document.get("paths", {}).items(): + for method, operation in path_item.items(): + if method in HTTP_METHODS and isinstance(operation, dict): + yield path, method, operation + + +def validate(document: dict[str, Any]) -> list[str]: + errors: list[str] = [] + operation_ids: set[str] = set() + + for path, method, operation in _iter_operations(document): + if path != path.rstrip("/"): + errors.append(f"{method.upper()} {path}: trailing slash") + if "//" in path: + errors.append(f"{method.upper()} {path}: double slash") + for segment in path.split("/"): + if not segment or (segment.startswith("{") and segment.endswith("}")): + continue + if not KEBAB_SEGMENT.fullmatch(segment): + errors.append(f"{method.upper()} {path}: non-kebab segment {segment!r}") + + operation_id = operation.get("operationId") + if not operation_id: + errors.append(f"{method.upper()} {path}: missing operationId") + elif operation_id in operation_ids: + errors.append(f"{method.upper()} {path}: duplicate operationId {operation_id}") + else: + operation_ids.add(operation_id) + + if not operation.get("tags"): + errors.append(f"{method.upper()} {path}: missing tags") + if not operation.get("summary"): + errors.append(f"{method.upper()} {path}: missing summary") + for parameter in operation.get("parameters", []): + if ( + parameter.get("in") in {"query", "path"} + and not SNAKE_PARAMETER.fullmatch(str(parameter.get("name", ""))) + ): + errors.append( + f"{method.upper()} {path}: non-snake parameter " + f"{parameter.get('name')!r}" + ) + + success_responses = [ + (status, response) + for status, response in operation.get("responses", {}).items() + if str(status).startswith("2") + ] + if not success_responses: + errors.append(f"{method.upper()} {path}: missing success response") + for status, response in success_responses: + if str(status) == "204": + continue + if "content" not in response: + errors.append(f"{method.upper()} {path}: success response has no content schema") + for media in response.get("content", {}).values(): + if media.get("schema") == {}: + errors.append(f"{method.upper()} {path}: empty success schema") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate TJWater REST OpenAPI invariants") + parser.add_argument( + "contract", + nargs="?", + type=Path, + default=Path("contracts/server-v1.openapi.json"), + ) + parser.add_argument( + "--manifest", + type=Path, + default=Path("contracts/manifest.json"), + ) + args = parser.parse_args() + + raw = args.contract.read_bytes() + document = json.loads(raw) + errors = validate(document) + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + expected_hash = manifest["contracts"]["server"]["sha256"] + actual_hash = hashlib.sha256(raw).hexdigest() + if expected_hash != actual_hash: + errors.append( + f"contract hash mismatch: manifest={expected_hash}, actual={actual_hash}" + ) + current = current_contract_bytes() + if raw != current: + errors.append( + "contract is stale: run " + "`python scripts/export_openapi.py` and commit the regenerated files" + ) + + if errors: + print("\n".join(f"- {error}" for error in errors)) + return 1 + print( + f"validated {len(document['paths'])} paths; " + f"sha256={actual_hash}; version={document['info']['version']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..256eeff --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from pathlib import Path +from typing import Any + + +def _canonical_json(document: dict[str, Any]) -> bytes: + return ( + json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + + b"\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Export the TJWater REST v1 OpenAPI contract") + parser.add_argument( + "--output", + type=Path, + default=Path("contracts/server-v1.openapi.json"), + ) + parser.add_argument( + "--manifest", + type=Path, + default=Path("contracts/manifest.json"), + ) + args = parser.parse_args() + + os.environ.setdefault("ENVIRONMENT", "development") + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + from app.main import app + + document = app.openapi() + document["info"]["version"] = "1.0.0" + payload = _canonical_json(document) + digest = hashlib.sha256(payload).hexdigest() + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.manifest.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(payload) + args.manifest.write_bytes( + _canonical_json( + { + "contract_version": "1.0.0", + "contracts": { + "server": { + "file": args.output.name, + "sha256": digest, + } + }, + } + ) + ) + print(f"exported {len(document['paths'])} paths to {args.output} ({digest})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/api/test_access_endpoints.py b/tests/api/test_access_endpoints.py index 71c9604..ad09ae8 100644 --- a/tests/api/test_access_endpoints.py +++ b/tests/api/test_access_endpoints.py @@ -34,7 +34,7 @@ def test_access_context_returns_global_admin_permissions_without_project(): repo = SimpleNamespace() client = _build_client(user, repo) - response = client.get("/api/v1/access/context") + response = client.get("/api/v1/access-context") assert response.status_code == 200 payload = response.json() @@ -64,7 +64,7 @@ def test_access_context_returns_project_member_permissions(): client = _build_client(user, repo) response = client.get( - "/api/v1/access/context", + "/api/v1/access-context", headers={"X-Project-Id": str(project_id)}, ) diff --git a/tests/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py index 214fd93..088e263 100644 --- a/tests/api/test_agent_auth_endpoints.py +++ b/tests/api/test_agent_auth_endpoints.py @@ -41,7 +41,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): ), ) - response = client.get("/api/v1/agent/auth/context") + response = client.get("/api/v1/agent-auth-context") assert response.status_code == 200 assert response.json() == { @@ -90,7 +90,7 @@ def test_agent_auth_context_propagates_project_auth_failures(): app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400} client = TestClient(app) - response = client.get("/api/v1/agent/auth/context") + response = client.get("/api/v1/agent-auth-context") assert response.status_code == 403 assert response.json()["detail"] == "No access to project" diff --git a/tests/api/test_api_integration.py b/tests/api/test_api_integration.py index 27ec1eb..b9eb9cb 100755 --- a/tests/api/test_api_integration.py +++ b/tests/api/test_api_integration.py @@ -48,9 +48,9 @@ def test_router_configuration(): routes = [r.path for r in api_router.routes if hasattr(r, "path")] # 验证基础路径是否存在 - assert any("/agent/auth/context" in r for r in routes), "缺少 Agent 认证上下文路由" - assert any("/meta" in r for r in routes), "缺少 Metadata 路由" - assert any("/audit" in r for r in routes), "缺少审计日志路由 (/audit)" + assert "/agent-auth-context" in routes, "缺少 Agent 认证上下文路由" + assert "/projects/current/metadata" in routes, "缺少 Metadata 路由" + assert "/audit-logs" in routes, "缺少审计日志路由" except Exception as e: pytest.fail(f"路由配置检查失败: {e}") diff --git a/tests/api/test_audit_endpoints.py b/tests/api/test_audit_endpoints.py index 5a7c598..2cd9a65 100644 --- a/tests/api/test_audit_endpoints.py +++ b/tests/api/test_audit_endpoints.py @@ -17,7 +17,7 @@ def _build_client( metadata_admin=None, metadata_user=None, ) -> TestClient: - app = build_test_app(audit_endpoint.router, "/audit") + app = build_test_app(audit_endpoint.router, "/api/v1") app.dependency_overrides[audit_endpoint.get_audit_repository] = lambda: repo if metadata_admin is not None: app.dependency_overrides[get_current_metadata_admin] = lambda: metadata_admin @@ -38,7 +38,7 @@ def test_get_audit_logs_passes_filters(): client = _build_client(repo, metadata_admin=object()) response = client.get( - "/audit/logs", + "/api/v1/audit-logs", params={ "action": "LOGIN", "resource_type": "user", @@ -68,7 +68,7 @@ def test_get_audit_logs_count_returns_count_payload(): )() client = _build_client(repo, metadata_admin=object()) - response = client.get("/audit/logs/count", params={"action": "DELETE_USER"}) + response = client.get("/api/v1/audit-logs/count", params={"action": "DELETE_USER"}) assert response.status_code == 200 assert response.json() == {"count": 7} @@ -88,7 +88,7 @@ def test_get_my_audit_logs_forces_current_user_id(): )() client = _build_client(repo, metadata_user=current_user) - response = client.get("/audit/logs/my", params={"limit": 3}) + response = client.get("/api/v1/audit-logs/mine", params={"limit": 3}) assert response.status_code == 200 repo.get_logs.assert_awaited_once() diff --git a/tests/api/test_leakage_endpoints.py b/tests/api/test_leakage_endpoints.py index 8be295b..ecd01d2 100644 --- a/tests/api/test_leakage_endpoints.py +++ b/tests/api/test_leakage_endpoints.py @@ -5,7 +5,7 @@ from app.api.v1.endpoints import leakage as leakage_endpoint def _build_client() -> TestClient: app = FastAPI() - app.include_router(leakage_endpoint.router, prefix="/api/v1/leakage") + app.include_router(leakage_endpoint.router, prefix="/api/v1") app.dependency_overrides[leakage_endpoint.get_current_keycloak_username] = ( lambda: "tester" ) @@ -23,7 +23,7 @@ def test_identify_leakage_success(monkeypatch): ) client = _build_client() response = client.post( - "/api/v1/leakage/identify/", + "/api/v1/leakage-identifications", json={ "network": "demo", "scada_start": "2026-01-01T00:00:00+08:00", diff --git a/tests/api/test_meta_endpoints.py b/tests/api/test_meta_endpoints.py index 6934612..e8ddd3a 100644 --- a/tests/api/test_meta_endpoints.py +++ b/tests/api/test_meta_endpoints.py @@ -59,7 +59,7 @@ def test_meta_project_returns_map_extent(monkeypatch): app.dependency_overrides[module.get_metadata_repository] = lambda: repo client = TestClient(app) - response = client.get("/api/v1/meta/project") + response = client.get("/api/v1/projects/current/metadata") assert response.status_code == 200 assert response.json()["map_extent"] == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4} @@ -81,7 +81,7 @@ def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch): app.dependency_overrides[module.get_project_timescale_connection] = lambda: DummyTimescaleConnection() client = TestClient(app) - response = client.get("/api/v1/meta/db/health") + response = client.get("/api/v1/projects/current/database-health") assert response.status_code == 503 assert response.json()["detail"] == "Project PostgreSQL health check failed: pg unavailable" diff --git a/tests/api/test_model_import_endpoints.py b/tests/api/test_model_import_endpoints.py index 52639ae..4fd52de 100644 --- a/tests/api/test_model_import_endpoints.py +++ b/tests/api/test_model_import_endpoints.py @@ -44,7 +44,7 @@ def test_system_admin_can_import_model_without_project_membership( client = _client(admin=admin, repo=repo) response = client.post( - f"/api/v1/admin/projects/{project_id}/model/import", + f"/api/v1/admin/projects/{project_id}/model-imports", files={"file": ("desktop-model.inp", VALID_INP)}, ) @@ -64,7 +64,7 @@ def test_non_admin_is_denied_model_import(): client = TestClient(app) response = client.post( - f"/api/v1/admin/projects/{uuid4()}/model/import", + f"/api/v1/admin/projects/{uuid4()}/model-imports", files={"file": ("desktop-model.inp", VALID_INP)}, ) @@ -91,7 +91,7 @@ def test_model_import_rejects_non_inp_file(monkeypatch): ) response = client.post( - f"/api/v1/admin/projects/{project_id}/model/import", + f"/api/v1/admin/projects/{project_id}/model-imports", files={"file": ("desktop-model.txt", VALID_INP)}, ) diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py new file mode 100644 index 0000000..e590014 --- /dev/null +++ b/tests/api/test_openapi_contract.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import schemes as schemes_endpoint +from app.api.v1.endpoints import simulation as simulation_endpoint +from app.api.v1.rest_router import api_router, build_rest_router +from app.api.v1.router import api_router as source_api_router +from app.auth.project_dependencies import ProjectContext, get_project_context +from scripts.check_openapi import current_contract_bytes, validate + + +def test_rest_router_preserves_every_distinct_source_operation() -> None: + skipped_names = {"fastapi_get_json", "fastapi_test_dict"} + source_names = { + route.name + for route in source_api_router.routes + if isinstance(route, APIRoute) and route.name not in skipped_names + } + rest_names = { + route.name for route in api_router.routes if isinstance(route, APIRoute) + } + + assert rest_names == source_names + + +def test_rest_router_has_unique_method_path_pairs() -> None: + pairs: list[tuple[str, str]] = [] + for route in api_router.routes: + if not isinstance(route, APIRoute): + continue + pairs.extend((method, route.path) for method in route.methods or set()) + assert len(pairs) == len(set(pairs)) + + +def test_rest_router_rejects_duplicate_method_path_pairs() -> None: + first = APIRoute( + "/duplicate", + lambda: None, + methods={"POST"}, + name="first_endpoint", + ) + second = APIRoute( + "/duplicate", + lambda: None, + methods={"POST"}, + name="second_endpoint", + ) + + with pytest.raises(RuntimeError, match="REST route collision"): + build_rest_router([first, second]) + + +def test_handler_router_defines_only_the_public_rest_operations() -> None: + source_operations = { + (method, route.path) + for route in source_api_router.routes + if isinstance(route, APIRoute) + for method in route.methods or set() + } + public_operations = { + (method, route.path) + for route in api_router.routes + if isinstance(route, APIRoute) + for method in route.methods or set() + } + + assert source_operations == public_operations + assert ("POST", "/burst-analysis") not in source_operations + assert ("GET", "/getpipeproperties/") not in source_operations + + +def test_rest_openapi_satisfies_contract_invariants() -> None: + from fastapi import FastAPI + + app = FastAPI(redirect_slashes=False) + app.include_router(api_router, prefix="/api/v1") + errors = validate(app.openapi()) + assert errors == [] + + +def test_openapi_snapshot_matches_current_application() -> None: + contract = Path(__file__).resolve().parents[2] / "contracts/server-v1.openapi.json" + + assert contract.read_bytes() == current_contract_bytes() + + +def test_rest_contract_uses_header_project_context() -> None: + from fastapi import FastAPI + + app = FastAPI(redirect_slashes=False) + app.include_router(api_router, prefix="/api/v1") + document = app.openapi() + + for path_item in document["paths"].values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + query_names = { + parameter["name"] + for parameter in operation.get("parameters", []) + if parameter.get("in") == "query" + } + assert "network" not in query_names + assert "network_name" not in query_names + + for name, schema in document["components"]["schemas"].items(): + if name.endswith("Rest"): + assert "network" not in schema.get("properties", {}) + assert "network_name" not in schema.get("properties", {}) + + assert "/api/v1/burst-analysis" not in document["paths"] + assert "/api/v1/getpipeproperties/" not in document["paths"] + + pipes_collection = document["paths"]["/api/v1/pipes"]["get"] + query_names = { + parameter["name"] + for parameter in pipes_collection["parameters"] + if parameter["in"] == "query" + } + assert {"limit", "offset"} <= query_names + assert "204" in document["paths"]["/api/v1/pipes"]["delete"]["responses"] + + +def test_side_effecting_analysis_routes_are_post() -> None: + methods_by_path = { + route.path: route.methods + for route in api_router.routes + if isinstance(route, APIRoute) + } + assert methods_by_path["/burst-analyses"] == {"POST"} + assert methods_by_path["/flushing-analyses"] == {"POST"} + assert methods_by_path["/contaminant-simulations"] == {"POST"} + + +def test_valve_isolation_route_uses_the_isolation_handler() -> None: + route = next( + route + for route in api_router.routes + if isinstance(route, APIRoute) + and route.path == "/valve-isolation-analyses" + and route.methods == {"POST"} + ) + + assert route.name == "valve_isolation_endpoint" + + +def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_analyze_valve_isolation(network, accident_element, disabled_valves): + captured.update( + network=network, + accident_element=accident_element, + disabled_valves=disabled_valves, + ) + return {"isolatable": True, "must_close_valves": ["V-1"]} + + monkeypatch.setattr( + simulation_endpoint, + "analyze_valve_isolation", + fake_analyze_valve_isolation, + ) + app = FastAPI(redirect_slashes=False) + app.include_router(api_router, prefix="/api/v1") + app.dependency_overrides[get_project_context] = lambda: ProjectContext( + project_id=uuid4(), + project_code="fengyang", + user_id=uuid4(), + project_role="member", + ) + + response = TestClient(app, raise_server_exceptions=False).post( + "/api/v1/valve-isolation-analyses", + params=[ + ("accident_element", "P-1"), + ("accident_element", "P-2"), + ("disabled_valves", "V-9"), + ], + ) + + assert response.status_code == 200 + assert response.json() == {"isolatable": True, "must_close_valves": ["V-1"]} + assert captured == { + "network": "fengyang", + "accident_element": ["P-1", "P-2"], + "disabled_valves": ["V-9"], + } + + +def test_scada_cleaning_runs_are_post() -> None: + methods_by_path = { + route.path: route.methods + for route in api_router.routes + if isinstance(route, APIRoute) + } + assert methods_by_path["/timeseries/scada-cleaning-runs"] == {"POST"} + + +def test_sensor_placement_excel_export_is_post() -> None: + methods_by_path = { + route.path: route.methods + for route in api_router.routes + if isinstance(route, APIRoute) + } + assert methods_by_path[ + "/sensor-placement-schemes/{scheme_id}/exports/excel" + ] == {"POST"} + + +def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_get_all_schemes(network, scheme_type=None, query_date=None): + captured.update( + network=network, + scheme_type=scheme_type, + query_date=query_date, + ) + return [{"scheme_name": "burst_case", "scheme_type": scheme_type}] + + monkeypatch.setattr( + schemes_endpoint, + "get_all_schemes", + fake_get_all_schemes, + ) + app = FastAPI(redirect_slashes=False) + app.include_router(api_router, prefix="/api/v1") + project_context = ProjectContext( + project_id=uuid4(), + project_code="fengyang", + user_id=uuid4(), + project_role="viewer", + ) + app.dependency_overrides[get_project_context] = lambda: project_context + + response = TestClient(app, raise_server_exceptions=False).get( + "/api/v1/schemes", + params={"scheme_type": "burst_analysis"}, + ) + + assert response.status_code == 200 + assert captured == { + "network": "fengyang", + "scheme_type": "burst_analysis", + "query_date": None, + } + assert response.json()["items"] == [ + {"scheme_name": "burst_case", "scheme_type": "burst_analysis"} + ] diff --git a/tests/api/test_project_endpoints.py b/tests/api/test_project_endpoints.py index d92c375..31efb35 100644 --- a/tests/api/test_project_endpoints.py +++ b/tests/api/test_project_endpoints.py @@ -78,7 +78,7 @@ def test_project_info_returns_404_when_missing(monkeypatch): app.dependency_overrides[module.get_metadata_repository] = lambda: repo client = TestClient(app) - response = client.get("/api/v1/project_info/", params={"network": "missing"}) + response = client.get("/api/v1/projects/current", params={"network": "missing"}) assert response.status_code == 404 assert response.json()["detail"] == "Project missing not found" @@ -100,7 +100,7 @@ def test_project_info_returns_project_workspace(monkeypatch): app.dependency_overrides[module.get_metadata_repository] = lambda: repo client = TestClient(app) - response = client.get("/api/v1/project_info/", params={"network": "demo"}) + response = client.get("/api/v1/projects/current", params={"network": "demo"}) assert response.status_code == 200 payload = response.json() @@ -121,7 +121,7 @@ def test_open_project_returns_network_even_when_db_connection_fails(monkeypatch) monkeypatch.setattr(module, "get_pg_db", failing_get_pg_db) client = TestClient(build_test_app(module.router, "/api/v1")) - response = client.post("/api/v1/openproject/", params={"network": "demo"}) + response = client.post("/api/v1/projects/current", params={"network": "demo"}) assert response.status_code == 200 assert response.json() == "demo" @@ -133,11 +133,17 @@ def test_project_lock_lifecycle(monkeypatch): module.lockedPrjs.clear() client = TestClient(build_test_app(module.router, "/api/v1")) - first_lock = client.post("/api/v1/lockproject/", params={"network": "demo"}) - second_lock = client.post("/api/v1/lockproject/", params={"network": "demo"}) - locked_by_me = client.get("/api/v1/isprojectlockedbyme/", params={"network": "demo"}) - unlock = client.post("/api/v1/unlockproject/", params={"network": "demo"}) - locked = client.get("/api/v1/isprojectlocked/", params={"network": "demo"}) + first_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"}) + second_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"}) + locked_by_me = client.get( + "/api/v1/projects/current/lock/ownership", + params={"network": "demo"}, + ) + unlock = client.delete( + "/api/v1/projects/current/lock", + params={"network": "demo"}, + ) + locked = client.get("/api/v1/projects/current/lock", params={"network": "demo"}) assert first_lock.json() == 0 assert second_lock.json() == 1 diff --git a/tests/api/test_regions_endpoints.py b/tests/api/test_regions_endpoints.py index b51a9cc..a1d0898 100644 --- a/tests/api/test_regions_endpoints.py +++ b/tests/api/test_regions_endpoints.py @@ -92,8 +92,8 @@ def test_calculate_service_area_contract_uses_only_network(monkeypatch): ) client = TestClient(build_test_app(module.router, "/api/v1")) - response = client.get( - "/api/v1/calculateservicearea/", + response = client.post( + "/api/v1/service-area-calculations", params={"network": "demo", "time_index": 5}, ) schema = client.get("/openapi.json").json() @@ -103,7 +103,7 @@ def test_calculate_service_area_contract_uses_only_network(monkeypatch): assert calls == ["demo"] parameter_names = [ item["name"] - for item in schema["paths"]["/api/v1/calculateservicearea/"]["get"]["parameters"] + for item in schema["paths"]["/api/v1/service-area-calculations"]["post"]["parameters"] ] assert parameter_names == ["network"] @@ -121,7 +121,7 @@ def test_add_district_metering_area_converts_boundary_to_tuples(monkeypatch): client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post( - "/api/v1/adddistrictmeteringarea/", + "/api/v1/district-metering-areas", params={"network": "demo"}, json={"id": "dma-1", "boundary": [[1, 2], [3, 4], [1, 2]]}, ) @@ -145,7 +145,7 @@ def test_generate_virtual_district_reads_centers_from_body(monkeypatch): client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post( - "/api/v1/generatevirtualdistrict/", + "/api/v1/virtual-district-generation-runs", params={"network": "demo", "inflate_delta": 0.75}, json={"centers": ["J1", "J2"]}, ) diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index a743693..226387c 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -154,7 +154,7 @@ def test_optimize_returns_created_scheme(monkeypatch): monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize) response = _client(module).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "tjwater", "scheme_name": "北区测压点", @@ -173,7 +173,7 @@ def test_optimize_returns_created_scheme(monkeypatch): def test_optimize_rejects_unsupported_sensor_type(monkeypatch): module = _load_module(monkeypatch) response = _client(module).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "tjwater", "scheme_name": "北区测流点", @@ -190,7 +190,7 @@ def test_optimize_rejects_unsupported_sensor_type(monkeypatch): def test_optimize_rejects_network_outside_project_context(monkeypatch): module = _load_module(monkeypatch) response = _client(module).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "other_project", "scheme_name": "越权方案", @@ -207,7 +207,7 @@ def test_optimize_rejects_network_outside_project_context(monkeypatch): def test_optimize_rejects_network_path_traversal(monkeypatch): module = _load_module(monkeypatch) response = _client(module).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "../other_project", "scheme_name": "非法路径", @@ -224,7 +224,7 @@ def test_optimize_rejects_network_path_traversal(monkeypatch): def test_optimize_rejects_unbounded_sensor_count(monkeypatch): module = _load_module(monkeypatch) response = _client(module).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "tjwater", "scheme_name": "超大方案", @@ -241,7 +241,7 @@ def test_optimize_rejects_unbounded_sensor_count(monkeypatch): def test_optimize_rejects_viewer_project_role(monkeypatch): module = _load_module(monkeypatch) response = _client(module, project_role="viewer").post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "tjwater", "scheme_name": "只读成员方案", @@ -262,7 +262,7 @@ def test_optimize_rejects_viewer_project_role(monkeypatch): def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role): module = _load_module(monkeypatch) response = _client(module, project_role=project_role).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "tjwater", "scheme_name": f"{project_role}方案", @@ -284,7 +284,7 @@ def test_optimize_maps_running_project_job_to_409(monkeypatch): monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", conflict) response = _client(module).post( - "/api/v1/sensor-placement-schemes/optimize", + "/api/v1/sensor-placement-optimization-runs", json={ "network": "tjwater", "scheme_name": "并发方案", diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index cee6eeb..b9e563f 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -118,7 +118,7 @@ def test_run_project_endpoint_returns_plain_text(monkeypatch): monkeypatch.setattr(module, "run_project", lambda network: f"report::{network}") client = TestClient(build_test_app(module.router, "/api/v1")) - response = client.get("/api/v1/runproject/", params={"network": "demo"}) + response = client.post("/api/v1/project-runs", params={"network": "demo"}) assert response.status_code == 200 assert response.text == "report::demo" @@ -143,7 +143,7 @@ def test_scheduling_analysis_maps_request_body(monkeypatch): client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post( - "/api/v1/scheduling_analysis/", + "/api/v1/scheduling-analyses", json={ "network": "demo", "start_time": "2025-01-01T08:00:00+08:00", @@ -177,7 +177,7 @@ def test_project_management_maps_named_arguments(monkeypatch): client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post( - "/api/v1/project_management/", + "/api/v1/project-managements", json={ "network": "demo", "start_time": "2025-01-01T08:00:00+08:00", @@ -260,7 +260,7 @@ def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time( client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post( - "/api/v1/runsimulationmanuallybydate/", + "/api/v1/simulation-runs", json={ "name": "demo", "start_time": "2025-01-02T03:04:05+08:00", @@ -280,7 +280,7 @@ def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypat client = TestClient(build_test_app(module.router, "/api/v1")) response = client.post( - "/api/v1/runsimulationmanuallybydate/", + "/api/v1/simulation-runs", json={ "name": "demo", "start_time": "2025-01-02T03:04:05", @@ -302,8 +302,8 @@ def test_valve_close_endpoint_passes_scheme_name(monkeypatch): monkeypatch.setattr(module, "valve_close_analysis", fake_valve_close_analysis) client = TestClient(build_test_app(module.router, "/api/v1")) - response = client.get( - "/api/v1/valve_close_analysis/", + response = client.post( + "/api/v1/valve-closure-analyses", params={ "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", @@ -334,8 +334,8 @@ def test_burst_endpoint_passes_current_username(monkeypatch): monkeypatch.setattr(module, "burst_analysis", fake_burst_analysis) client = _build_authenticated_client(module) - response = client.get( - "/api/v1/burst_analysis/", + response = client.post( + "/api/v1/burst-analyses", params={ "network": "demo", "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", @@ -362,8 +362,8 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) client = _build_authenticated_client(module) - response = client.get( - "/api/v1/flushing_analysis/", + response = client.post( + "/api/v1/flushing-analyses", params={ "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", @@ -401,8 +401,8 @@ def test_contaminant_endpoint_passes_current_username(monkeypatch): monkeypatch.setattr(module, "contaminant_simulation", fake_contaminant_simulation) client = _build_authenticated_client(module) - response = client.get( - "/api/v1/contaminant_simulation/", + response = client.post( + "/api/v1/contaminant-simulations", params={ "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", @@ -422,8 +422,8 @@ def test_contaminant_endpoint_requires_scheme_name(monkeypatch): module = _load_simulation_module(monkeypatch) client = _build_authenticated_client(module) - response = client.get( - "/api/v1/contaminant_simulation/", + response = client.post( + "/api/v1/contaminant-simulations", params={ "network": "demo", "start_time": "2025-01-02T03:04:05+08:00", diff --git a/tests/unit/test_keycloak_theme_config.py b/tests/unit/test_keycloak_theme_config.py new file mode 100644 index 0000000..caae611 --- /dev/null +++ b/tests/unit/test_keycloak_theme_config.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +THEME_SCRIPT = ( + Path(__file__).resolve().parents[2] + / "infra" + / "docker" + / "keycloak" + / "configure-theme.sh" +) + + +def test_theme_script_clears_client_login_theme_override() -> None: + script = THEME_SCRIPT.read_text(encoding="utf-8") + + assert "TJWATER_KEYCLOAK_CLIENT_ID" in script + assert "sed -n '1p'" in script + assert "--set attributes.login_theme=" in script + assert "client ${client_id} 已改为继承 realm 登录主题。" in script From 1d88f8efbe469c4c4243ac68964b8f8df11f148e Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 21:50:21 +0800 Subject: [PATCH 75/93] fix(api): wrap pre-paginated list responses --- app/api/v1/rest_router.py | 62 ++++++++++++++++++++---------- tests/api/test_openapi_contract.py | 30 ++++++++++++++- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/app/api/v1/rest_router.py b/app/api/v1/rest_router.py index fc736ee..496a2d9 100644 --- a/app/api/v1/rest_router.py +++ b/app/api/v1/rest_router.py @@ -201,18 +201,39 @@ def _with_header_project_context(endpoint, route_name: str): def _with_pagination(endpoint): signature = inspect.signature(endpoint) - if "limit" in signature.parameters or "offset" in signature.parameters: - return endpoint + handler_limit_parameter = "limit" if "limit" in signature.parameters else None + handler_offset_parameter = next( + ( + parameter_name + for parameter_name in ("offset", "skip") + if parameter_name in signature.parameters + ), + None, + ) + handler_handles_pagination = bool( + handler_limit_parameter or handler_offset_parameter + ) @wraps(endpoint) async def wrapper(*args, **kwargs): - limit = kwargs.pop("_rest_limit") - offset = kwargs.pop("_rest_offset") + if handler_handles_pagination: + limit = kwargs.get(handler_limit_parameter, 0) + offset = kwargs.get(handler_offset_parameter, 0) + else: + limit = kwargs.pop("_rest_limit") + offset = kwargs.pop("_rest_offset") result = endpoint(*args, **kwargs) if inspect.isawaitable(result): result = await result if not isinstance(result, list): return result + if handler_handles_pagination: + return Page( + items=result, + total=offset + len(result), + limit=limit or len(result), + offset=offset, + ) return Page( items=result[offset : offset + limit], total=len(result), @@ -221,22 +242,23 @@ def _with_pagination(endpoint): ) parameters = list(signature.parameters.values()) - parameters.extend( - [ - inspect.Parameter( - "_rest_limit", - kind=inspect.Parameter.KEYWORD_ONLY, - annotation=int, - default=Query(100, ge=1, le=1000, alias="limit"), - ), - inspect.Parameter( - "_rest_offset", - kind=inspect.Parameter.KEYWORD_ONLY, - annotation=int, - default=Query(0, ge=0, alias="offset"), - ), - ] - ) + if not handler_handles_pagination: + parameters.extend( + [ + inspect.Parameter( + "_rest_limit", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=Query(100, ge=1, le=1000, alias="limit"), + ), + inspect.Parameter( + "_rest_offset", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=Query(0, ge=0, alias="offset"), + ), + ] + ) wrapper.__signature__ = signature.replace(parameters=parameters) return wrapper diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index e590014..623e98a 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -4,7 +4,7 @@ from pathlib import Path from uuid import uuid4 import pytest -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI, Query from fastapi.routing import APIRoute from fastapi.testclient import TestClient @@ -254,3 +254,31 @@ def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None: assert response.json()["items"] == [ {"scheme_name": "burst_case", "scheme_type": "burst_analysis"} ] + + +def test_rest_runtime_wraps_handler_paginated_list() -> None: + source_router = APIRouter() + + @source_router.get("/records", response_model=list[int]) + async def list_records( + skip: int = Query(0, ge=0), + limit: int = Query(2, ge=1, le=10), + ) -> list[int]: + records = [10, 20, 30, 40] + return records[skip : skip + limit] + + app = FastAPI(redirect_slashes=False) + app.include_router(build_rest_router(source_router.routes), prefix="/api/v1") + + response = TestClient(app, raise_server_exceptions=False).get( + "/api/v1/records", + params={"skip": 1, "limit": 2}, + ) + + assert response.status_code == 200 + assert response.json() == { + "items": [20, 30], + "total": 3, + "limit": 2, + "offset": 1, + } From eac6b78598e0e8d1a0d4772315f2124c47a9e169 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 31 Jul 2026 00:01:21 +0800 Subject: [PATCH 76/93] fix(ci): align backend image with deployment --- .gitea/workflows/package.yml | 3 ++- scripts/trigger-gitea-pipeline.sh | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 25a10a2..de41372 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -46,7 +46,8 @@ jobs: fi REPOSITORY_PATH="${RAW_REPOSITORY#/}" - IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" + IMAGE_OWNER="${REPOSITORY_PATH%%/*}" + IMAGE_REPOSITORY_PATH="$(printf '%s' "${IMAGE_OWNER}/tjwater-backend" | tr '[:upper:]' '[:lower:]')" IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}" IMAGE_TAG="${RAW_REF_NAME}" { diff --git a/scripts/trigger-gitea-pipeline.sh b/scripts/trigger-gitea-pipeline.sh index 53efaa6..3e7453f 100755 --- a/scripts/trigger-gitea-pipeline.sh +++ b/scripts/trigger-gitea-pipeline.sh @@ -9,7 +9,6 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then echo " bash scripts/trigger-gitea-pipeline.sh" echo " bash scripts/trigger-gitea-pipeline.sh origin latest" echo " bash scripts/trigger-gitea-pipeline.sh gitea latest" - echo " bash scripts/trigger-gitea-pipeline.sh origin v2026.06.09.1" exit 0 fi @@ -30,6 +29,11 @@ resolve_default_remote() { REMOTE="${1:-}" TAG="${2:-latest}" +if [[ "$TAG" != "latest" ]]; then + echo "[ERROR] This deployment only supports the 'latest' tag." + exit 1 +fi + if ! git rev-parse --git-dir >/dev/null 2>&1; then echo "[ERROR] Current directory is not a git repository." exit 1 From 0be31869b5d333f1bd56c1309c07a18b35afeb94 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 31 Jul 2026 18:39:36 +0800 Subject: [PATCH 77/93] fix(api): encode untyped datetime responses --- app/api/v1/rest_router.py | 20 ++++++++++++++++++ tests/api/test_openapi_contract.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/app/api/v1/rest_router.py b/app/api/v1/rest_router.py index 496a2d9..232ad2d 100644 --- a/app/api/v1/rest_router.py +++ b/app/api/v1/rest_router.py @@ -8,8 +8,10 @@ from functools import wraps from typing import Any, Generic, TypeVar, get_args, get_origin from fastapi import APIRouter, Depends, Query +from fastapi.encoders import jsonable_encoder from fastapi.routing import APIRoute from pydantic import BaseModel, JsonValue, create_model +from starlette.responses import Response from app.api.problem_details import ProblemDetails from app.api.v1.router import api_router as handler_api_router @@ -263,6 +265,21 @@ def _with_pagination(endpoint): return wrapper +def _with_jsonable_response(endpoint): + """Normalize untyped handler results before JsonValue validation.""" + + @wraps(endpoint) + async def wrapper(*args, **kwargs): + result = endpoint(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + if isinstance(result, Response): + return result + return jsonable_encoder(result) + + return wrapper + + def _adapt_route(route: APIRoute) -> APIRoute: methods = route.methods or set() if len(methods) != 1: @@ -286,6 +303,9 @@ def _adapt_route(route: APIRoute) -> APIRoute: endpoint = _with_header_project_context(route.endpoint, route.name) response_model = route.response_model + has_untyped_response = response_model is None + if has_untyped_response: + endpoint = _with_jsonable_response(endpoint) if get_origin(response_model) is list: item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue response_model = Page[item_type] diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index 623e98a..3d2d169 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 @@ -282,3 +283,35 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None: "limit": 2, "offset": 1, } + + +def test_rest_runtime_json_encodes_untyped_datetime_response() -> None: + source_router = APIRouter() + + @source_router.get("/simulation-result") + async def simulation_result(): + return { + "result": [ + { + "time": datetime(2026, 7, 30, 4, tzinfo=timezone.utc), + "id": "4277", + } + ] + } + + app = FastAPI(redirect_slashes=False) + app.include_router(build_rest_router(source_router.routes), prefix="/api/v1") + + response = TestClient(app, raise_server_exceptions=False).get( + "/api/v1/simulation-result" + ) + + assert response.status_code == 200 + assert response.json() == { + "result": [ + { + "time": "2026-07-30T04:00:00+00:00", + "id": "4277", + } + ] + } From f010f071eb75081f996783cd8844f8617e8c4548 Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 10:39:31 +0800 Subject: [PATCH 78/93] refactor(db): align project template schema --- resources/sql/001_create_users_table.sql | 67 ------------------- resources/sql/002_create_audit_logs_table.sql | 45 ------------- resources/sql/create/39.users.sql | 10 --- resources/sql/create/40.scheme_list.sql | 2 +- resources/sql/create/42.sensor_placement.sql | 4 +- .../sql/create/44.leakage_identify_result.sql | 17 +++++ resources/sql/drop/39.users.sql | 5 -- .../sql/drop/44.leakage_identify_result.sql | 3 + scripts/create_template.py | 4 +- 9 files changed, 25 insertions(+), 132 deletions(-) delete mode 100644 resources/sql/001_create_users_table.sql delete mode 100644 resources/sql/002_create_audit_logs_table.sql delete mode 100644 resources/sql/create/39.users.sql create mode 100644 resources/sql/create/44.leakage_identify_result.sql delete mode 100644 resources/sql/drop/39.users.sql create mode 100644 resources/sql/drop/44.leakage_identify_result.sql diff --git a/resources/sql/001_create_users_table.sql b/resources/sql/001_create_users_table.sql deleted file mode 100644 index 5caed32..0000000 --- a/resources/sql/001_create_users_table.sql +++ /dev/null @@ -1,67 +0,0 @@ --- ============================================ --- TJWater Server 用户系统数据库迁移脚本 --- ============================================ - --- 创建用户表 -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, - hashed_password VARCHAR(255) NOT NULL, - role VARCHAR(20) DEFAULT 'USER' NOT NULL, - is_active BOOLEAN DEFAULT TRUE NOT NULL, - is_superuser BOOLEAN DEFAULT FALSE NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - - CONSTRAINT users_role_check CHECK (role IN ('ADMIN', 'OPERATOR', 'USER', 'VIEWER')) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); -CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); -CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active); - --- 创建触发器自动更新 updated_at -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS update_users_updated_at ON users; -CREATE TRIGGER update_users_updated_at - BEFORE UPDATE ON users - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- 创建默认管理员账号 (密码: admin123) -INSERT INTO users (username, email, hashed_password, role, is_superuser) -VALUES ( - 'admin', - 'admin@tjwater.com', - '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5aeAJK.1tYKAW', - 'ADMIN', - TRUE -) ON CONFLICT (username) DO NOTHING; - --- 迁移现有硬编码用户 (tjwater/tjwater@123) -INSERT INTO users (username, email, hashed_password, role, is_superuser) -VALUES ( - 'tjwater', - 'tjwater@tjwater.com', - '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', - 'ADMIN', - TRUE -) ON CONFLICT (username) DO NOTHING; - --- 添加注释 -COMMENT ON TABLE users IS '用户表 - 存储系统用户信息'; -COMMENT ON COLUMN users.id IS '用户ID(主键)'; -COMMENT ON COLUMN users.username IS '用户名(唯一)'; -COMMENT ON COLUMN users.email IS '邮箱地址(唯一)'; -COMMENT ON COLUMN users.hashed_password IS 'bcrypt 密码哈希'; -COMMENT ON COLUMN users.role IS '用户角色: ADMIN, OPERATOR, USER, VIEWER'; diff --git a/resources/sql/002_create_audit_logs_table.sql b/resources/sql/002_create_audit_logs_table.sql deleted file mode 100644 index 6f0d9fe..0000000 --- a/resources/sql/002_create_audit_logs_table.sql +++ /dev/null @@ -1,45 +0,0 @@ --- ============================================ --- TJWater Server 审计日志表迁移脚本 --- ============================================ - --- 创建审计日志表 -CREATE TABLE IF NOT EXISTS audit_logs ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - username VARCHAR(50), - action VARCHAR(50) NOT NULL, - resource_type VARCHAR(50), - resource_id VARCHAR(100), - ip_address VARCHAR(45), - user_agent TEXT, - request_method VARCHAR(10), - request_path TEXT, - request_data JSONB, - response_status INTEGER, - error_message TEXT, - timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL -); - --- 创建索引以提高查询性能 -CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); -CREATE INDEX IF NOT EXISTS idx_audit_logs_username ON audit_logs(username); -CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); -CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource_type, resource_id); - --- 添加注释 -COMMENT ON TABLE audit_logs IS '审计日志表 - 记录所有关键操作'; -COMMENT ON COLUMN audit_logs.id IS '日志ID(主键)'; -COMMENT ON COLUMN audit_logs.user_id IS '用户ID(外键)'; -COMMENT ON COLUMN audit_logs.username IS '用户名(冗余字段,用于用户删除后仍可查询)'; -COMMENT ON COLUMN audit_logs.action IS '操作类型(如:LOGIN, LOGOUT, CREATE, UPDATE, DELETE)'; -COMMENT ON COLUMN audit_logs.resource_type IS '资源类型(如:user, project, network)'; -COMMENT ON COLUMN audit_logs.resource_id IS '资源ID'; -COMMENT ON COLUMN audit_logs.ip_address IS '客户端IP地址'; -COMMENT ON COLUMN audit_logs.user_agent IS '客户端User-Agent'; -COMMENT ON COLUMN audit_logs.request_method IS 'HTTP请求方法'; -COMMENT ON COLUMN audit_logs.request_path IS '请求路径'; -COMMENT ON COLUMN audit_logs.request_data IS '请求数据(JSON格式,敏感信息已脱敏)'; -COMMENT ON COLUMN audit_logs.response_status IS 'HTTP响应状态码'; -COMMENT ON COLUMN audit_logs.error_message IS '错误消息(如果有)'; -COMMENT ON COLUMN audit_logs.timestamp IS '操作时间'; diff --git a/resources/sql/create/39.users.sql b/resources/sql/create/39.users.sql deleted file mode 100644 index 2f82deb..0000000 --- a/resources/sql/create/39.users.sql +++ /dev/null @@ -1,10 +0,0 @@ --- [USERS] --- 王名豪 --- 2025/03/23 --- 存储系统的用户信息,如用户名,密码 - -create table users ( - user_id SERIAL PRIMARY KEY, - username varchar(32) not null unique, - password varchar(32) not null -) \ No newline at end of file diff --git a/resources/sql/create/40.scheme_list.sql b/resources/sql/create/40.scheme_list.sql index 0b8d007..7d5ea3a 100644 --- a/resources/sql/create/40.scheme_list.sql +++ b/resources/sql/create/40.scheme_list.sql @@ -7,7 +7,7 @@ create table scheme_list ( scheme_id SERIAL PRIMARY KEY, scheme_name varchar(32) not null, scheme_type varchar(32) not null, - username varchar(32) not null REFERENCES "users"(username) ON UPDATE CASCADE ON DELETE RESTRICT, + username varchar(32) not null, create_time TIMESTAMP WITH TIME ZONE not null DEFAULT date_trunc('minute', CURRENT_TIMESTAMP), scheme_start_time TIMESTAMP WITH TIME ZONE not null, scheme_detail JSON diff --git a/resources/sql/create/42.sensor_placement.sql b/resources/sql/create/42.sensor_placement.sql index 5927dfe..8d1144a 100644 --- a/resources/sql/create/42.sensor_placement.sql +++ b/resources/sql/create/42.sensor_placement.sql @@ -8,7 +8,7 @@ CREATE TABLE sensor_placement ( scheme_name varchar(32) not null, sensor_number int, min_diameter int, - username varchar(32) not null REFERENCES "users"(username) ON UPDATE CASCADE ON DELETE RESTRICT, + username varchar(32) not null, create_time TIMESTAMP WITH TIME ZONE not null DEFAULT date_trunc('minute', CURRENT_TIMESTAMP), sensor_location TEXT[] -); \ No newline at end of file +); diff --git a/resources/sql/create/44.leakage_identify_result.sql b/resources/sql/create/44.leakage_identify_result.sql new file mode 100644 index 0000000..2079965 --- /dev/null +++ b/resources/sql/create/44.leakage_identify_result.sql @@ -0,0 +1,17 @@ +-- [LEAKAGE_IDENTIFY_RESULT] +-- 存储漏损识别任务的结果数据。 + +CREATE TABLE leakage_identify_result ( + id BIGSERIAL PRIMARY KEY, + scheme_name varchar NOT NULL, + network varchar NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + run_status varchar NOT NULL DEFAULT 'completed', + error_message text, + sensor_nodes jsonb NOT NULL DEFAULT '[]'::jsonb, + result_rows jsonb NOT NULL DEFAULT '[]'::jsonb, + node_area_map jsonb NOT NULL DEFAULT '{}'::jsonb, + areas jsonb DEFAULT '[]'::jsonb, + drawing_payload jsonb DEFAULT '{"type": "FeatureCollection", "features": []}'::jsonb, + CONSTRAINT uq_leakage_identify_result_scheme UNIQUE (scheme_name) +); diff --git a/resources/sql/drop/39.users.sql b/resources/sql/drop/39.users.sql deleted file mode 100644 index f96c351..0000000 --- a/resources/sql/drop/39.users.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 王名豪 --- 2025/03/23 --- 删除user这张表 - -drop table if exists users; \ No newline at end of file diff --git a/resources/sql/drop/44.leakage_identify_result.sql b/resources/sql/drop/44.leakage_identify_result.sql new file mode 100644 index 0000000..e859186 --- /dev/null +++ b/resources/sql/drop/44.leakage_identify_result.sql @@ -0,0 +1,3 @@ +-- [LEAKAGE_IDENTIFY_RESULT] + +DROP TABLE IF EXISTS leakage_identify_result; diff --git a/scripts/create_template.py b/scripts/create_template.py index 2f9a4ab..8bd7db4 100644 --- a/scripts/create_template.py +++ b/scripts/create_template.py @@ -40,11 +40,11 @@ sql_create = [ "script/sql/create/36.wda.sql", "script/sql/create/37.history_patterns_flows.sql", "script/sql/create/38.scada_info.sql", - "script/sql/create/39.users.sql", "script/sql/create/40.scheme_list.sql", "script/sql/create/41.pipe_risk_probability.sql", "script/sql/create/42.sensor_placement.sql", "script/sql/create/43.burst_locate_result.sql", + "script/sql/create/44.leakage_identify_result.sql", "script/sql/create/extension_data.sql", "script/sql/create/operation.sql" ] @@ -54,9 +54,9 @@ sql_drop = [ "script/sql/drop/extension_data.sql", "script/sql/drop/43.burst_locate_result.sql", "script/sql/drop/42.sensor_placement.sql", + "script/sql/drop/44.leakage_identify_result.sql", "script/sql/drop/41.pipe_risk_probability.sql", "script/sql/drop/40.scheme_list.sql", - "script/sql/drop/39.users.sql", "script/sql/drop/38.scada_info.sql", "script/sql/drop/37.history_patterns_flows.sql", "script/sql/drop/36.wda.sql", From b6a6527bab8f4dbd2f37bc9818d9fb8f6834f31b Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 10:39:41 +0800 Subject: [PATCH 79/93] refactor(auth): remove project-local user API --- app/api/v1/endpoints/users.py | 36 ---- app/api/v1/router.py | 6 - app/native/wndb/__init__.py | 2 - app/native/wndb/s39_user.py | 37 ---- app/services/scheme_management.py | 51 +---- app/services/tjnetwork.py | 13 -- cli/tjwater_cli_endpoint_scope.md | 4 - contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 333 ----------------------------- scripts/main.py | 21 -- scripts/main_api_endpoints.md | 3 - scripts/online_Analysis.py | 53 ----- tests/api/test_openapi_contract.py | 14 ++ 13 files changed, 17 insertions(+), 558 deletions(-) delete mode 100644 app/api/v1/endpoints/users.py delete mode 100644 app/native/wndb/s39_user.py diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py deleted file mode 100644 index 867a766..0000000 --- a/app/api/v1/endpoints/users.py +++ /dev/null @@ -1,36 +0,0 @@ -from fastapi import APIRouter, Request, Query -from typing import Any, List, Dict, Union -from app.services.tjnetwork import Any, get_all_users, get_user, get_user_schema - -router = APIRouter() - -########################################################### -# user 39 -########################################################### - -@router.get("/network-schemas/user", summary="获取用户模式", description="获取指定网络的用户模式定义") -async def fastapi_get_user_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]: - """ - 获取用户模式定义 - - 返回指定网络的用户模式结构定义 - """ - return get_user_schema(network) - -@router.get("/users/detail", summary="获取单个用户", description="获取指定网络中的单个用户信息") -async def fastapi_get_user(network: str = Query(..., description="管网名称(或数据库名称)"), user_name: str = Query(..., description="用户名")) -> dict[Any, Any]: - """ - 获取用户信息 - - 返回指定网络中指定用户名的详细信息 - """ - return get_user(network, user_name) - -@router.get("/users", summary="获取所有用户", description="获取指定网络的所有用户列表") -async def fastapi_get_all_users(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: - """ - 获取所有用户列表 - - 返回指定网络中所有用户的信息 - """ - return get_all_users(network) \ No newline at end of file diff --git a/app/api/v1/router.py b/app/api/v1/router.py index b6f801c..b77b425 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -22,7 +22,6 @@ from app.api.v1.endpoints import ( sensor_placement, simulation, snapshots, - users, web_search, ) from app.api.v1.endpoints.components import ( @@ -152,11 +151,6 @@ api_router.include_router( tags=["Snapshots"], dependencies=[simulation_access], ) -api_router.include_router( - users.router, - tags=["Users"], - dependencies=[webgis_view_access], -) api_router.include_router( schemes.router, tags=["Schemes"], diff --git a/app/native/wndb/__init__.py b/app/native/wndb/__init__.py index 4765495..35b9c20 100644 --- a/app/native/wndb/__init__.py +++ b/app/native/wndb/__init__.py @@ -460,8 +460,6 @@ from .s36_wda_cal import ( # ----------------------------------------------------------------------------- from .s38_scada_info import get_scada_info_schema, get_scada_info, get_all_scada_info -from .s39_user import get_user_schema, get_user, get_all_users - from .s40_schema import get_scheme_schema, get_scheme, get_all_schemes from .s41_pipe_risk_probability import ( diff --git a/app/native/wndb/s39_user.py b/app/native/wndb/s39_user.py deleted file mode 100644 index 3b4c37d..0000000 --- a/app/native/wndb/s39_user.py +++ /dev/null @@ -1,37 +0,0 @@ -from .database import * -from .s0_base import * - -class User(object): - def __init__(self, input: dict[str, Any]) -> None: - self.type = 'user' - self.id = str(input['user_id']) - self.name = str(input['username']) - self.password = str(input['password']) - - def as_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id, 'name': self.name, 'password': self.password } - - def as_id_dict(self) -> dict[str, Any]: - return { 'type': self.type, 'id': self.id } - - -def get_user_schema(name: str) -> dict[str, dict[Any, Any]]: - return { 'id' : {'type': 'str' , 'optional': False , 'readonly': True }, - 'name' : {'type': 'str' , 'optional': False , 'readonly': False}, - 'password' : {'type': 'str' , 'optional': False , 'readonly': False} } - -def get_user(name: str, user_name: str) -> dict[Any, Any]: - t = try_read(name, f"select * from users where username = '{user_name}'") - if t == None: - return {} - - d = {} - d['id'] = str(t['user_id']) - d['name'] = str(t['username']) - # d['password'] = str(t['password']) - - return d - -def get_all_users(name: str) -> list[dict[Any, Any]]: - return read_all(name, "select * from users") - diff --git a/app/services/scheme_management.py b/app/services/scheme_management.py index aaa5c04..1cab298 100644 --- a/app/services/scheme_management.py +++ b/app/services/scheme_management.py @@ -11,53 +11,6 @@ from app.core.config import get_pgconn_string from app.services.time_api import parse_utc_time -# 2025/03/23 -def create_user(name: str, username: str, password: str): - """ - 创建用户 - :param name: 数据库名称 - :param username: 用户名 - :param password: 密码 - :return: - """ - try: - # 动态替换数据库名称 - conn_string = get_pgconn_string(db_name=name) - # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - "INSERT INTO users (username, password) VALUES (%s, %s)", - (username, password), - ) - # 提交事务 - conn.commit() - print("新用户创建成功!") - except Exception as e: - print(f"创建用户出错:{e}") - - -# 2025/03/23 -def delete_user(name: str, username: str): - """ - 删除用户 - :param name: 数据库名称 - :param username: 用户名 - :return: - """ - try: - # 动态替换数据库名称 - conn_string = get_pgconn_string(db_name=name) - # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute("DELETE FROM users WHERE username = %s", (username,)) - conn.commit() - print(f"用户 {username} 删除成功!") - except Exception as e: - print(f"删除用户出错:{e}") - - # 2025/03/23 def scheme_name_exists(name: str, scheme_name: str) -> bool: """ @@ -98,8 +51,8 @@ def store_scheme_info( :param name: 数据库名称 :param scheme_name: 方案名称 :param scheme_type: 方案类型 - :param username: 用户名(需在 users 表中已存在) - :param scheme_start_time: 方案起始时间(字符串) + :param username: MetaDB 中的用户名快照 + :param scheme_start_time: 带时区的方案起始时间;写入前统一转换为 UTC :param scheme_detail: 方案详情(字典,会转换为 JSON) :return: """ diff --git a/app/services/tjnetwork.py b/app/services/tjnetwork.py index fa36d76..2400e38 100644 --- a/app/services/tjnetwork.py +++ b/app/services/tjnetwork.py @@ -1290,19 +1290,6 @@ def get_scada_info(name: str, id: str) -> dict[str, Any]: def get_all_scada_info(name: str) -> list[dict[str, Any]]: return api.get_all_scada_info(name) -# DingZQ 2025-03-27 -############################################################ -# 39 users -############################################################ -def get_user_schema(name: str) -> dict[str, dict[str, Any]]: - return api.get_user_schema(name) - -def get_user(name: str, user_name: str) -> dict[str, Any]: - return api.get_user(name, user_name=user_name) - -def get_all_users(name: str) -> list[dict[str, Any]]: - return api.get_all_users(name) - ############################################################ # scheme 40 ############################################################ diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 86da44a..bd6d36a 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -305,7 +305,6 @@ GET /getjson/ app/api/v1/endpoints/snapshots.py app/api/v1/endpoints/cache.py app/api/v1/endpoints/audit.py -app/api/v1/endpoints/users.py ``` 这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。 @@ -335,9 +334,6 @@ POST /clearallredis/ GET /audit/logs GET /audit/logs/my GET /audit/logs/count -GET /getuserschema/ -GET /getuser/ -GET /getallusers/ ``` ## Help diff --git a/contracts/manifest.json b/contracts/manifest.json index 719a09e..22e0996 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c" + "sha256": "3cb27b1a6f83ad0619e6b086b314303d15b4d23ce0c9ab318c9cbe720d974c40" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 15f2abe..ddd1df1 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -19425,108 +19425,6 @@ ] } }, - "/api/v1/network-schemas/user": { - "get": { - "description": "获取指定网络的用户模式定义", - "operationId": "get_network_schemas_user", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "object" - }, - "title": "Response Get Network Schemas User", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取用户模式", - "tags": [ - "Users" - ] - } - }, "/api/v1/network-schemas/valve": { "get": { "description": "获取指定水网中所有阀门的架构和字段定义", @@ -47552,237 +47450,6 @@ ] } }, - "/api/v1/users": { - "get": { - "description": "获取指定网络的所有用户列表", - "operationId": "get_users", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 100, - "maximum": 1000, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_dict_Any__Any__" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取所有用户", - "tags": [ - "Users" - ] - } - }, - "/api/v1/users/detail": { - "get": { - "description": "获取指定网络中的单个用户信息", - "operationId": "get_users_detail", - "parameters": [ - { - "description": "用户名", - "in": "query", - "name": "user_name", - "required": true, - "schema": { - "description": "用户名", - "title": "User Name", - "type": "string" - } - }, - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Get Users Detail", - "type": "object" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Authentication required" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Insufficient permission" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource not found" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Resource conflict" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Validation error" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - }, - "description": "Dependency unavailable" - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "summary": "获取单个用户", - "tags": [ - "Users" - ] - } - }, "/api/v1/valve-closure-analyses": { "post": { "description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。", diff --git a/scripts/main.py b/scripts/main.py index 7247e14..902f9ef 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -138,7 +138,6 @@ from app.services.tjnetwork import ( get_all_sensor_placements, get_all_service_areas, get_all_tanks, - get_all_users, get_all_valves, get_all_vertex_links, get_all_vertices, @@ -238,8 +237,6 @@ from app.services.tjnetwork import ( get_time_schema, get_title, get_title_schema, - get_user, - get_user_schema, get_valve, get_valve_schema, get_vertex, @@ -2910,24 +2907,6 @@ async def fastapi_get_all_scada_info(network: str) -> list[dict[str, float]]: return get_all_scada_info(network) -########################################################### -# user 39 -########################################################### -@app.get("/getuserschema/") -async def fastapi_get_user_schema(network: str) -> dict[str, dict[Any, Any]]: - return get_user_schema(network) - - -@app.get("/getuser/") -async def fastapi_get_user(network: str, user_name: str) -> dict[Any, Any]: - return get_user(network, user_name) - - -@app.get("/getallusers/") -async def fastapi_get_all_users(network: str) -> list[dict[Any, Any]]: - return get_all_users(network) - - ############################################################ # scheme 40 ############################################################ diff --git a/scripts/main_api_endpoints.md b/scripts/main_api_endpoints.md index 89e2132..aed26a8 100644 --- a/scripts/main_api_endpoints.md +++ b/scripts/main_api_endpoints.md @@ -327,9 +327,6 @@ Non-commented FastAPI routes defined in `scripts/main.py`. - `GET /getscadainfoschema/` - `GET /getscadainfo/` - `GET /getallscadainfo/` -- `GET /getuserschema/` -- `GET /getuser/` -- `GET /getallusers/` - `GET /getschemeschema/` - `GET /getscheme/` - `GET /getallschemes/` diff --git a/scripts/online_Analysis.py b/scripts/online_Analysis.py index e615ac0..72b2dba 100644 --- a/scripts/online_Analysis.py +++ b/scripts/online_Analysis.py @@ -1145,53 +1145,6 @@ def submit_scada_info(name: str, coord_id: str) -> None: print(f"scada_info文件不存在。") -# 2025/03/23 -def create_user(name: str, username: str, password: str): - """ - 创建用户 - :param name: 数据库名称 - :param username: 用户名 - :param password: 密码 - :return: - """ - try: - # 动态替换数据库名称 - conn_string = get_pgconn_string(db_name=name) - # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute( - "INSERT INTO users (username, password) VALUES (%s, %s)", - (username, password), - ) - # 提交事务 - conn.commit() - print("新用户创建成功!") - except Exception as e: - print(f"创建用户出错:{e}") - - -# 2025/03/23 -def delete_user(name: str, username: str): - """ - 删除用户 - :param name: 数据库名称 - :param username: 用户名 - :return: - """ - try: - # 动态替换数据库名称 - conn_string = get_pgconn_string(db_name=name) - # 连接到 PostgreSQL 数据库(这里是数据库 "bb") - with psycopg.connect(conn_string) as conn: - with conn.cursor() as cur: - cur.execute("DELETE FROM users WHERE username = %s", (username,)) - conn.commit() - print(f"用户 {username} 删除成功!") - except Exception as e: - print(f"删除用户出错:{e}") - - # 2025/03/23 def scheme_name_exists(name: str, scheme_name: str) -> bool: """ @@ -1572,12 +1525,6 @@ if __name__ == "__main__": # burst_analysis(name='bb', modify_pattern_start_time='2025-04-17T00:00:00+08:00', # burst_ID='GSD230112144241FA18292A84CB', burst_size=400, modify_total_duration=1800, scheme_name='GSD230112144241FA18292A84CB_400') - # 示例:create_user - # create_user(name=project_info.name, username='tjwater dev', password='123456') - - # # 示例:delete_user - # delete_user(name=project_info.name, username='admin_test') - # # 示例:query_scheme_list # result = query_scheme_list(name=project_info.name) # print(result) diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index 3d2d169..c7542dd 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -77,6 +77,20 @@ def test_handler_router_defines_only_the_public_rest_operations() -> None: assert ("GET", "/getpipeproperties/") not in source_operations +def test_legacy_project_user_operations_are_not_exposed() -> None: + source_paths = { + route.path + for route in source_api_router.routes + if isinstance(route, APIRoute) + } + + assert { + "/network-schemas/user", + "/users", + "/users/detail", + }.isdisjoint(source_paths) + + def test_rest_openapi_satisfies_contract_invariants() -> None: from fastapi import FastAPI From c126e99b6069b78b675a6222139d417e7820416d Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 11:12:31 +0800 Subject: [PATCH 80/93] docs(api): align project lock contract --- app/api/v1/endpoints/project.py | 2 +- contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index cf34a77..aeeebfb 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -260,7 +260,7 @@ async def is_project_locked_endpoint( """ return network in lockedPrjs.keys() -@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。") +@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前访问地址 (IP) 锁定。") async def is_project_locked_by_me_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None diff --git a/contracts/manifest.json b/contracts/manifest.json index 22e0996..c11cbce 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "3cb27b1a6f83ad0619e6b086b314303d15b4d23ce0c9ab318c9cbe720d974c40" + "sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index ddd1df1..f6ddeac 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -27328,7 +27328,7 @@ }, "/api/v1/projects/current/lock/ownership": { "get": { - "description": "检查指定项目是否被当前客户端 (IP) 锁定。", + "description": "检查指定项目是否被当前访问地址 (IP) 锁定。", "operationId": "get_projects_current_lock_ownership", "parameters": [ { From 87d922ea612f33540166f363c94cf7034852194c Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 11:18:38 +0800 Subject: [PATCH 81/93] refactor(db): retain scheme start time as text --- .../sql/003_normalize_timestamp_columns.sql | 16 ---------------- resources/sql/create/40.scheme_list.sql | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/resources/sql/003_normalize_timestamp_columns.sql b/resources/sql/003_normalize_timestamp_columns.sql index bd99af4..c4a13b9 100644 --- a/resources/sql/003_normalize_timestamp_columns.sql +++ b/resources/sql/003_normalize_timestamp_columns.sql @@ -44,20 +44,4 @@ BEGIN ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE USING "timestamp" AT TIME ZONE ''UTC'''; END IF; - - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'scheme_list' - AND column_name = 'scheme_start_time' - AND data_type IN ('character varying', 'text') - ) THEN - EXECUTE 'ALTER TABLE public.scheme_list - ALTER COLUMN scheme_start_time TYPE TIMESTAMP WITH TIME ZONE - USING CASE - WHEN scheme_start_time ~ ''(Z|[+-][0-9]{2}:[0-9]{2})$'' THEN scheme_start_time::timestamptz - ELSE scheme_start_time::timestamp AT TIME ZONE ''UTC'' - END'; - END IF; END $$; diff --git a/resources/sql/create/40.scheme_list.sql b/resources/sql/create/40.scheme_list.sql index 7d5ea3a..5563403 100644 --- a/resources/sql/create/40.scheme_list.sql +++ b/resources/sql/create/40.scheme_list.sql @@ -9,6 +9,6 @@ create table scheme_list ( scheme_type varchar(32) not null, username varchar(32) not null, create_time TIMESTAMP WITH TIME ZONE not null DEFAULT date_trunc('minute', CURRENT_TIMESTAMP), - scheme_start_time TIMESTAMP WITH TIME ZONE not null, + scheme_start_time varchar(50) not null, scheme_detail JSON ) From b0e9d480efce1245f054bef31d42ee41cfd306b1 Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 18:34:13 +0800 Subject: [PATCH 82/93] =?UTF-8?q?refactor(sensor):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=81=B5=E6=95=8F=E5=BA=A6=E7=9B=91=E6=B5=8B=E7=82=B9=E5=B8=83?= =?UTF-8?q?=E7=BD=AE=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/algorithms/sensor/sensitivity.py | 1543 +++++++++++++---------- docs/sensor-sensitivity-optimization.md | 270 ++++ tests/unit/test_sensor_sensitivity.py | 391 ++++++ 3 files changed, 1540 insertions(+), 664 deletions(-) create mode 100644 docs/sensor-sensitivity-optimization.md create mode 100644 tests/unit/test_sensor_sensitivity.py diff --git a/app/algorithms/sensor/sensitivity.py b/app/algorithms/sensor/sensitivity.py index ef09fad..618c08a 100644 --- a/app/algorithms/sensor/sensitivity.py +++ b/app/algorithms/sensor/sensitivity.py @@ -1,701 +1,916 @@ -# 改进灵敏度法 -import networkx +"""Pressure sensor placement based on scalable sensitivity analysis. + +The original implementation expanded a sparse water network into several dense +``node x node``, ``node x pipe``, and ``pipe x pipe`` matrices. That made the +memory requirement quadratic and the explicit matrix inverse cubic in time. + +This module keeps one algorithm for every network size: + +* run EPANET once and reuse the first hydraulic state; +* keep incidence and hydraulic graphs sparse; +* estimate the row-wise L1 pressure sensitivity with deterministic Cauchy + projections and one sparse factorization; +* estimate total directed hydraulic distance from a deterministic spatial + coreset without materialising an all-pairs distance matrix; +* balance sensitivity score with geographic and pipe-network coverage without + materialising candidate-to-candidate distances. + +The random seed and sample counts are fixed, so the same model and request +produce the same placement on every run. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from time import perf_counter + import numpy as np -import pandas import wntr -import pandas as pd -import copy -import matplotlib.pyplot as plt -import networkx as nx -from sklearn.cluster import KMeans -from wntr.epanet.toolkit import EpanetException -from numpy.linalg import slogdet -import random -from matplotlib.lines import Line2D -from sklearn.cluster import SpectralClustering -import libpysal as ps -from spopt.region import Skater -from shapely.geometry import Point -import geopandas as gpd -from sklearn.metrics import pairwise_distances -import app.services.project_info as project_info +from scipy.sparse import csr_matrix, eye +from scipy.sparse.csgraph import connected_components, dijkstra +from scipy.sparse.linalg import splu +from sklearn.cluster import MiniBatchKMeans -# 2025/03/12 -# Step1: 获取节点坐标 -def getCoor(wn: wntr.network.WaterNetworkModel) -> pandas.DataFrame: +logger = logging.getLogger(__name__) + +_RANDOM_SEED = 42 +_SENSITIVITY_PROJECTIONS = 256 +_HYDRAULIC_LANDMARKS = 256 +_PROJECTION_BLOCK_SIZE = 16 +_DIJKSTRA_BLOCK_SIZE = 16 +_HEADLOSS_EPSILON = 1e-10 +_DIAMETER_TOLERANCE_MM = 1e-9 +_COVERAGE_ELIGIBILITY_RATIO = 0.70 +_COVERAGE_EDGE_EPSILON = 1e-9 + + +@dataclass(frozen=True) +class _PreparedNetwork: + """Sparse data required by the placement pipeline.""" + + node_names: tuple[str, ...] + full_node_indices: np.ndarray + candidate_indices: np.ndarray + coordinates: np.ndarray + incidence: csr_matrix + conductance: np.ndarray + roughness_response: np.ndarray + distance_graph: csr_matrix + coverage_graph: csr_matrix + + +@dataclass(frozen=True) +class _CandidatePool: + """Aligned candidate arrays consumed by the placement stage.""" + + full_indices: np.ndarray + coordinates: np.ndarray + names: np.ndarray + scores: np.ndarray + + +def _run_hydraulic_simulation( + wn: wntr.network.WaterNetworkModel, +): + """Run only the initial EPANET state without shared ``temp.*`` files.""" + + original_duration = wn.options.time.duration + try: + # Every downstream calculation reads ``iloc[0]``. Running an extended + # simulation only allocates unused time-series results, which is + # especially expensive for daily models with tens of thousands of + # nodes. Restore the caller's model even when EPANET fails. + wn.options.time.duration = 0 + with TemporaryDirectory(prefix="tjwater-sensitivity-") as temp_dir: + file_prefix = str(Path(temp_dir) / "simulation") + return wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=file_prefix) + finally: + wn.options.time.duration = original_duration + + +def _excluded_elements( + wn: wntr.network.WaterNetworkModel, +) -> tuple[set[str], set[str]]: + """Return nodes that cannot host sensors and source-connected pipes. + + Reservoirs, tanks, pump/valve endpoints, and the junction immediately next + to a reservoir or tank are treated as hydraulic boundary nodes. Pipes + connected directly to a source are removed from the perturbation set, as + in the legacy algorithm. """ - 获取管网模型的节点坐标 - :param wn: 由wntr生成的模型 - :return: 节点坐标 - """ - # site: pandas.Series - # index:节点名称(wn.node_name_list) - # values:每个节点的坐标,格式为 tuple(如 (x, y) 或 (x, y, z)) - site = wn.query_node_attribute("coordinates") - # Coor: pandas.Series - # index:与site相同(节点名称)。 - # values:坐标转换为numpy.ndarray(如array([10.5, 20.3])) - Coor = site.apply(lambda x: np.array(x)) # 将节点坐标转换为numpy数组 - # x, y: list[float] - x = [] # 存储所有节点的 x 坐标 - y = [] # 存储所有节点的 y 坐标 - for i in range(0, len(Coor)): - x.append(Coor.values[i][0]) # 将 x 坐标存入 x 列表。 - y.append(Coor.values[i][1]) # 将 y 坐标存入 y 列表 - # xy: dict[str, list], x、y 坐标的字典 - xy = {"x": x, "y": y} - # Coor_node: pandas.DataFrame, 存储节点 x, y 坐标的 DataFrame - Coor_node = pd.DataFrame(xy, index=wn.node_name_list, columns=["x", "y"]) - return Coor_node + + source_nodes = set(wn.reservoir_name_list) | set(wn.tank_name_list) + excluded_nodes = set(source_nodes) + source_pipes: set[str] = set() + + for pipe_name, pipe in wn.pipes(): + endpoints = {pipe.start_node_name, pipe.end_node_name} + if endpoints & source_nodes: + source_pipes.add(pipe_name) + excluded_nodes.update(endpoints) + + for _link_name, link in list(wn.pumps()) + list(wn.valves()): + excluded_nodes.add(link.start_node_name) + excluded_nodes.add(link.end_node_name) + + return excluded_nodes, source_pipes -# 2025/03/12 -# Step2: KMeans 聚类 -# 将节点用kmeans根据坐标分为k组,存入字典g -def kgroup(coor: pandas.DataFrame, knum: int) -> dict[int, list[str]]: - """ - 使用KMeans聚类,将节点坐标分组 - :param coor: 存储所有节点的坐标数据 - :param knum: 需要分成的聚类数 - :return: 聚类结果字典 - """ - g = {} - # estimator: sklearn.cluster.KMeans,KMeans 聚类模型 - estimator = KMeans(n_clusters=knum) - estimator.fit(coor) - # label_pred: numpy.ndarray(int),每个点的类别标签 - label_pred = estimator.labels_ - for i in range(0, knum): - g[i] = coor[label_pred == i].index.tolist() - return g +def _minimum_weight_csr( + rows: list[int], + columns: list[int], + weights: list[float], + *, + shape: tuple[int, int], +) -> csr_matrix: + """Build a CSR graph while retaining the lightest parallel edge.""" + if not rows: + return csr_matrix(shape, dtype=np.float64) -def skater_partition(G, n_clusters): - """ - 使用 SKATER 算法对输入的无向图 G 进行区域划分, - 保证每个划分区域在图论意义上是连通的, - 同时依据节点坐标的空间信息进行划分。 + row_array = np.asarray(rows, dtype=np.int64) + column_array = np.asarray(columns, dtype=np.int64) + weight_array = np.asarray(weights, dtype=np.float64) + order = np.lexsort((column_array, row_array)) + row_array = row_array[order] + column_array = column_array[order] + weight_array = weight_array[order] - 参数: - G: networkx.Graph - 带有节点坐标属性(键为 'pos')的无向图。 - n_clusters: int - 希望划分的区域数量。 - - 返回: - groups: dict - 字典形式的聚类结果,键为区域编号,值为该区域内的节点列表。 - """ - # 1. 获取所有节点坐标,假设每个节点都有 'pos' 属性 - pos = nx.get_node_attributes(G, "pos") - nodes = list(G.nodes()) - # 构造坐标数组:每行为 [x, y] - coords = np.array([pos[node] for node in nodes]) - - # 2. 构造 GeoDataFrame:创建 DataFrame 并生成 geometry 列 - df = pd.DataFrame(coords, columns=["x", "y"], index=nodes) - # 利用 shapely 的 Point 构造空间位置 - df["geometry"] = df.apply(lambda row: Point(row["x"], row["y"]), axis=1) - gdf = gpd.GeoDataFrame(df, geometry="geometry") - - # 3. 构造空间权重矩阵,使用 4 近邻方法(k=4,可根据实际情况调整) - w = ps.weights.KNN.from_array(coords, k=4) - w.transform = "R" - - # 4. 调用 SKATER:新版本 API 要求传入 gdf, w 以及 attrs_name(这里使用 'x' 和 'y' 作为属性) - skater = Skater(gdf, w, attrs_name=["x", "y"], n_clusters=n_clusters) - skater.solve() - - # 5. 获取聚类标签,构造成字典格式 - labels = skater.labels_ - groups = {} - for label, node in zip(labels, nodes): - groups.setdefault(label, []).append(node) - - return groups - - -def spectral_partition(G, n_clusters): - """ - 利用谱聚类算法对图 G 进行分区: - 1. 根据所有节点的空间坐标计算欧氏距离矩阵; - 2. 利用高斯核函数构造相似度矩阵; - 3. 使用 SpectralClustering 进行归一化割,返回分区结果。 - - 参数: - G: networkx.Graph - 每个节点需要有 'pos' 属性,其值为 (x, y) 坐标。 - n_clusters: int - 希望划分的聚类数目。 - - 返回: - groups: dict - 键为聚类标签,值为该聚类对应的节点列表。 - """ - # 1. 获取节点空间坐标,注意保证每个节点都有 'pos' 属性 - pos_dict = nx.get_node_attributes(G, "pos") - nodes = list(G.nodes()) - coords = np.array([pos_dict[node] for node in nodes]) - - # 2. 计算节点之间的欧氏距离矩阵 - D = pairwise_distances(coords, metric="euclidean") - - # 3. 计算 sigma 值:这里取所有距离的均值,当然也可以根据实际情况调整 - sigma = np.mean(D) - - # 4. 构造相似度矩阵:使用高斯核函数 - # A(i, j) = exp( -d(i,j)^2 / (2*sigma^2) ) - A = np.exp(-(D**2) / (2 * sigma**2)) - - # 5. 使用谱聚类进行图分区 - clustering = SpectralClustering( - n_clusters=n_clusters, affinity="precomputed", random_state=0 + group_start = np.empty(len(row_array), dtype=bool) + group_start[0] = True + group_start[1:] = (row_array[1:] != row_array[:-1]) | ( + column_array[1:] != column_array[:-1] + ) + starts = np.flatnonzero(group_start) + minimum_weights = np.minimum.reduceat(weight_array, starts) + return csr_matrix( + (minimum_weights, (row_array[starts], column_array[starts])), + shape=shape, ) - labels = clustering.fit_predict(A) - - # 6. 构造字典形式的分区结果 - groups = {} - for label, node in zip(labels, nodes): - groups.setdefault(label, []).append(node) - - return groups -# 2025/03/12 -# Step3: wn_func类,水力计算 -# wn_func 主要用于计算: -# 水力距离(hydraulic length):即节点之间的水力阻力。 -# 灵敏度分析(sensitivity analysis):用于优化测压点的布置。 -# 一些与水力相关的函数,包括 CtoS:求水力距离,stafun:求状态函数F -# # diff:求F对P的导数,返回灵敏度矩阵A -# # sensitivity:返回灵敏度和总灵敏度 -class wn_func(object): +def _node_coordinates( + wn: wntr.network.WaterNetworkModel, + node_names: tuple[str, ...], +) -> np.ndarray: + coordinate_series = wn.query_node_attribute("coordinates") + coordinates = np.asarray( + [coordinate_series.loc[node_name] for node_name in node_names], + dtype=np.float64, + ) + if coordinates.ndim != 2 or coordinates.shape[1] < 2: + raise ValueError("管网节点缺少二维坐标,无法进行监测点空间布置") + coordinates = coordinates[:, :2] + if not np.isfinite(coordinates).all(): + raise ValueError("管网节点坐标包含非有限值,无法进行监测点空间布置") + return coordinates - # Step3.1: 初始化 - def __init__(self, wn: wntr.network.WaterNetworkModel, min_diameter: int): - """ - 获取管网模型信息 - :param wn: 由wntr生成的模型 - :param min_diameter: 安装的最小管径 - """ - # self.results: wntr.sim.results.SimulationResults,仿真结果,包含压力、流量、水头等数据 - self.results = wntr.sim.EpanetSimulator(wn).run_sim() # 存储运行结果 - self.wn = wn - # self.q:pandas.DataFrame,管道流量,索引为时间步长,列为管道名称 - self.q = self.results.link["flowrate"] - # ReservoirIndex / Tankindex: list[str],水库 / 水箱节点名称列表 - ReservoirIndex = wn.reservoir_name_list - Tankindex = wn.tank_name_list - # 删除水库节点,删除与直接水库相连的虚拟管道 - # self.pipes: list[str],所有管道的名称 - self.pipes = wn.pipe_name_list - # self.nodes: list[str],所有节点的名称 - self.nodes = wn.node_name_list - # self.coordinates:pandas.Series,节点坐标,索引为节点名,值为 (x, y) 坐标的 tuple - self.coordinates = wn.query_node_attribute("coordinates") - # allpumps / allvalves: list[str],所有泵/阀门名称列表 - allpumps = wn.pump_name_list - allvalves = wn.valve_name_list - # pumpstnode / pumpednode / valvestnode / valveednode: list[str],存储泵和阀门 起终点节点的名称 - pumpstnode = [] - pumpednode = [] - valvestnode = [] - valveednode = [] - # Reservoirpipe / Reservoirednode: list[str],记录与水库相关的管道和节点 - Reservoirpipe = [] - Reservoirednode = [] - for pump in allpumps: - pumpstnode.append(wn.links[pump].start_node.name) - pumpednode.append(wn.links[pump].end_node.name) - for valve in allvalves: - valvestnode.append(wn.links[valve].start_node.name) - valveednode.append(wn.links[valve].end_node.name) - for pipe in self.pipes: - if wn.links[pipe].start_node.name in ReservoirIndex: - Reservoirpipe.append(pipe) - Reservoirednode.append(wn.links[pipe].end_node.name) - if wn.links[pipe].start_node.name in Tankindex: - Reservoirpipe.append(pipe) - Reservoirednode.append(wn.links[pipe].end_node.name) - if wn.links[pipe].end_node.name in Tankindex: - Reservoirpipe.append(pipe) - Reservoirednode.append(wn.links[pipe].start_node.name) - # 泵的起终点、tank、reservoir - # self.delnodes: list[str],需要删除的节点(包括水库、泵、阀门连接的节点) - self.delnodes = list( - set(ReservoirIndex).union( - Tankindex, - pumpstnode, - pumpednode, - valvestnode, - valveednode, - Reservoirednode, + +def _build_coverage_graph( + wn: wntr.network.WaterNetworkModel, + results, + full_node_index: dict[str, int], +) -> csr_matrix: + """Build the active undirected physical graph used to spread sensors.""" + + status_series = results.link["status"].iloc[0] + rows: list[int] = [] + columns: list[int] = [] + weights: list[float] = [] + + for link_name, link in wn.links(): + if float(status_series.loc[link_name]) <= 0: + continue + + start = full_node_index[link.start_node_name] + end = full_node_index[link.end_node_name] + # Pipes carry their physical length. Pumps and valves are point + # devices, so a tiny positive length preserves connectivity without + # dominating shortest-path distance. + weight = max( + float(getattr(link, "length", 0.0)), + _COVERAGE_EDGE_EPSILON, + ) + rows.extend((start, end)) + columns.extend((end, start)) + weights.extend((weight, weight)) + + return _minimum_weight_csr( + rows, + columns, + weights, + shape=(len(full_node_index), len(full_node_index)), + ) + + +def _prepare_network( + wn: wntr.network.WaterNetworkModel, + results, + *, + min_diameter: int, +) -> _PreparedNetwork: + excluded_nodes, source_pipes = _excluded_elements(wn) + full_node_names = tuple(wn.node_name_list) + full_node_index = { + node_name: index for index, node_name in enumerate(full_node_names) + } + node_names = tuple( + node_name for node_name in full_node_names if node_name not in excluded_nodes + ) + if not node_names: + raise ValueError("管网中没有可参与灵敏度分析的节点") + + node_index = {node_name: index for index, node_name in enumerate(node_names)} + full_node_indices = np.asarray( + [full_node_index[node_name] for node_name in node_names], + dtype=np.int64, + ) + coordinates = _node_coordinates(wn, node_names) + + flow_series = results.link["flowrate"].iloc[0] + headloss_series = results.link["headloss"].iloc[0] + head_series = results.node["head"].iloc[0] + + candidate_nodes: set[str] = set() + for _pipe_name, pipe in wn.pipes(): + diameter_mm = float(pipe.diameter) * 1000.0 + if diameter_mm + _DIAMETER_TOLERANCE_MM < min_diameter: + continue + if pipe.start_node_name in node_index: + candidate_nodes.add(pipe.start_node_name) + if pipe.end_node_name in node_index: + candidate_nodes.add(pipe.end_node_name) + + incidence_rows: list[int] = [] + incidence_columns: list[int] = [] + incidence_values: list[float] = [] + conductance: list[float] = [] + roughness_response: list[float] = [] + distance_rows: list[int] = [] + distance_columns: list[int] = [] + distance_weights: list[float] = [] + + kept_pipe_count = 0 + for pipe_name, pipe in wn.pipes(): + if pipe_name in source_pipes: + continue + + start_name = pipe.start_node_name + end_name = pipe.end_node_name + if start_name not in node_index and end_name not in node_index: + continue + + flow = float(flow_series.loc[pipe_name]) + absolute_flow = abs(flow) + headloss = abs(float(headloss_series.loc[pipe_name])) + roughness = float(pipe.roughness) + if roughness <= 0: + raise ValueError(f"管道 {pipe_name} 的粗糙度必须大于 0") + + orientation = -1.0 if flow < 0 else 1.0 + if start_name in node_index: + incidence_rows.append(node_index[start_name]) + incidence_columns.append(kept_pipe_count) + incidence_values.append(-orientation) + if end_name in node_index: + incidence_rows.append(node_index[end_name]) + incidence_columns.append(kept_pipe_count) + incidence_values.append(orientation) + + conductance.append( + absolute_flow / (1.852 * headloss + _HEADLOSS_EPSILON) + ) + roughness_response.append(absolute_flow / roughness) + + if flow > 0: + upstream_name, downstream_name = start_name, end_name + else: + upstream_name, downstream_name = end_name, start_name + hydraulic_weight = ( + abs(float(head_series.loc[start_name]) - float(head_series.loc[end_name])) + * float(pipe.length) + ) + distance_rows.append(full_node_index[upstream_name]) + distance_columns.append(full_node_index[downstream_name]) + distance_weights.append(hydraulic_weight) + kept_pipe_count += 1 + + if kept_pipe_count == 0: + raise ValueError("管网中没有可用于灵敏度分析的管道") + + incidence = csr_matrix( + ( + np.asarray(incidence_values, dtype=np.float64), + ( + np.asarray(incidence_rows, dtype=np.int64), + np.asarray(incidence_columns, dtype=np.int64), + ), + ), + shape=(len(node_names), kept_pipe_count), + ) + conductance_array = np.asarray(conductance, dtype=np.float64) + response_array = np.asarray(roughness_response, dtype=np.float64) + if not np.isfinite(conductance_array).all() or not np.isfinite( + response_array + ).all(): + raise ValueError("水力结果产生了非有限灵敏度系数") + + distance_graph = _minimum_weight_csr( + distance_rows, + distance_columns, + distance_weights, + shape=(len(full_node_names), len(full_node_names)), + ) + coverage_graph = _build_coverage_graph(wn, results, full_node_index) + candidate_indices = np.asarray( + [ + index + for index, node_name in enumerate(node_names) + if node_name in candidate_nodes + ], + dtype=np.int64, + ) + + return _PreparedNetwork( + node_names=node_names, + full_node_indices=full_node_indices, + candidate_indices=candidate_indices, + coordinates=coordinates, + incidence=incidence, + conductance=conductance_array, + roughness_response=response_array, + distance_graph=distance_graph, + coverage_graph=coverage_graph, + ) + + +def _axis_normalized_coordinates(coordinates: np.ndarray) -> np.ndarray: + """Scale each axis independently for MiniBatchKMeans.""" + + minimum = coordinates.min(axis=0) + span = np.ptp(coordinates, axis=0) + span[span == 0] = 1.0 + return (coordinates - minimum) / span + + +def _isotropic_coordinates(coordinates: np.ndarray) -> np.ndarray: + """Normalize coordinates without distorting the network aspect ratio.""" + + minimum = coordinates.min(axis=0) + scale = float(np.max(np.ptp(coordinates, axis=0), initial=0.0)) + if scale == 0: + scale = 1.0 + return (coordinates - minimum) / scale + + +def _cluster_labels( + coordinates: np.ndarray, + cluster_count: int, + *, + random_seed: int, +) -> tuple[np.ndarray, np.ndarray]: + """Cluster coordinates deterministically with one implementation at all sizes.""" + + normalized = _axis_normalized_coordinates(coordinates) + if cluster_count == 1: + return np.zeros(len(coordinates), dtype=np.int64), normalized[[0]] + if cluster_count >= len(coordinates): + return np.arange(len(coordinates), dtype=np.int64), normalized.copy() + + model = MiniBatchKMeans( + n_clusters=cluster_count, + random_state=random_seed, + n_init=3, + batch_size=min(len(coordinates), max(1024, cluster_count * 3)), + max_iter=100, + max_no_improvement=20, + reassignment_ratio=0.0, + ) + labels = model.fit_predict(normalized).astype(np.int64, copy=False) + return labels, np.asarray(model.cluster_centers_, dtype=np.float64) + + +def _estimate_log_pressure_sensitivity(prepared: _PreparedNetwork) -> np.ndarray: + """Estimate each row's L1 sensitivity using streaming Cauchy projections.""" + + weighted_incidence = prepared.incidence.multiply(prepared.conductance) + laplacian = (weighted_incidence @ prepared.incidence.T).tocsc() + diagonal = np.asarray(laplacian.diagonal(), dtype=np.float64) + diagonal_scale = float(np.max(np.abs(diagonal), initial=0.0)) + if diagonal_scale == 0: + raise ValueError("水力雅可比矩阵为空,无法计算压力灵敏度") + + regularization = diagonal_scale * np.sqrt(np.finfo(np.float64).eps) + laplacian = laplacian + eye( + laplacian.shape[0], format="csc", dtype=np.float64 + ) * regularization + factor = splu( + laplacian, + permc_spec="MMD_AT_PLUS_A", + diag_pivot_thresh=0.0, + options={"SymmetricMode": True}, + ) + + random = np.random.default_rng(_RANDOM_SEED) + log_absolute_sum = np.zeros(len(prepared.node_names), dtype=np.float64) + projection_count = 0 + float_epsilon = np.finfo(np.float64).eps + float_tiny = np.finfo(np.float64).tiny + + while projection_count < _SENSITIVITY_PROJECTIONS: + block_size = min( + _PROJECTION_BLOCK_SIZE, + _SENSITIVITY_PROJECTIONS - projection_count, + ) + uniform = random.random((prepared.incidence.shape[1], block_size)) + np.clip(uniform, float_epsilon, 1.0 - float_epsilon, out=uniform) + cauchy_projection = np.tan(np.pi * (uniform - 0.5)) + projected_response = prepared.incidence @ ( + prepared.roughness_response[:, None] * cauchy_projection + ) + solution = factor.solve(np.asarray(projected_response, dtype=np.float64)) + log_absolute_sum += np.log( + np.maximum(np.abs(solution), float_tiny) + ).sum(axis=1) + projection_count += block_size + + # For a standard Cauchy variable E[log(abs(X))] is zero. Therefore this + # streaming geometric mean estimates log(||row||_1) without retaining the + # node-by-projection matrix. A finite-sample bias is common to all rows and + # does not affect ranking. + return log_absolute_sum / _SENSITIVITY_PROJECTIONS + + +def _landmark_coreset(prepared: _PreparedNetwork) -> tuple[np.ndarray, np.ndarray]: + landmark_count = min(_HYDRAULIC_LANDMARKS, len(prepared.node_names)) + labels, centers = _cluster_labels( + prepared.coordinates, + landmark_count, + random_seed=_RANDOM_SEED + 1, + ) + normalized = _axis_normalized_coordinates(prepared.coordinates) + landmarks: list[int] = [] + weights: list[float] = [] + + for label in np.unique(labels): + members = np.flatnonzero(labels == label) + center = centers[int(label)] + squared_distance = np.square(normalized[members] - center).sum(axis=1) + landmarks.append(int(members[int(np.argmin(squared_distance))])) + weights.append(float(len(members))) + + return ( + np.asarray(landmarks, dtype=np.int64), + np.asarray(weights, dtype=np.float64), + ) + + +def _estimate_hydraulic_distance_sums(prepared: _PreparedNetwork) -> np.ndarray: + """Estimate outbound distance sums without an all-pairs distance matrix.""" + + landmark_indices, landmark_weights = _landmark_coreset(prepared) + full_landmark_indices = prepared.full_node_indices[landmark_indices] + reversed_graph = prepared.distance_graph.transpose().tocsr() + distance_sums = np.zeros(len(prepared.node_names), dtype=np.float64) + + for start in range(0, len(landmark_indices), _DIJKSTRA_BLOCK_SIZE): + stop = min(start + _DIJKSTRA_BLOCK_SIZE, len(landmark_indices)) + distances = dijkstra( + reversed_graph, + directed=True, + indices=full_landmark_indices[start:stop], + return_predecessors=False, + ) + distances = np.atleast_2d(distances)[:, prepared.full_node_indices] + # The legacy matrix represented unreachable pairs as zero. Retaining + # that convention prevents disconnected branches from receiving an + # artificial infinite score. + distances[~np.isfinite(distances)] = 0.0 + distance_sums += landmark_weights[start:stop] @ distances + + return distance_sums + + +def _build_candidate_pool( + prepared: _PreparedNetwork, + log_sensitivity: np.ndarray, + hydraulic_distance_sums: np.ndarray, +) -> _CandidatePool: + candidate_indices = prepared.candidate_indices + candidate_distance = hydraulic_distance_sums[candidate_indices] + with np.errstate(divide="ignore", invalid="ignore"): + scores = log_sensitivity[candidate_indices] + np.log(candidate_distance) + scores = np.nan_to_num( + scores, + nan=-np.inf, + neginf=-np.inf, + posinf=np.finfo(np.float64).max, + ) + return _CandidatePool( + full_indices=prepared.full_node_indices[candidate_indices], + coordinates=_isotropic_coordinates(prepared.coordinates[candidate_indices]), + names=np.asarray( + [prepared.node_names[index] for index in candidate_indices], + dtype=str, + ), + scores=scores, + ) + + +def _highest_scoring_position( + names: np.ndarray, + scores: np.ndarray, + positions: np.ndarray, +) -> int: + """Return the best position, breaking score ties by node name.""" + + order = np.lexsort((names[positions], -scores[positions])) + return int(positions[order[0]]) + + +def _relative_gap( + distances: np.ndarray, + available: np.ndarray, +) -> np.ndarray: + """Normalize available distances to their current finite maximum.""" + + maximum = float(np.max(distances[available], initial=0.0)) + if not np.isfinite(maximum) or maximum <= np.finfo(np.float64).eps: + return np.zeros(len(distances), dtype=np.float64) + return distances / maximum + + +def _eligible_gap_positions( + relative_gap: np.ndarray, + available: np.ndarray, +) -> np.ndarray: + """Return positions within the configured fraction of the largest gap.""" + + maximum = float(np.max(relative_gap[available], initial=0.0)) + if maximum <= np.finfo(np.float64).eps: + return np.flatnonzero(available) + threshold = _COVERAGE_ELIGIBILITY_RATIO * maximum + return np.flatnonzero( + available & (relative_gap >= threshold - np.finfo(np.float64).eps) + ) + + +def _allocate_component_quotas( + coverage_graph: csr_matrix, + candidate_full_indices: np.ndarray, + candidate_scores: np.ndarray, + *, + sensor_num: int, +) -> tuple[np.ndarray, np.ndarray]: + """Allocate sensor counts by active pipe length with candidate caps.""" + + component_count, node_components = connected_components( + coverage_graph, + directed=False, + return_labels=True, + ) + candidate_components = node_components[candidate_full_indices] + capacities = np.bincount( + candidate_components, + minlength=component_count, + ).astype(np.int64, copy=False) + + # The graph is symmetric. Summed row weights count every physical edge + # twice, hence the division by two after aggregation by component. + node_lengths = np.asarray(coverage_graph.sum(axis=1)).ravel() + component_lengths = np.bincount( + node_components, + weights=node_lengths, + minlength=component_count, + ) / 2.0 + component_best_scores = np.full(component_count, -np.inf, dtype=np.float64) + np.maximum.at( + component_best_scores, + candidate_components, + candidate_scores, + ) + + active_components = np.flatnonzero(capacities) + quotas = np.zeros(component_count, dtype=np.int64) + if len(active_components) > sensor_num: + order = np.lexsort( + ( + active_components, + -component_best_scores[active_components], + -component_lengths[active_components], ) ) - # 泵、起终点为tank、reservoir的管道 - # self.delpipes: list[str],需要删除的管道(包括水库、泵、阀门连接的管道) - self.delpipes = list( - set(wn.pump_name_list).union(wn.valve_name_list).union(Reservoirpipe) - ) - self.pipes = [pipe for pipe in wn.pipe_name_list if pipe not in self.delpipes] - # self.L: list[float],所有管道的长度(以米为单位) - self.L = wn.query_link_attribute("length")[self.pipes].tolist() - self.n = len(self.nodes) - self.m = len(self.pipes) - # self.unit_headloss: list[float],单位水头损失(headloss 数据的第一行,单位:米/km) - self.unit_headloss = self.results.link["headloss"].iloc[0, :].tolist() - ## - self.delnodes1 = list(set(ReservoirIndex).union(Tankindex)) + quotas[active_components[order[:sensor_num]]] = 1 + return candidate_components, quotas - # === 改动新增部分:筛选管径小于 min_diameter 的管道节点 === - self.less_than_min_diameter_junction_list = [] - for pipe in self.pipes: - diameter = wn.links[pipe].diameter - if diameter < min_diameter: - start_node = wn.links[pipe].start_node.name - end_node = wn.links[pipe].end_node.name - self.less_than_min_diameter_junction_list.extend([start_node, end_node]) - # 去重 - self.less_than_min_diameter_junction_list = list( - set(self.less_than_min_diameter_junction_list) - ) - - # Step3.2: 计算水力距离 - def CtoS(self): - """ - 计算水力距离矩阵 - :return: - """ - # 水力距离:当行索引对应的节点为控制点时,列索引对应的节点距离控制点的(路径*水头损失)的最小值 - # nodes:list[str](节点名称) - nodes = copy.deepcopy(self.nodes) - # pipes:list[str](管道名称) - pipes = self.pipes - wn = self.wn - # n / m:int(节点数 / 管道数) - n = self.n - m = self.m - s1 = [0] * m - q = self.q - L = self.L - # H1:pandas.DataFrame,水头数据,索引为时间步长,列为节点名 - H1 = self.results.node["head"].T - # hh:list[float],计算管道两端水头之差 - hh = [] - # 水头损失 - for p in pipes: - h1 = self.wn.links[p].start_node.name - h1 = H1.loc[str(h1)] - h2 = self.wn.links[p].end_node.name - h2 = H1.loc[str(h2)] - hh.append(abs(h1 - h2)) - hh = np.array(hh) - # headloss:pandas.DataFrame,管道水头损失矩阵 - headloss = pd.DataFrame(hh, index=pipes).T - # s1:管道阻力系数,s2:将管道阻力系数与管道的起始节点和终止节点对应 - hf = pd.DataFrame( - np.array([0] * (n**2)).reshape(n, n), - index=nodes, - columns=nodes, - dtype=float, - ) - weightL = pd.DataFrame( - np.array([0] * (n**2)).reshape(n, n), - index=nodes, - columns=nodes, - dtype=float, - ) - # s2为对应管道起始节点与终止节点的粗糙度系数矩阵,index代表起始节点,columns代表终止节点 - G = nx.DiGraph() - for i in range(0, m): - pipe = pipes[i] - a = wn.links[pipe].start_node.name - b = wn.links[pipe].end_node.name - if q.loc[0, pipe] > 0: - hf.loc[a, b] = headloss.loc[0, pipe] - weightL.loc[a, b] = headloss.loc[0, pipe] * L[i] - G.add_weighted_edges_from([(a, b, weightL.loc[a, b])]) - - else: - hf.loc[b, a] = headloss.loc[0, pipe] - weightL.loc[b, a] = headloss.loc[0, pipe] * L[i] - G.add_weighted_edges_from([(b, a, weightL.loc[b, a])]) - - hydraulicL = pd.DataFrame( - np.array([0] * (n**2)).reshape(n, n), - index=nodes, - columns=nodes, - dtype=float, - ) - - for a in nodes: - if a in G.nodes: - d = nx.shortest_path_length(G, source=a, weight="weight") - for b in list(d.keys()): - hydraulicL.loc[a, b] = d[b] - - hydraulicL = hydraulicL.drop(self.delnodes) - hydraulicL = hydraulicL.drop(self.delnodes, axis=1) - - # 求加权水力距离 - return hydraulicL, G - - # Step3.3: 计算灵敏度矩阵 - # 获取关系矩阵 - def get_Conn(self): - """ - 计算管网连接关系矩阵 - :return: - """ - m = self.wn.num_links - n = self.wn.num_nodes - p = self.wn.num_pumps - v = self.wn.num_valves - - self.nonjunc_index = [] - self.non_link_index = [] - for r in self.wn.reservoirs(): - self.nonjunc_index.append(r[0]) - for t in self.wn.tanks(): - self.nonjunc_index.append(t[0]) - # Conn:numpy.matrix,节点-管道连接矩阵,起点 -1,终点 1 - Conn = np.mat( - np.zeros([n, m - p - v]) - ) # 节点和管道的关系矩阵,行为节点,列为管道,起点为-1,终点为1 - # NConn:numpy.matrix,节点-节点连接矩阵,有管道相连的地方设为 1 - NConn = np.mat(np.zeros([n, n])) # 节点之间的关系,之间有管道为1,反之为0 - # pipes:list[str],去除泵和阀门的管道列表 - pipes = [ - pipe - for pipe in self.wn.pipes() - if pipe not in self.wn.pumps() and pipe not in self.wn.valves() + quotas[active_components] = 1 + remaining = sensor_num - len(active_components) + while remaining > 0: + available = active_components[ + quotas[active_components] < capacities[active_components] ] - for pipe_name, pipe in pipes: - start = self.wn.node_name_list.index(pipe.start_node_name) - end = self.wn.node_name_list.index(pipe.end_node_name) - p_index = self.wn.link_name_list.index(pipe_name) - Conn[start, p_index] = -1 - Conn[end, p_index] = 1 - NConn[start, end] = 1 - NConn[end, start] = 1 - self.A = Conn - link_name_list = [ - link - for link in self.wn.link_name_list - if link not in self.wn.pump_name_list - and link not in self.wn.valve_name_list - ] - self.A2 = pd.DataFrame( - self.A, index=self.wn.node_name_list, columns=link_name_list + if len(available) == 0: + raise ValueError("连通区域中的候选节点不足,无法分配监测点名额") + + weights = component_lengths[available] + if float(weights.sum()) <= 0: + weights = (capacities[available] - quotas[available]).astype( + np.float64, + copy=False, + ) + ideal = remaining * weights / float(weights.sum()) + whole = np.minimum( + np.floor(ideal).astype(np.int64), + capacities[available] - quotas[available], ) - self.A2 = self.A2.drop(self.delnodes) - for pipe in self.delpipes: - if ( - pipe not in self.wn.pump_name_list - and pipe not in self.wn.valve_name_list - ): - self.A2 = self.A2.drop(columns=pipe) - self.junc_list = self.A2.index - self.A2 = np.mat(self.A2) # 节点管道关系 - self.A3 = NConn + whole_count = int(whole.sum()) + if whole_count: + quotas[available] += whole + remaining -= whole_count + continue - def Jaco(self, hL: pandas.DataFrame): - """ - 计算灵敏度矩阵(节点压力对粗糙度变化的响应) - :param hL: 水力距离矩阵 - :return: - """ - # global result - # A:numpy.matrix, 节点-管道关系矩阵 - A = self.A2 - wn = self.wn + fractional = ideal - np.floor(ideal) + order = np.lexsort( + ( + available, + -component_best_scores[available], + -weights, + -fractional, + ) + ) + for component in available[order]: + quotas[component] += 1 + remaining -= 1 + if remaining == 0: + break - try: - result = wntr.sim.EpanetSimulator(wn).run_sim() - except EpanetException: - pass - finally: - h = result.link["headloss"][self.pipes].values[0] - q = result.link["flowrate"][self.pipes].values[0] - l = self.wn.query_link_attribute("length")[self.pipes] - C = self.wn.query_link_attribute("roughness")[self.pipes] - # headloss:numpy.ndarray,水头损失数组 - headloss = np.array(h) - # 调整流量方向 - for i in range(0, len(q)): - if q[i] < 0: - A[:, i] = -A[:, i] - # q:numpy.ndarray,流量数组 - q = np.abs(q) - # 两个灵敏度矩阵 - # B / S:numpy.matrix,灵敏度计算的中间矩阵 - B = np.mat(np.diag(q / ((1.852 * headloss) + 1e-10))) - S = np.mat(np.diag(q / C)) - # X:numpy.matrix, 灵敏度矩阵 - X = A * B * A.T - try: - det = np.linalg.det(X) - except RuntimeError as e: - sign, logdet = slogdet(X) # 防止溢出 - det = sign * np.exp(logdet) - if det != 0: - J_H_Cw = X.I * A * S - # J_H_Q = -X.I - J_q_Cw = S - B * A.T * X.I * A * S # 去掉了delnodes和delpipes - # J_q_Q = B * A.T * X.I - else: # 当X不可逆 - J_H_Cw = np.linalg.pinv(X) @ A @ S - # J_H_Q = -np.linalg.pinv(X) - J_q_Cw = S - B * A.T * np.linalg.pinv(X) * A * S - # J_q_Q = B * A.T * np.linalg.pinv(X) - - Sen_pressure = [] - S_pressure = np.abs(J_H_Cw).sum(axis=1).tolist() # 修改为绝对值 - for ss in S_pressure: - Sen_pressure.append(ss[0]) - # 求总灵敏度 - SS_pressure = copy.deepcopy(hL) - for i in range(0, len(Sen_pressure)): - SS_pressure.iloc[i, :] = SS_pressure.iloc[i, :] * Sen_pressure[i] - SS = copy.deepcopy(hL) - for i in range(0, len(Sen_pressure)): - SS.iloc[i, :] = SS.iloc[i, :] * Sen_pressure[i] - # SS[i,j]:节点nodes[i]的灵敏度*该节点到nodes[j]的水力距离 - return SS + return candidate_components, quotas -# 2025/03/12 -# Step4: 传感器布置优化 -# Sensorplacement -# weight:分配权重 -# sensor:传感器布置的位置 -class Sensorplacement(wn_func): - """ - Sensorplacement 类继承了 wn_func 类,并且用于计算和优化传感器布置的位置。 +def _select_component_positions( + coverage_graph: csr_matrix, + candidates: _CandidatePool, + component_positions: np.ndarray, + existing_positions: list[int], + *, + quota: int, +) -> list[int]: + """Select one component's sensors with score-aware farthest-first search.""" + + local_coordinates = candidates.coordinates[component_positions] + local_names = candidates.names[component_positions] + local_scores = candidates.scores[component_positions] + nearest_geographic = np.full(len(component_positions), np.inf) + nearest_topological = np.full(len(component_positions), np.inf) + + for position in existing_positions: + nearest_geographic = np.minimum( + nearest_geographic, + np.linalg.norm( + local_coordinates - candidates.coordinates[position], + axis=1, + ), + ) + + if existing_positions: + all_local = np.ones(len(component_positions), dtype=bool) + seed_eligible = _eligible_gap_positions( + _relative_gap(nearest_geographic, all_local), + all_local, + ) + else: + seed_eligible = np.arange(len(component_positions), dtype=np.int64) + + seed = _highest_scoring_position( + local_names, + local_scores, + seed_eligible, + ) + selected_local = [seed] + remaining = np.ones(len(component_positions), dtype=bool) + remaining[seed] = False + + while len(selected_local) < quota: + newest = selected_local[-1] + geographic_distance = np.linalg.norm( + local_coordinates - local_coordinates[newest], + axis=1, + ) + nearest_geographic = np.minimum( + nearest_geographic, + geographic_distance, + ) + + source = int(candidates.full_indices[component_positions[newest]]) + topological_distance = dijkstra( + coverage_graph, + directed=False, + indices=source, + return_predecessors=False, + )[candidates.full_indices[component_positions]] + nearest_topological = np.minimum( + nearest_topological, + topological_distance, + ) + + coverage_gap = np.maximum( + _relative_gap(nearest_geographic, remaining), + _relative_gap(nearest_topological, remaining), + ) + eligible_local = _eligible_gap_positions(coverage_gap, remaining) + next_local = _highest_scoring_position( + local_names, + local_scores, + eligible_local, + ) + selected_local.append(next_local) + remaining[next_local] = False + + return [int(component_positions[position]) for position in selected_local] + + +def _geographic_coverage_metrics( + candidate_coordinates: np.ndarray, + selected_positions: list[int], +) -> tuple[float, float, float]: + normalized = _isotropic_coordinates(candidate_coordinates) + nearest = np.full(len(normalized), np.inf) + for position in selected_positions: + nearest = np.minimum( + nearest, + np.linalg.norm(normalized - normalized[position], axis=1), + ) + + selected_coordinates = normalized[selected_positions] + if len(selected_positions) < 2: + minimum_gap = 0.0 + else: + pairwise = np.linalg.norm( + selected_coordinates[:, None, :] - selected_coordinates[None, :, :], + axis=2, + ) + np.fill_diagonal(pairwise, np.inf) + minimum_gap = float(pairwise.min()) + return ( + float(nearest.max()), + float(np.quantile(nearest, 0.95)), + minimum_gap, + ) + + +def _select_sensor_nodes( + prepared: _PreparedNetwork, + log_sensitivity: np.ndarray, + hydraulic_distance_sums: np.ndarray, + *, + sensor_num: int, +) -> list[str]: + candidate_indices = prepared.candidate_indices + if len(candidate_indices) < sensor_num: + raise ValueError( + "满足最小管径要求的候选节点少于请求的监测点数量:" + f"候选 {len(candidate_indices)} 个,请求 {sensor_num} 个" + ) + + candidates = _build_candidate_pool( + prepared, + log_sensitivity, + hydraulic_distance_sums, + ) + candidate_components, component_quotas = _allocate_component_quotas( + prepared.coverage_graph, + candidates.full_indices, + candidates.scores, + sensor_num=sensor_num, + ) + selected_positions: list[int] = [] + quota_components = np.flatnonzero(component_quotas) + component_order = np.lexsort( + (quota_components, -component_quotas[quota_components]) + ) + for component in quota_components[component_order]: + component_positions = np.flatnonzero(candidate_components == component) + selected_positions.extend( + _select_component_positions( + prepared.coverage_graph, + candidates, + component_positions, + selected_positions, + quota=int(component_quotas[component]), + ) + ) + + selected_array = np.asarray(selected_positions, dtype=np.int64) + selected_order = np.lexsort( + ( + candidates.names[selected_array], + -candidates.scores[selected_array], + ) + ) + selected_positions = selected_array[selected_order].tolist() + maximum_radius, p95_radius, minimum_gap = _geographic_coverage_metrics( + prepared.coordinates[candidate_indices], + selected_positions, + ) + logger.info( + "Sensitivity placement coverage: components=%d max_radius=%.6f " + "p95_radius=%.6f min_sensor_gap=%.6f", + int(np.count_nonzero(component_quotas)), + maximum_radius, + p95_radius, + minimum_gap, + ) + return [str(candidates.names[position]) for position in selected_positions] + + +def optimize_sensor_placement( + wn: wntr.network.WaterNetworkModel, + sensor_num: int, + min_diameter: int, +) -> list[str]: + """Return deterministic pressure monitoring nodes for a loaded network. + + ``min_diameter`` is expressed in millimetres, matching the HTTP contract. + A node is a valid installation candidate when at least one incident pipe + meets the threshold. All valid hydraulic nodes still participate in the + sensitivity calculation so small pipes continue to influence the result. """ - def __init__( - self, wn: wntr.network.WaterNetworkModel, sensornum: int, min_diameter: int - ): - """ + if sensor_num <= 0: + raise ValueError("监测点数量必须大于 0") + if min_diameter < 0: + raise ValueError("最小管径不能小于 0") - :param wn: 由wntr生成的模型 - :param sensornum: 传感器的数量 - :param min_diameter: 安装的最小管径 - """ - wn_func.__init__(self, wn, min_diameter=min_diameter) - self.sensornum = sensornum + total_started = perf_counter() + simulation_started = total_started + results = _run_hydraulic_simulation(wn) + simulation_seconds = perf_counter() - simulation_started - # 1.某个节点到所有节点的加权距离之和 - # 2.某个节点到该组内所有节点的加权距离之和 - def sensor( - self, SS: pandas.DataFrame, G: networkx.Graph, group: dict[int, list[str]] - ): - """ - sensor 方法是用来根据灵敏度矩阵 SS 和加权图 G 来确定传感器布置位置的 - :param SS: 灵敏度矩阵,每个节点的行和列代表不同节点,矩阵元素表示节点间的灵敏度。SS.iloc[i, :] 表示第 i 行对应节点 i 到所有其他节点的灵敏度 - :param G: 加权图,表示管网的拓扑结构,每个节点通过管道连接。图的边的权重通常是根据水力距离或者流量等计算的 - :param group: 节点分组,字典的键是分组编号,值是该组的节点名称列表 - :return: - """ - # 传感器布置个数以及位置 - # W = self.weight() - n = self.n - len(self.delnodes) - nodes = copy.deepcopy(self.nodes) - for node in self.delnodes: - nodes.remove(node) - # sumSS:list[float],每个节点到其他节点的灵敏度之和。SS.iloc[i, :] 返回第 i 个节点与所有其他节点的灵敏度值,sum(SS.iloc[i, :]) 计算这些灵敏度值的总和。 - sumSS = [] - for i in range(0, n): - sumSS.append(sum(SS.iloc[i, :])) - # 一个整数范围,表示每个节点的索引,用作sumSS_ DataFrame的索引 - indices = range(0, n) - # sumSS_:pandas.DataFrame,将 sumSS 转换成 DataFrame 格式,并且将节点的总灵敏度保存到 CSV 文件 sumSS_data.csv 中 - sumSS_ = pd.DataFrame(np.array(sumSS), index=indices) - # sumSS_.to_csv('sumSS_data.csv') # 存储节点总灵敏度 + preparation_started = perf_counter() + prepared = _prepare_network(wn, results, min_diameter=min_diameter) + preparation_seconds = perf_counter() - preparation_started - # sumSS:pandas.DataFrame,sumSS 被转换为 DataFrame 类型,并且按总灵敏度(即灵敏度之和)降序排列。此时,sumSS 是按节点的灵敏度之和排序的 DataFrame - sumSS = pd.DataFrame(np.array(sumSS), index=nodes) - sumSS = sumSS.sort_values(by=[0], ascending=[False]) - # sensorindex:list[str],用于存储根据灵敏度排序选出的传感器位置的节点名称,存储根据总灵敏度排序的节点列表,用于传感器布置 - sensorindex = [] - # sensorindex_2:list[str],用于存储每组内根据灵敏度排序选出的传感器位置的节点名称,存储每个组内根据灵敏度排序选择的传感器节点 - sensorindex_2 = [] - # group_S:dict[int, pandas.DataFrame],存储每个组内的灵敏度矩阵 - group_S = {} - # group_sumSS:dict[int, list[float]],存储每个组内节点的总灵敏度,值为每个组内节点灵敏度之和的列表 - group_sumSS = {} + sensitivity_started = perf_counter() + log_sensitivity = _estimate_log_pressure_sensitivity(prepared) + sensitivity_seconds = perf_counter() - sensitivity_started - # 改动 - for i in range(0, len(group)): - for node in self.delnodes: - # 这里的group[i]是每个组的节点列表,代码首先去除已经被标记为删除的节点self.delnodes - if node in group[i]: - group[i].remove(node) - group_S[i] = SS.loc[group[i], group[i]] - # 对每个组内的节点,计算组内节点的总灵敏度(group_sumSS[i])。它将每个组内节点的灵敏度值相加,并且按灵敏度降序排序 - group_sumSS[i] = [] - for j in range(0, len(group[i])): - group_sumSS[i].append(sum(group_S[i].iloc[j, :])) - group_sumSS[i] = pd.DataFrame(np.array(group_sumSS[i]), index=group[i]) - group_sumSS[i] = group_sumSS[i].sort_values(by=[0], ascending=[False]) - for node in self.less_than_min_diameter_junction_list: - # 这里的group_sumSS[i]是每个分组的灵敏度节点排序列表,去除已经被标记为删除的节点self.less_than_min_diameter_junction_list - if node in group_sumSS[i]: - group_sumSS[i].remove(node) - pass + distance_started = perf_counter() + hydraulic_distance_sums = _estimate_hydraulic_distance_sums(prepared) + distance_seconds = perf_counter() - distance_started - # 1.选sumSS最大的节点,然后把这个节点所在的那个组删掉,就可以不再从这个组选点。再重新排序选sumSS最大的; - # 2.在每组内选group_sumSS最大的节点 - # 在这个循环中,首先选择灵敏度最高的节点Smaxnode并添加到sensorindex。然后根据灵敏度排序,删除已选的节点并继续选择下一个灵敏度最大的节点。这个过程用于选择传感器的位置 - sensornum = self.sensornum - for i in range(0, sensornum): - # Smaxnode:str,最大灵敏度节点,sumSS.index[0] 表示灵敏度最高的节点 - Smaxnode = sumSS.index[0] - sensorindex.append(Smaxnode) - sensorindex_2.append(group_sumSS[i].index[0]) + selection_started = perf_counter() + selected = _select_sensor_nodes( + prepared, + log_sensitivity, + hydraulic_distance_sums, + sensor_num=sensor_num, + ) + selection_seconds = perf_counter() - selection_started - for key, value in group.items(): - if Smaxnode in value: - sumSS = sumSS.drop(index=group[key]) - continue + logger.info( + "Sensitivity placement completed: nodes=%d pipes=%d candidates=%d " + "sensors=%d seconds=%.3f " + "(simulation=%.3f preparation=%.3f sensitivity=%.3f " + "distance=%.3f selection=%.3f)", + len(prepared.node_names), + prepared.incidence.shape[1], + len(prepared.candidate_indices), + len(selected), + perf_counter() - total_started, + simulation_seconds, + preparation_seconds, + sensitivity_seconds, + distance_seconds, + selection_seconds, + ) + return selected - sumSS = sumSS.sort_values(by=[0], ascending=[False]) - return sensorindex, sensorindex_2 +def optimize_sensor_placement_from_inp( + inp_path: str | Path, + sensor_num: int, + min_diameter: int, +) -> list[str]: + """Load an EPANET INP model and run the unified placement algorithm.""" + + wn = wntr.network.WaterNetworkModel(str(inp_path)) + return optimize_sensor_placement( + wn, + sensor_num=sensor_num, + min_diameter=min_diameter, + ) -# 2025/03/13 def get_ID(name: str, sensor_num: int, min_diameter: int) -> list[str]: - """ - 获取布置测压点的坐标,初始测压点布置根据灵敏度来布置,计算初始情况下的校准过程的error - :param name: 数据库名称 - :param sensor_num: 测压点数目 - :param min_diameter: 安装的最小管径 - :return: 测压点节点ID - """ - # inp_file_real:str,输入文件名,表示原始水力模型文件的路径,该文件格式为 EPANET 输入文件(.inp),包含管网的结构信息、节点、管道、泵等数据 - inp_file_real = f"./db_inp/{name}.db.inp" - # sensornum:int,需要布置的传感器数量 - # sensornum = sensor_num - # wn_real:wntr.network.WaterNetworkModel,加载 EPANET 水力模型 - wn_real = wntr.network.WaterNetworkModel(inp_file_real) # 真实粗糙度的原始管网 - # sim_real:wntr.sim.EpanetSimulator,创建一个水力仿真器对象 - sim_real = wntr.sim.EpanetSimulator(wn_real) - # results_real:wntr.sim.results.SimulationResults,运行仿真并返回结果 - results_real = sim_real.run_sim() + """Compatibility entry point used by the sensor placement service.""" - # real_C:list[float],包含所有管道粗糙度的列表 - real_C = wn_real.query_link_attribute("roughness").tolist() - # wn_fun1:wn_func(继承自 object),创建 wn_func 类的实例,传入 wn_real 水力模型对象。wn_func 用于计算管网相关的水力属性,比如水力距离、灵敏度等 - wn_fun1 = wn_func(wn_real, min_diameter=min_diameter) - # nodes:list[str],管网的节点名称列表 - nodes = wn_fun1.nodes - # delnodes:list[str],被删除的节点(如水库、泵、阀门连接的节点等) - delnodes = wn_fun1.delnodes - # Coor_node:pandas.DataFrame - Coor_node = getCoor(wn_real) - Coor_node = Coor_node.drop(wn_fun1.delnodes) - nodes = [node for node in wn_fun1.nodes if node not in delnodes] - # coordinates:pandas.Series,存储所有节点的坐标,类型为 Series,索引为节点名称,值为 (x, y) 坐标对 - coordinates = wn_fun1.coordinates - - # 随机产生监测点 - # junctionnum:int,nodes 的长度,表示节点的数量 - junctionnum = len(nodes) - # random_numbers:list[int],使用 random.sample 随机选择 sensornum(20)个节点的编号。它返回一个不重复的随机编号列表 - # random_numbers = random.sample(range(junctionnum), sensor_num) - # for i in range(sensor_num): - # # print(random_numbers[i]) - - wn_fun1.get_Conn() - # hL:pandas.DataFrame,水力距离矩阵,表示每个节点到其他节点的水力阻力 - # G:networkx.DiGraph,加权有向图,表示管网的拓扑结构,节点之间的边带有权重 - hL, G = wn_fun1.CtoS() - # SS:pandas.DataFrame,灵敏度矩阵,表示每个节点对管网变化(如粗糙度、流量等)的响应 - SS = wn_fun1.Jaco(hL) - # group:dict[int, list[str]],使用 kgroup 函数将节点按坐标分成若干组,每组包含的节点数不一定相同。group 是一个字典,键为分组编号,值为节点名列表 - - G1 = wn_real.to_graph() - G1 = G1.to_undirected() # 变为无向图 - - group = kgroup(Coor_node, sensor_num) - # group = skater_partition(G1, sensor_num) - # group = spectral_partition(G1, sensor_num) - - # print(group) - # --------------------- 保存 group 数据 --------------------- - # 将 group 数据转换为一个“长格式”的 DataFrame, - # 每一行记录一个节点及其所属的分组 - # group_data = [] - # for group_id, node_list in group.items(): - # for node in node_list: - # group_data.append({"Group": group_id, "Node": node}) - # - # df_group = pd.DataFrame(group_data) - # - # # 保存为 Excel 文件,文件名为 "group.xlsx";index=False 表示不保存行索引 - # df_group.to_excel("group.xlsx", index=False) - - # wn_fun:Sensorplacement(继承自wn_func) - # 创建Sensorplacement类的实例,传入水力网络模型wn_real和传感器数量sensornum。Sensorplacement用于计算和布置传感器 - wn_fun = Sensorplacement(wn_real, sensor_num, min_diameter=min_diameter) - wn_fun.__dict__.update(wn_fun1.__dict__) - # sensorindex:list[str],初始传感器布置位置的节点名称 - # sensorindex_2:list[str],根据分组选择的传感器位置 - sensorindex, sensorindex_2 = wn_fun.sensor(SS, G, group) # 初始的sensorindex - # print(str(sensor_num), "个测压点,测压点位置:", sensorindex) - - # 重新打开数据库 - # if is_project_open(name=name): - # close_project(name=name) - # open_project(name=name) - # for node_id in sensorindex : - # sensor_coord[node_id] = get_node_coord(name=name, node_id=node_id) - # close_project(name=name) - # print(sensor_coord) - # # 分区画图 - # colorlist = ['lightpink', 'coral', 'rosybrown', 'olive', 'powderblue', 'lightskyblue', 'steelblue', 'peachpuff','brown','silver','indigo','lime','gold','violet','maroon','navy','teal','magenta','cyan', - # 'burlywood', 'tan', 'slategrey', 'thistle', 'lightseagreen', 'lightgreen', 'red','blue','yellow','orange','purple','grey','green','pink','lightblue','beige','chartreuse','turquoise','lavender','fuchsia','coral'] - # G = wn_real.to_graph() - # G = G.to_undirected() # 变为无向图 - # pos = nx.get_node_attributes(G, 'pos') - # pass - # - # for i in range(0, sensor_num): - # ax = plt.gca() - # ax.set_title(inp_file_real + str(sensor_num)) - # nodes = nx.draw_networkx_nodes(G, pos, nodelist=group[i], node_color=colorlist[i], node_size=10) - # nodes = nx.draw_networkx_nodes(G, pos, - # nodelist=sensorindex_2, node_color='red', node_size=70, node_shape='*' - # ) - # edges = nx.draw_networkx_edges(G, pos) - # ax.spines['top'].set_visible(False) - # ax.spines['right'].set_visible(False) - # ax.spines['bottom'].set_visible(False) - # ax.spines['left'].set_visible(False) - # plt.savefig(inp_file_real + str(sensor_num) + ".png", dpi=300) - # plt.show() - # - # wntr.graphics.plot_network(wn_real, node_attribute=sensorindex_2, node_size=50, node_labels=False, - # title=inp_file_real + '_Projetion' + str(sensor_num)) - # plt.savefig(inp_file_real + '_S' + str(sensor_num) + ".png", dpi=300) - # plt.show() - return sensorindex - - -if __name__ == "__main__": - sensorindex = get_ID(name=project_info.name, sensor_num=20, min_diameter=300) - - print(sensorindex) - # 将 sensor_coord 字典转换为 DataFrame, - # 使用 orient='index' 表示字典的键作为 DataFrame 的行索引, - # 数据中每个键对应的 value 是一个子字典,其键 'x' 和 'y' 成为 DataFrame 的列名 - # df_sensor_coord = pd.DataFrame.from_dict(sensor_coord, orient='index') - # - # # 将索引名称设为 'Node' - # df_sensor_coord.index.name = 'Node' - # - # # 保存到 Excel 文件 - # df_sensor_coord.to_excel("sensor_coord.xlsx", index=True) + inp_path = Path("db_inp") / f"{name}.db.inp" + return optimize_sensor_placement_from_inp( + inp_path, + sensor_num=sensor_num, + min_diameter=min_diameter, + ) diff --git a/docs/sensor-sensitivity-optimization.md b/docs/sensor-sensitivity-optimization.md new file mode 100644 index 0000000..85aecc6 --- /dev/null +++ b/docs/sensor-sensitivity-optimization.md @@ -0,0 +1,270 @@ +# 监测点灵敏度算法优化说明 + +本文记录压力监测点布置算法的改造方案、数学含义、复杂度变化和本地验证结果。实现位于 `app/algorithms/sensor/sensitivity.py`,对外兼容入口仍为 `get_ID(name, sensor_num, min_diameter)`。 + +## 改造目标 + +原算法根据节点压力对管道粗糙度变化的灵敏度,以及节点到全网的水力距离,给每个候选节点评分。空间聚类用于限制监测点过度集中。业务评分可以写成: + +$$ +score_i = sensitivity_i \times \sum_j distance(i, j) +$$ + +本次改造保留这套评分语义和空间覆盖原则,主要处理以下问题: + +- 稠密的节点与节点、节点与管道、管道与管道矩阵占用大量内存。 +- 显式计算行列式、逆矩阵和伪逆,计算量随节点数快速增长。 +- 从每个节点执行最短路径并保存全节点距离矩阵,时间和内存均为平方级。 +- 一次任务重复运行 EPANET,并保留算法没有使用的扩展时序结果。 +- 聚类没有固定随机状态,相同输入可能返回不同结果。 +- KMeans 按候选节点密度最小化平方距离,密集管网会系统性获得更多监测点,不能保证地图或管网路径覆盖均匀。 +- WNTR 管径使用米,接口参数使用毫米,旧候选过滤没有统一单位。 + +## 稀疏表示替代稠密矩阵 + +管网的节点通常只连接少量管道。新实现使用 SciPy CSR 或 CSC 矩阵保存节点与管道关联矩阵、水力有向图和水力雅可比矩阵,只记录真实存在的连接。 + +准备阶段生成一个只读的 `_PreparedNetwork`,其中包括: + +- 参与分析的节点及其在完整模型中的索引。 +- 符合安装条件的候选节点索引。 +- 归一化前的二维坐标。 +- 稀疏节点与管道关联矩阵。 +- 管道导通系数和粗糙度响应系数。 +- 按实际流向构建的稀疏水力距离图。 +- 按初始运行状态构建的无向物理覆盖图。 + +水库、水箱、泵和阀门的边界节点继续沿用原算法的排除规则。与水源直接相连的管道不参加扰动计算。平行有向边只保留权重最小的边,以符合最短路径语义。 + +主体存储由平方级降到接近 `O(n + m)`。稀疏 LU 分解可能产生填充,其内存仍取决于管网拓扑,但实现不会再主动分配完整的 `n × n` 或 `n × m` 数组。 + +## 用 Cauchy 投影估计压力灵敏度 + +旧算法先显式计算压力对管道粗糙度的响应矩阵: + +$$ +J = X^{-1} A S +$$ + +其中,`X` 是节点水力雅可比矩阵,`A` 是节点与管道关联矩阵,`S` 是管道粗糙度响应矩阵。节点灵敏度是 `J` 对应行的 L1 范数。完整的 `J` 为 `节点数 × 管道数`,大模型无法保存。 + +新算法使用 Cauchy 分布的 1-stable 特性估计每一行的 L1 范数。设 `R` 为 Cauchy 随机投影矩阵,只需求解: + +$$ +Y = X^{-1} A S R +$$ + +对节点 `i` 而言,`Y` 中每个投影值服从以 `||J_i||_1` 为尺度的 Cauchy 分布。实现使用投影绝对值的对数几何均值估计 `log(||J_i||_1)`,不保存完整灵敏度矩阵。 + +当前固定参数如下: + +| 参数 | 数值 | 用途 | +|---|---:|---| +| 随机种子 | 42 | 保证结果可复现 | +| Cauchy 投影数 | 256 | 控制灵敏度估计精度 | +| 投影批大小 | 16 | 限制投影中间矩阵内存 | +| 雅可比正则化 | `max(abs(diag(X))) × sqrt(eps)` | 改善接近奇异矩阵的稳定性 | +| 稀疏排序 | `MMD_AT_PLUS_A` | 减少 LU 分解填充 | + +水力雅可比矩阵只进行一次稀疏 LU 分解,256 次投影复用该分解结果。批处理期间最多保留 16 列投影数据。 + +## 用空间代表点估计水力距离总和 + +旧算法从每个节点执行一次最短路径,并保存 `n × n` 的水力距离矩阵。新算法根据节点坐标构建最多 256 个空间代表点: + +1. 使用固定随机状态的 `MiniBatchKMeans` 对节点坐标分组。 +2. 每组选择最靠近聚类中心的节点作为代表点。 +3. 代表点权重等于该组包含的节点数。 +4. 在反向水力图上从代表点执行有向 Dijkstra,得到原图中各节点到代表点的距离。 + +节点 `i` 的全网水力距离总和估计为: + +$$ +distance\_sum_i \approx \sum_{l \in landmarks} weight_l \times distance(i, l) +$$ + +Dijkstra 每批处理 16 个代表点,内存中只保留当前距离块。不可达距离按原算法约定计为 0,避免断开分支得到无穷评分。 + +## 评分约束下的混合覆盖选点 + +最终排序使用对数形式: + +$$ +log\_score_i = log\_sensitivity_i + \log(distance\_sum_i) +$$ + +对数是单调函数,因此该排序等价于比较 `sensitivity_i × distance_sum_i`,同时可以避免大数乘法溢出。 + +最终选点不再对全部候选节点执行 KMeans。KMeans 的目标函数按候选节点数计权,节点密集区域即使地理范围较小,也会获得更多聚类中心。本次改为评分约束下的最远空白区优先策略。 + +候选坐标使用同一个尺度因子归一化,保留管网原始长宽比。初始状态下开启的管道按物理长度构建无向覆盖图,开启的泵和阀门作为点连接;关闭连接会形成独立区域。监测点名额首先按各连通区域的有效管道长度分配:名额足够时每个区域至少一个,其余名额按最大余数法分配,并受该区域候选数量限制。 + +选点按区域名额从多到少处理,使主干管网先形成覆盖骨架。第一个区域的首点取综合评分最高的候选;后续区域的首点也必须考虑此前所有区域的已选点,先进入全局地图空白度前 70% 的候选集,再比较综合评分。这一约束避免两个拓扑断开但地图上重叠或相邻的区域各自选择一个近邻高分点。 + +设全局已选集合为 `S`,其余候选的最近地图距离和本连通区域内的最近管网路径距离分别为: + +$$ +g_i = \min_{s \in S} ||x_i-x_s||, \qquad +t_i = \min_{s \in S} shortest\_path(i, s) +$$ + +两类距离分别除以当前未选候选中的最大值,混合空白度取两者较大值: + +$$ +coverage_i = \max\left(\frac{g_i}{\max g}, \frac{t_i}{\max t}\right) +$$ + +每轮只保留空白度达到当前最大值 70% 的候选,再从中选择综合评分最高的节点。地图距离始终相对全部已选区域更新,管网路径距离在当前连通区域内更新。这样地图或管网路径中任一维度仍有明显空白时,该区域都不会被高密度节点误判为已经覆盖。综合评分相同时按节点 ID 排序,结果数量、唯一性和可复现性保持不变。 + +实现只维护候选节点的最近距离数组。每新增一个监测点,执行一次单源 Dijkstra 并增量更新,不构造候选节点两两距离矩阵。 + +所有模型使用同一套算法和相同参数,不根据节点数量切换实现。 + +## EPANET 只计算初始状态 + +后续计算只读取水力结果的第一个时刻。新实现会临时将 `wn.options.time.duration` 设置为 0,只运行一次 EPANET 初始状态模拟,并在结束或异常后恢复原始时长。 + +EPANET 中间文件写入独立的 `TemporaryDirectory`,任务结束后自动清理。并发请求不再共用工作目录下的 `temp.*` 文件。 + +旧调用链最多重复运行约四次 EPANET,新调用链只运行一次,也不会分配没有使用的完整时序结果。 + +## 最小管径规则 + +`min_diameter` 的接口单位是毫米,WNTR 中的管径单位是米。准备阶段使用以下换算: + +$$ +diameter\_mm = diameter\_m \times 1000 +$$ + +节点连接的管道中,只要至少一根达到最小管径,该节点就可以作为安装候选。小管径管道仍参加全网水力和灵敏度计算,管径条件只限制监测点安装位置。 + +当候选节点少于请求的监测点数量时,算法会返回包含候选数量和请求数量的明确错误。 + +## 复杂度变化 + +下表中的 `n` 为参与分析的节点数,`m` 为参与分析的管道数,`k=256` 为灵敏度投影数,`l≤256` 为水力距离代表点数,`b=16` 为批大小。 + +| 环节 | 原实现 | 新实现 | +|---|---|---| +| 节点与管道关系 | 稠密 `n × m` | CSR 稀疏矩阵,约 `O(n + m)` | +| 节点关系与距离 | 多个稠密 `n × n` 矩阵 | 稀疏有向图和流式距离块 | +| 灵敏度 | 稠密行列式、逆矩阵或伪逆,时间接近 `O(n³)` | 一次稀疏 LU 分解和 `k` 次稀疏求解 | +| 灵敏度结果 | 保存完整 `n × m` 响应矩阵 | 保存 `n` 个对数灵敏度和当前投影批 | +| 水力距离 | 从全部节点执行最短路径并保存 `n × n` 结果 | 从至多 `l` 个代表点执行 Dijkstra,每批 `b` 个 | +| 最终选点 | 按节点密度分配的完整 KMeans | 连通区域配额和 70% 混合覆盖,线性距离数组 | +| 水力模拟 | 调用链中重复执行,并可能保存完整时序 | 一次初始状态模拟 | + +稀疏 LU 的时间和内存不能简单视为线性,其填充程度受网络拓扑影响。当前压力测试覆盖到 20.3 万原始节点,不能据此保证任意更大或连接更稠密的模型都保持相同比例。 + +## 精度取舍 + +新实现不逐元素生成旧算法的完整精确矩阵,而是估计最终排序需要的两个统计量: + +- Cauchy 投影估计压力响应矩阵每一行的 L1 范数。 +- 加权空间代表点估计节点到全网的水力距离总和。 + +单元测试使用小型模型构造完整稠密参考结果,要求近似方案选点的精确参考目标值不低于精确方案的 95%。本地模型对比结果如下: + +| 模型 | 近似选点的精确参考目标比 | +|---|---:| +| `fengxian.inp` | 98.80% | +| MD 模型 | 99.71% | + +固定随机种子和固定采样数保证同一模型、监测点数量和最小管径得到相同结果。近似排序仍可能与完整稠密算法不同,特别是多个候选节点得分接近时。 + +混合覆盖会主动放弃部分集中在同一区域的高分节点。单点综合评分之和因此不是唯一质量指标,还需要同时检查未覆盖半径、最小点间距和入选节点的评分百分位。 + +## 资源压力测试记录 + +以下结果来自 2026-08-03 的本地验证。环境为 Linux、Python 3.12、Conda `server` 环境,主机物理内存约 30 GiB。测试参数统一为 20 个监测点、最小管径 0。 + +外部看门狗使用以下停止条件: + +- 算法进程树 RSS 达到 7.5 GiB。 +- 系统可用内存低于 6 GiB。 +- 单模型运行超过 600 秒。 +- 子进程虚拟地址空间硬限制为 8 GiB。 + +任一条件满足时,看门狗会终止整个进程组。三次压力测试均未触发停止条件。 + +| 模型 | 原始节点 | 实际分析节点 | 实际分析管道 | 算法流水线耗时 | 峰值 RSS | +|---|---:|---:|---:|---:|---:| +| `temp/leakage/temp_3698123.inp` | 31,143 | 28,723 | 29,974 | 2.77 秒 | 467 MiB | +| `inp/jbh.inp` | 94,049 | 69,949 | 82,369 | 8.92 秒 | 980 MiB | +| `inp/Todo/v-16常熟模型.inp` | 203,569 | 145,948 | 176,512 | 20.00 秒 | 1.84 GiB | + +实际分析节点少于原始节点,是因为水库、水箱、泵和阀门边界节点按算法规则排除。20.3 万节点模型原文件使用非标准的 `REPORTING TIMESTEP`,并且缺少 `[END]`。压力测试只在隔离临时副本中将其规范化,没有修改原文件。 + +20.3 万节点模型各阶段耗时如下: + +| 阶段 | 耗时 | +|---|---:| +| 模型加载 | 6.63 秒 | +| EPANET 初始状态模拟 | 8.30 秒 | +| 稀疏数据准备 | 3.00 秒 | +| 压力灵敏度估计 | 0.76 秒 | +| 水力距离估计 | 0.94 秒 | +| 监测点选择 | 0.37 秒 | + +### `tjwater` 分布优化前后对比 + +2026-08-03 使用 `db_inp/tjwater.db.inp`、20 个监测点、最小管径 0 进行同机对比。进程通过 systemd scope 限制在 2.5 GiB 内存,并设置 90 秒超时;两次运行均未触发保护。覆盖距离使用保持长宽比后的模型坐标,按管网最大轴跨度归一化。 + +| 指标 | KMeans 选点 | 70% 混合覆盖 | 变化 | +|---|---:|---:|---:| +| 实际分析/候选节点 | 87,877 | 87,877 | 不变 | +| 最大地图覆盖半径 | 0.176786 | 0.173941 | -1.61% | +| P95 地图覆盖半径 | 0.122093 | 0.090686 | -25.72% | +| 最小监测点间距 | 0.000559 | 0.040887 | 约 73.2 倍 | +| 综合评分中位百分位 | 98.76% | 95.41% | -3.35 个百分点 | +| 端到端耗时 | 12.34 秒 | 11.71 秒 | -5.11% | +| 峰值 RSS | 913 MiB | 932 MiB | +19 MiB | + +结果达到预定验收条件:P95 覆盖半径下降超过 15%,评分中位百分位下降少于 10 个百分点,运行时间没有增加,峰值内存增加远低于 256 MiB。 + +### 2026-08-03:跨连通区域共享全局间距 + +首次混合覆盖结果中,节点 `121302` 与 `110874` 分属两个不可达连通区域,但地图距离只有约 550.47 个模型单位。旧的分区独立首点规则分别选中了两个区域的最高分节点,形成视觉近邻。加入跨区域全局地图间距后,`121302` 被替换,最小归一化点间距由 0.013685 进一步提高到 0.040887。 + +本次调整保留连通区域名额,改变区域之间互不感知的选点方式。区域按名额从多到少处理,主干管网先形成覆盖骨架。第一个区域仍从综合评分最高的候选开始;后续区域选择首点时,先计算该区域所有候选到全局已选点的最近地图距离,只保留达到本区域最大空白距离 70% 的候选,再比较灵敏度综合评分。区域内部后续选点继续使用地图距离与管网路径距离的混合空白度。 + +这项约束只影响跨区域首点选择,不改变灵敏度估计、水力距离估计、区域名额、最小管径规则和公开 API。合成回归模型会构造两个地图上重叠但拓扑断开的区域,确保算法不会再次分别选择两个相邻高分点。 + +## 测试与回归验证 + +新增测试位于 `tests/unit/test_sensor_sensitivity.py`,覆盖以下行为: + +- 相同输入返回相同节点,并且每次调用只运行一次 EPANET。 +- EPANET 只保留初始状态,模型原始模拟时长能够恢复。 +- 关联矩阵和距离图保持 CSR 稀疏格式。 +- 最小管径按毫米过滤安装候选。 +- 近似方案在稠密参考目标上的结果不低于 95%。 +- 高密西部、低密东部的合成模型不再按候选节点密度分配名额。 +- 地理位置相邻但拓扑断开的区域在名额允许时分别获得监测点。 +- 地图上重叠的独立区域共享全局地理间距,不能各自选择相邻的首个高分点。 +- 名额不足时优先覆盖有效管道长度更大的连通区域。 +- 重复坐标依靠管网路径距离继续选点,并保持数量和确定性。 +- 非法监测点数量和最小管径返回明确错误。 +- 模拟结束后不残留共享 `temp.*` 文件。 + +回归命令: + +```bash +conda run -n server python -m pytest \ + tests/unit \ + tests/auth \ + tests/api/test_sensor_placement_endpoints.py \ + tests/api/test_simulation_endpoints.py \ + -q +``` + +2026-08-03 的执行结果为 `142 passed, 2 skipped, 7 warnings`。`git diff --check` 同时通过。 + +## 实现边界 + +- 256 次投影和 256 个代表点是当前质量与性能验证后的固定参数。调整参数需要重新运行稠密参考质量测试和大模型压力测试。 +- 稀疏 LU 对高连接度或拓扑特殊的模型可能产生更多填充,应继续用进程级资源保护运行未知大模型。 +- 算法使用单个初始水力状态。如果业务目标改为覆盖全天多个工况,需要先定义多工况评分和结果合并规则,不能直接恢复长时段模拟后沿用当前评分。 +- 节点坐标用于代表点构建和地图覆盖,假定其能表达一致的平面相对距离;缺少有效二维坐标的模型会返回错误。 +- 物理覆盖图使用初始水力状态。全天工况中频繁开闭的阀门或泵需要在多工况方案中重新定义连通区域合并规则。 +- 当前压力测试验证到 203,569 个原始节点,没有验证 30 万节点模型。 diff --git a/tests/unit/test_sensor_sensitivity.py b/tests/unit/test_sensor_sensitivity.py new file mode 100644 index 0000000..42a8a50 --- /dev/null +++ b/tests/unit/test_sensor_sensitivity.py @@ -0,0 +1,391 @@ +from pathlib import Path + +import numpy as np +import pytest +import wntr +from scipy.sparse import csr_matrix, isspmatrix_csr +from scipy.sparse.csgraph import dijkstra + +from app.algorithms.sensor import sensitivity + + +def _build_test_network() -> wntr.network.WaterNetworkModel: + wn = wntr.network.WaterNetworkModel() + wn.options.time.duration = 0 + wn.add_reservoir("R1", base_head=100.0, coordinates=(-1.0, 0.0)) + wn.add_junction("J0", elevation=5.0, coordinates=(0.0, 0.0)) + + for index in range(1, 13): + wn.add_junction( + f"J{index}", + base_demand=0.001 + index * 0.00001, + elevation=5.0 + index * 0.05, + coordinates=(float(index % 4), float(index // 4)), + ) + + wn.add_pipe("P0", "R1", "J0", length=100.0, diameter=0.4, roughness=110) + wn.add_pipe("P1", "J0", "J1", length=100.0, diameter=0.4, roughness=110) + for index in range(1, 11): + wn.add_pipe( + f"P{index + 1}", + f"J{index}", + f"J{index + 1}", + length=80.0 + index, + diameter=0.3, + roughness=105, + ) + # J12 is connected only through a small pipe, while J11 also touches P11. + wn.add_pipe("P12", "J11", "J12", length=90.0, diameter=0.1, roughness=105) + wn.add_pipe("PX1", "J2", "J6", length=120.0, diameter=0.3, roughness=105) + wn.add_pipe("PX2", "J5", "J9", length=120.0, diameter=0.3, roughness=105) + return wn + + +def _prepared_selection_network( + coordinates: np.ndarray, + edges: list[tuple[int, int, float]], +) -> sensitivity._PreparedNetwork: + node_count = len(coordinates) + rows: list[int] = [] + columns: list[int] = [] + weights: list[float] = [] + for start, end, weight in edges: + rows.extend((start, end)) + columns.extend((end, start)) + weights.extend((weight, weight)) + coverage_graph = csr_matrix( + (weights, (rows, columns)), + shape=(node_count, node_count), + ) + return sensitivity._PreparedNetwork( + node_names=tuple(f"N{index:04d}" for index in range(node_count)), + full_node_indices=np.arange(node_count, dtype=np.int64), + candidate_indices=np.arange(node_count, dtype=np.int64), + coordinates=np.asarray(coordinates, dtype=np.float64), + incidence=csr_matrix((node_count, 1), dtype=np.float64), + conductance=np.ones(1, dtype=np.float64), + roughness_response=np.ones(1, dtype=np.float64), + distance_graph=csr_matrix((node_count, node_count), dtype=np.float64), + coverage_graph=coverage_graph, + ) + + +def test_algorithm_is_deterministic_and_runs_epanet_once(monkeypatch, tmp_path): + wn = _build_test_network() + original_run_sim = wntr.sim.EpanetSimulator.run_sim + prefixes: list[str] = [] + + def counted_run_sim(simulator, *args, **kwargs): + prefixes.append(str(kwargs["file_prefix"])) + return original_run_sim(simulator, *args, **kwargs) + + monkeypatch.setattr(wntr.sim.EpanetSimulator, "run_sim", counted_run_sim) + monkeypatch.chdir(tmp_path) + + first = sensitivity.optimize_sensor_placement(wn, sensor_num=4, min_diameter=0) + second = sensitivity.optimize_sensor_placement(wn, sensor_num=4, min_diameter=0) + + assert first == second + assert len(first) == len(set(first)) == 4 + assert len(prefixes) == 2 + assert all(not Path(prefix).parent.exists() for prefix in prefixes) + assert not list(tmp_path.glob("temp.*")) + + +def test_hydraulic_simulation_keeps_only_initial_state_and_restores_duration(): + wn = _build_test_network() + wn.options.time.duration = 24 * 60 * 60 + + results = sensitivity._run_hydraulic_simulation(wn) + + assert len(results.node["head"].index) == 1 + assert wn.options.time.duration == 24 * 60 * 60 + + +def test_preparation_keeps_network_matrices_sparse(): + wn = _build_test_network() + results = sensitivity._run_hydraulic_simulation(wn) + + prepared = sensitivity._prepare_network(wn, results, min_diameter=0) + + assert isspmatrix_csr(prepared.incidence) + assert isspmatrix_csr(prepared.distance_graph) + assert isspmatrix_csr(prepared.coverage_graph) + assert prepared.incidence.nnz <= 2 * prepared.incidence.shape[1] + assert prepared.distance_graph.nnz <= wn.num_pipes + assert prepared.coverage_graph.nnz <= 2 * wn.num_links + assert (prepared.coverage_graph != prepared.coverage_graph.T).nnz == 0 + dense_incidence_bytes = int(np.prod(prepared.incidence.shape)) * 8 + sparse_payload_bytes = ( + prepared.incidence.data.nbytes + + prepared.incidence.indices.nbytes + + prepared.incidence.indptr.nbytes + ) + assert sparse_payload_bytes < dense_incidence_bytes + + +def test_minimum_diameter_filters_installation_candidates_in_millimetres(): + wn = _build_test_network() + results = sensitivity._run_hydraulic_simulation(wn) + prepared = sensitivity._prepare_network(wn, results, min_diameter=300) + candidate_names = { + prepared.node_names[index] for index in prepared.candidate_indices + } + + assert "J12" not in candidate_names + assert "J11" in candidate_names + + selected = sensitivity.optimize_sensor_placement( + wn, + sensor_num=4, + min_diameter=300, + ) + assert set(selected) <= candidate_names + + with pytest.raises(ValueError, match="候选节点少于"): + sensitivity.optimize_sensor_placement( + wn, + sensor_num=len(candidate_names) + 1, + min_diameter=300, + ) + + +def test_sparse_estimate_preserves_dense_reference_placement_quality(): + wn = _build_test_network() + results = sensitivity._run_hydraulic_simulation(wn) + prepared = sensitivity._prepare_network(wn, results, min_diameter=0) + + approximate_log_sensitivity = sensitivity._estimate_log_pressure_sensitivity( + prepared + ) + approximate_distance = sensitivity._estimate_hydraulic_distance_sums(prepared) + approximate_selected = sensitivity._select_sensor_nodes( + prepared, + approximate_log_sensitivity, + approximate_distance, + sensor_num=4, + ) + + incidence = prepared.incidence.toarray() + laplacian = ( + prepared.incidence.multiply(prepared.conductance) + @ prepared.incidence.T + ).toarray() + diagonal_scale = float(np.max(np.abs(np.diag(laplacian)))) + laplacian += np.eye(laplacian.shape[0]) * ( + diagonal_scale * np.sqrt(np.finfo(np.float64).eps) + ) + response = np.linalg.solve( + laplacian, + incidence * prepared.roughness_response, + ) + exact_sensitivity = np.abs(response).sum(axis=1) + + exact_distances = dijkstra( + prepared.distance_graph.transpose().tocsr(), + directed=True, + indices=prepared.full_node_indices, + return_predecessors=False, + )[:, prepared.full_node_indices] + exact_distances[~np.isfinite(exact_distances)] = 0.0 + exact_distance = exact_distances.sum(axis=0) + exact_selected = sensitivity._select_sensor_nodes( + prepared, + np.log(np.maximum(exact_sensitivity, np.finfo(np.float64).tiny)), + exact_distance, + sensor_num=4, + ) + + exact_score = exact_sensitivity * exact_distance + node_index = { + node_name: index for index, node_name in enumerate(prepared.node_names) + } + approximate_objective = sum( + exact_score[node_index[node_name]] for node_name in approximate_selected + ) + exact_objective = sum( + exact_score[node_index[node_name]] for node_name in exact_selected + ) + + assert approximate_objective / exact_objective >= 0.95 + + +def test_mixed_coverage_avoids_candidate_density_bias(): + dense_west = np.linspace(0.0, 2.0, 200) + sparse_east = np.linspace(3.0, 10.0, 20) + x_coordinates = np.concatenate((dense_west, sparse_east)) + coordinates = np.column_stack( + (x_coordinates, np.zeros(len(x_coordinates), dtype=np.float64)) + ) + ordered = np.argsort(x_coordinates) + edges = [ + ( + int(start), + int(end), + float(x_coordinates[end] - x_coordinates[start]), + ) + for start, end in zip(ordered[:-1], ordered[1:]) + ] + prepared = _prepared_selection_network(coordinates, edges) + log_scores = np.linspace(4.0, 0.0, len(coordinates)) + distance_sums = np.ones(len(coordinates), dtype=np.float64) + + selected = sensitivity._select_sensor_nodes( + prepared, + log_scores, + distance_sums, + sensor_num=6, + ) + name_to_position = { + name: position for position, name in enumerate(prepared.node_names) + } + selected_positions = [name_to_position[name] for name in selected] + new_metrics = sensitivity._geographic_coverage_metrics( + coordinates, + selected_positions, + ) + + legacy_labels, _centers = sensitivity._cluster_labels( + coordinates, + 6, + random_seed=sensitivity._RANDOM_SEED + 2, + ) + legacy_positions: list[int] = [] + represented: set[int] = set() + for position in np.argsort(-log_scores): + label = int(legacy_labels[position]) + if label in represented: + continue + represented.add(label) + legacy_positions.append(int(position)) + legacy_metrics = sensitivity._geographic_coverage_metrics( + coordinates, + legacy_positions, + ) + + assert new_metrics[0] <= legacy_metrics[0] * 0.6 + assert new_metrics[2] >= legacy_metrics[2] * 1.5 + assert max(x_coordinates[selected_positions]) >= 9.0 + + +def test_disconnected_components_each_receive_a_sensor_when_slots_allow(): + coordinates = np.asarray( + [ + (0.0, 0.0), + (1.0, 0.0), + (0.0, 0.01), + (1.0, 0.01), + ] + ) + prepared = _prepared_selection_network( + coordinates, + [(0, 1, 1.0), (2, 3, 1.0)], + ) + + selected = sensitivity._select_sensor_nodes( + prepared, + np.asarray([10.0, 9.0, 8.0, 7.0]), + np.ones(4), + sensor_num=2, + ) + + assert len(set(selected) & {"N0000", "N0001"}) == 1 + assert len(set(selected) & {"N0002", "N0003"}) == 1 + + +def test_overlapping_components_respect_global_geographic_spacing(): + coordinates = np.asarray( + [ + (0.0, 0.0), + (10.0, 0.0), + (0.1, 0.0), + (10.1, 0.0), + ] + ) + prepared = _prepared_selection_network( + coordinates, + [(0, 1, 10.0), (2, 3, 10.0)], + ) + + selected = sensitivity._select_sensor_nodes( + prepared, + np.asarray([10.0, 1.0, 9.0, 0.0]), + np.ones(4), + sensor_num=2, + ) + selected_positions = [prepared.node_names.index(name) for name in selected] + minimum_gap = sensitivity._geographic_coverage_metrics( + coordinates, + selected_positions, + )[2] + + assert minimum_gap >= 0.9 + + +def test_component_quota_prefers_longer_networks_when_slots_are_limited(): + coordinates = np.asarray( + [ + (0.0, 0.0), + (10.0, 0.0), + (20.0, 0.0), + (25.0, 0.0), + (30.0, 0.0), + (31.0, 0.0), + ] + ) + prepared = _prepared_selection_network( + coordinates, + [(0, 1, 10.0), (2, 3, 5.0), (4, 5, 1.0)], + ) + + selected = sensitivity._select_sensor_nodes( + prepared, + np.asarray([1.0, 1.0, 2.0, 2.0, 100.0, 100.0]), + np.ones(6), + sensor_num=2, + ) + + assert set(selected) <= {"N0000", "N0001", "N0002", "N0003"} + assert len(set(selected) & {"N0000", "N0001"}) == 1 + assert len(set(selected) & {"N0002", "N0003"}) == 1 + + +def test_duplicate_coordinates_use_topology_and_return_exact_count(): + coordinates = np.zeros((6, 2), dtype=np.float64) + prepared = _prepared_selection_network( + coordinates, + [(index, index + 1, 1.0) for index in range(5)], + ) + log_scores = np.linspace(6.0, 1.0, 6) + + first = sensitivity._select_sensor_nodes( + prepared, + log_scores, + np.ones(6), + sensor_num=4, + ) + second = sensitivity._select_sensor_nodes( + prepared, + log_scores, + np.ones(6), + sensor_num=4, + ) + + assert first == second + assert len(first) == len(set(first)) == 4 + + +@pytest.mark.parametrize( + ("sensor_num", "min_diameter", "message"), + [ + (0, 0, "监测点数量必须大于 0"), + (1, -1, "最小管径不能小于 0"), + ], +) +def test_algorithm_rejects_invalid_parameters(sensor_num, min_diameter, message): + with pytest.raises(ValueError, match=message): + sensitivity.optimize_sensor_placement( + _build_test_network(), + sensor_num=sensor_num, + min_diameter=min_diameter, + ) From 1432934f12f099d021e60f2272e6188bc200f8d4 Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 19:00:00 +0800 Subject: [PATCH 83/93] =?UTF-8?q?feat(sensor):=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E5=80=99=E9=80=89=E8=8A=82=E7=82=B9=E6=9C=80=E5=A4=A7=E7=AE=A1?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/sensor_placement.py | 23 +++- app/domain/schemas/sensor_placement.py | 4 + app/native/wndb/s42_sensor_placement.py | 17 ++- app/services/sensor_placement.py | 14 +++ contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 121 +++++++++++++++++++ tests/api/test_sensor_placement_endpoints.py | 17 +++ tests/unit/test_sensor_placement_service.py | 10 +- 8 files changed, 203 insertions(+), 5 deletions(-) diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 61f0b26..5ebdd25 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -2,7 +2,7 @@ import logging from typing import Any from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from fastapi.responses import StreamingResponse from starlette.concurrency import run_in_threadpool @@ -13,6 +13,7 @@ from app.algorithms.sensor import ( from app.auth.metadata_dependencies import get_current_metadata_user from app.auth.project_dependencies import ProjectContext, get_project_context from app.domain.schemas.sensor_placement import ( + SensorPointResponse, SensorPlacementExportRequest, SensorPlacementOptimizeRequest, SensorPlacementSchemeResponse, @@ -24,6 +25,7 @@ from app.services.sensor_placement import ( SensorPlacementValidationError, build_sensor_placement_workbook, can_edit_sensor_placement, + get_sensor_placement_candidate, get_sensor_placement_scheme, update_sensor_placement_scheme, ) @@ -94,6 +96,25 @@ def _get_scheme_response( raise _service_http_error(exc) from exc +@router.get( + "/sensor-placement-candidates/{node_id}", + response_model=SensorPointResponse, + summary="获取监测点候选节点详情", +) +async def get_sensor_placement_candidate_detail( + node_id: str = Path(..., min_length=1, max_length=32), + project_context: ProjectContext = Depends(get_project_context), +) -> dict[str, Any]: + try: + return await run_in_threadpool( + get_sensor_placement_candidate, + project_context.project_code, + node_id, + ) + except SensorPlacementValidationError as exc: + raise _service_http_error(exc) from exc + + @router.post( "/sensor-placement-optimization-runs", response_model=SensorPlacementSchemeResponse, diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py index 71f6680..a8f503b 100644 --- a/app/domain/schemas/sensor_placement.py +++ b/app/domain/schemas/sensor_placement.py @@ -67,6 +67,10 @@ class SensorPlacementExportRequest(BaseModel): class SensorPointResponse(BaseModel): node_id: str + max_pipe_diameter: float | None = Field( + ..., + description="节点关联管道的最大管径,单位:毫米", + ) project_x: float project_y: float map_x: float diff --git a/app/native/wndb/s42_sensor_placement.py b/app/native/wndb/s42_sensor_placement.py index c65dc66..38eb57a 100644 --- a/app/native/wndb/s42_sensor_placement.py +++ b/app/native/wndb/s42_sensor_placement.py @@ -66,8 +66,22 @@ def get_sensor_placement_nodes( with conn.cursor(row_factory=dict_row) as cur: cur.execute( """ + WITH incident_pipe_diameters AS ( + SELECT node_id, MAX(diameter) AS max_pipe_diameter + FROM ( + SELECT node1 AS node_id, diameter + FROM pipes + WHERE node1 = ANY(%s) + UNION ALL + SELECT node2 AS node_id, diameter + FROM pipes + WHERE node2 = ANY(%s) + ) AS incident_pipes + GROUP BY node_id + ) SELECT DISTINCT ON (gj.id) gj.id AS node_id, + ipd.max_pipe_diameter, gj.elevation, ST_X(c.coord) AS project_x, ST_Y(c.coord) AS project_y, @@ -75,10 +89,11 @@ def get_sensor_placement_nodes( ST_Y(gj.geom) AS map_y FROM geo_junctions_mat AS gj JOIN coordinates AS c ON c.node = gj.id + LEFT JOIN incident_pipe_diameters AS ipd ON ipd.node_id = gj.id WHERE gj.id = ANY(%s) ORDER BY gj.id """, - (node_ids,), + (node_ids, node_ids, node_ids), ) return list(cur.fetchall()) diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py index 3d44686..8d4f4f7 100644 --- a/app/services/sensor_placement.py +++ b/app/services/sensor_placement.py @@ -80,6 +80,11 @@ def _sensor_points( points.append( { "node_id": node_id, + "max_pipe_diameter": ( + float(node["max_pipe_diameter"]) + if node["max_pipe_diameter"] is not None + else None + ), "project_x": project_x, "project_y": project_y, "map_x": map_x, @@ -92,6 +97,15 @@ def _sensor_points( return points +def get_sensor_placement_candidate( + network: str, + node_id: str, +) -> dict[str, Any]: + """Return the authoritative editable point data for one junction.""" + + return _sensor_points(network, _normalize_locations([node_id]))[0] + + def validate_sensor_placement_nodes( network: str, sensor_location: list[str], diff --git a/contracts/manifest.json b/contracts/manifest.json index c11cbce..cd3362f 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704" + "sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index f6ddeac..cd08f0e 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -2598,6 +2598,18 @@ "title": "Map Y", "type": "number" }, + "max_pipe_diameter": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "节点关联管道的最大管径,单位:毫米", + "title": "Max Pipe Diameter" + }, "node_id": { "title": "Node Id", "type": "string" @@ -2613,6 +2625,7 @@ }, "required": [ "node_id", + "max_pipe_diameter", "project_x", "project_y", "map_x", @@ -35546,6 +35559,114 @@ ] } }, + "/api/v1/sensor-placement-candidates/{node_id}": { + "get": { + "operationId": "get_sensor_placement_candidates_node_id", + "parameters": [ + { + "in": "path", + "name": "node_id", + "required": true, + "schema": { + "maxLength": 32, + "minLength": 1, + "title": "Node Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorPointResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取监测点候选节点详情", + "tags": [ + "Sensor Placement" + ] + } + }, "/api/v1/sensor-placement-optimization-runs": { "post": { "operationId": "post_sensor_placement_optimization_runs", diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index 226387c..73b42ef 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -32,6 +32,7 @@ def _scheme(**overrides): "sensor_points": [ { "node_id": "J1", + "max_pipe_diameter": 400.0, "project_x": 13500000.0, "project_y": 3600000.0, "map_x": 13500000.0, @@ -42,6 +43,7 @@ def _scheme(**overrides): }, { "node_id": "J2", + "max_pipe_diameter": 300.0, "project_x": 13500100.0, "project_y": 3600100.0, "map_x": 13500100.0, @@ -114,6 +116,9 @@ def _load_module(monkeypatch): "get_sensor_placement_scheme": lambda network, scheme_id: _scheme( id=scheme_id ), + "get_sensor_placement_candidate": ( + lambda network, node_id: _scheme()["sensor_points"][0] + ), "update_sensor_placement_scheme": ( lambda network, scheme_id, **kwargs: _scheme( id=scheme_id, @@ -170,6 +175,18 @@ def test_optimize_returns_created_scheme(monkeypatch): assert captured["username"] == "alice" +def test_get_candidate_returns_maximum_incident_pipe_diameter(monkeypatch): + module = _load_module(monkeypatch) + + response = _client(module).get( + "/api/v1/sensor-placement-candidates/J1", + ) + + assert response.status_code == 200 + assert response.json()["node_id"] == "J1" + assert response.json()["max_pipe_diameter"] == 400.0 + + def test_optimize_rejects_unsupported_sensor_type(monkeypatch): module = _load_module(monkeypatch) response = _client(module).post( diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py index b4131aa..e14e06b 100644 --- a/tests/unit/test_sensor_placement_service.py +++ b/tests/unit/test_sensor_placement_service.py @@ -29,6 +29,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch): lambda network, locations: [ { "node_id": "J1", + "max_pipe_diameter": 400.0, "project_x": 13500000.0, "project_y": 3600000.0, "map_x": 13500000.0, @@ -75,7 +76,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch): assert workbook["方案信息"]["B8"].value == "未保存草稿" -def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinates( +def test_candidate_keeps_engineering_coordinates_and_transforms_map_coordinates( monkeypatch, ): monkeypatch.setattr( @@ -84,6 +85,7 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate lambda network, node_ids: [ { "node_id": "J1", + "max_pipe_diameter": 400.0, "project_x": 3038.94, "project_y": -34446.59, "map_x": 13525191.530279, @@ -93,10 +95,11 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate ], ) - point = sensor_placement._sensor_points("tjwater", ["J1"])[0] + point = sensor_placement.get_sensor_placement_candidate("tjwater", "J1") assert point["project_x"] == 3038.94 assert point["project_y"] == -34446.59 + assert point["max_pipe_diameter"] == 400.0 assert point["longitude"] == pytest.approx(121.498863, abs=1e-6) assert point["latitude"] == pytest.approx(30.924784, abs=1e-6) @@ -133,6 +136,8 @@ def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch): assert "ST_Y(c.coord)" in query assert "ST_X(gj.geom)" in query assert "ST_Y(gj.geom)" in query + assert "MAX(diameter) AS max_pipe_diameter" in query + assert cursor.execute.call_args.args[1] == (["J1"], ["J1"], ["J1"]) def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch): @@ -142,6 +147,7 @@ def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch): lambda network, locations: [ { "node_id": "J1", + "max_pipe_diameter": 400.0, "project_x": 3038.94, "project_y": -34446.59, "map_x": 13525191.53, From 29f691731c07a79c59dda9c30f121f019c19f612 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 5 Aug 2026 17:10:59 +0800 Subject: [PATCH 84/93] fix(auth): enforce Keycloak access token age --- .env.example | 1 + app/auth/keycloak_dependencies.py | 14 ++++++-- app/core/config.py | 1 + tests/auth/test_keycloak_dependencies.py | 45 +++++++++++++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 0f63d17..75f7c1b 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ METADATA_DB_PASSWORD="password" KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_AUDIENCE="account" +KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900 # ============================================ diff --git a/app/auth/keycloak_dependencies.py b/app/auth/keycloak_dependencies.py index f99a358..2ddf5e9 100644 --- a/app/auth/keycloak_dependencies.py +++ b/app/auth/keycloak_dependencies.py @@ -1,4 +1,4 @@ -# import logging +import time from uuid import UUID from fastapi import Depends, HTTPException, status @@ -23,12 +23,22 @@ def _decode_keycloak_token(token: str) -> dict: key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n") - return jwt.decode( + payload = jwt.decode( token, key, algorithms=[settings.KEYCLOAK_ALGORITHM], audience=settings.KEYCLOAK_AUDIENCE or None, ) + if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0: + return payload + + issued_at = payload.get("iat") + if not isinstance(issued_at, (int, float)) or ( + time.time() >= issued_at + settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS + ): + raise JWTError("Keycloak access token is older than the allowed maximum age") + + return payload async def get_current_keycloak_payload( diff --git a/app/core/config.py b/app/core/config.py index 521252a..9a521bf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -53,6 +53,7 @@ class Settings(BaseSettings): KEYCLOAK_PUBLIC_KEY: str = "" KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_AUDIENCE: str = "" + KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900 # Bocha Web Search API BOCHA_API_KEY: str = "" diff --git a/tests/auth/test_keycloak_dependencies.py b/tests/auth/test_keycloak_dependencies.py index b7c6a24..18ba63d 100644 --- a/tests/auth/test_keycloak_dependencies.py +++ b/tests/auth/test_keycloak_dependencies.py @@ -1,7 +1,11 @@ import pytest from fastapi import HTTPException -from app.auth.keycloak_dependencies import get_current_keycloak_username +from app.auth import keycloak_dependencies +from app.auth.keycloak_dependencies import ( + _decode_keycloak_token, + get_current_keycloak_username, +) @pytest.fixture @@ -28,3 +32,42 @@ async def test_current_username_rejects_username_fallback(): assert exc.value.status_code == 401 assert exc.value.detail == "Missing preferred_username claim" + + +def test_decode_keycloak_token_rejects_a_token_older_than_the_configured_limit( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_PUBLIC_KEY", "public-key") + monkeypatch.setattr( + keycloak_dependencies.settings, + "KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS", + 900, + ) + monkeypatch.setattr(keycloak_dependencies.time, "time", lambda: 2_000) + monkeypatch.setattr( + keycloak_dependencies.jwt, + "decode", + lambda *args, **kwargs: {"iat": 1_000}, + ) + + with pytest.raises(keycloak_dependencies.JWTError): + _decode_keycloak_token("expired-by-policy") + + +def test_decode_keycloak_token_accepts_a_recent_token( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_PUBLIC_KEY", "public-key") + monkeypatch.setattr( + keycloak_dependencies.settings, + "KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS", + 900, + ) + monkeypatch.setattr(keycloak_dependencies.time, "time", lambda: 1_500) + monkeypatch.setattr( + keycloak_dependencies.jwt, + "decode", + lambda *args, **kwargs: {"iat": 1_000, "sub": "subject"}, + ) + + assert _decode_keycloak_token("recent") == {"iat": 1_000, "sub": "subject"} From 70c5b0e445da7a1663135f7fd9a43bd602bc5cd6 Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 5 Aug 2026 17:57:49 +0800 Subject: [PATCH 85/93] fix(keycloak): design logout completion page --- .../keycloak/themes/tjwater/login/info.ftl | 44 +++++++++++++++ .../login/messages/messages_en.properties | 4 ++ .../login/messages/messages_zh_CN.properties | 4 ++ .../login/resources/css/tjwater-login.css | 54 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 infra/docker/keycloak/themes/tjwater/login/info.ftl diff --git a/infra/docker/keycloak/themes/tjwater/login/info.ftl b/infra/docker/keycloak/themes/tjwater/login/info.ftl new file mode 100644 index 0000000..c9643f3 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/info.ftl @@ -0,0 +1,44 @@ +<#import "template.ftl" as layout> +<#assign isLogout = message.summary == msg("successLogout")> +<@layout.registrationLayout displayMessage=false; section> + <#if section = "header"> + <#if isLogout> + ${kcSanitize(msg("tjwaterLogoutTitle"))?no_esc} + <#elseif messageHeader??> + ${kcSanitize(msg("${messageHeader}"))?no_esc} + <#else> + ${message.summary} + + <#elseif section = "form"> + <#if isLogout> +
+ +
+

${kcSanitize(msg("tjwaterLogoutEyebrow"))?no_esc}

+

${kcSanitize(msg("tjwaterLogoutDescription"))?no_esc}

+
+ <#if pageRedirectUri?has_content> + ${kcSanitize(msg("tjwaterReturnToApplication"))?no_esc} + <#elseif (client.baseUrl)?has_content> + ${kcSanitize(msg("tjwaterReturnToApplication"))?no_esc} + +
+ <#else> +
+

${message.summary}<#if requiredActions??><#list requiredActions>: <#items as reqActionItem>${kcSanitize(msg("requiredAction.${reqActionItem}"))?no_esc}<#sep>, <#else>

+ <#if skipLink??> + <#else> + <#if pageRedirectUri?has_content> +

${kcSanitize(msg("backToApplication"))?no_esc}

+ <#elseif actionUri?has_content> +

${kcSanitize(msg("proceedWithAction"))?no_esc}

+ <#elseif (client.baseUrl)?has_content> +

${kcSanitize(msg("backToApplication"))?no_esc}

+ + +
+ + + diff --git a/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties b/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties index b7790c0..e0dce29 100644 --- a/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties +++ b/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties @@ -1,3 +1,7 @@ loginAccountTitle=Account sign in doLogIn=Sign in doForgotPassword=Forgot password +tjwaterLogoutTitle=Signed out securely +tjwaterLogoutEyebrow=Your session has ended +tjwaterLogoutDescription=Your platform and identity-provider sessions have been ended securely. +tjwaterReturnToApplication=Return to sign in diff --git a/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties b/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties index 30bd332..3fba4a9 100644 --- a/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties +++ b/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties @@ -7,3 +7,7 @@ invalidUserMessage=用户名或密码错误 invalidUsernameOrPasswordMessage=用户名或密码错误 expiredCodeMessage=登录已超时,请重新登录 loginTimeout=登录已超时,请重新开始登录 +tjwaterLogoutTitle=已安全退出 +tjwaterLogoutEyebrow=会话已结束 +tjwaterLogoutDescription=您的平台与身份认证会话均已安全结束。 +tjwaterReturnToApplication=返回登录页 diff --git a/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css b/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css index 9d84925..19a185d 100644 --- a/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css +++ b/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css @@ -367,6 +367,60 @@ a:focus-visible { background: transparent; } +.tjwater-logout-message { + display: grid; + gap: 20px; + padding-top: 4px; +} + +.tjwater-logout-mark { + display: grid; + width: 52px; + height: 52px; + border: 1px solid oklch(0.76 0.09 184 / 52%); + border-radius: 50%; + background: oklch(0.92 0.04 184 / 58%); + place-items: center; +} + +.tjwater-logout-mark span { + width: 18px; + height: 10px; + border-bottom: 3px solid var(--tjwater-teal); + border-left: 3px solid var(--tjwater-teal); + transform: translateY(-2px) rotate(-45deg); +} + +.tjwater-logout-copy { + display: grid; + gap: 7px; +} + +.tjwater-logout-eyebrow { + margin: 0; + color: var(--tjwater-ink); + font-size: 17px; + font-weight: 700; + line-height: 1.45; +} + +.tjwater-logout-description { + margin: 0; + color: var(--tjwater-muted); + font-size: 14px; + line-height: 1.75; +} + +.tjwater-logout-action { + display: inline-flex; + width: 100%; + min-height: 48px; + align-items: center; + justify-content: center; + text-align: center; + text-decoration: none; +} + @keyframes tjwater-enter { from { opacity: 0; From b0a23a80121f232ef4dfbc04280f3866ad6f008c Mon Sep 17 00:00:00 2001 From: Jiang Date: Wed, 5 Aug 2026 18:39:00 +0800 Subject: [PATCH 86/93] fix(cli): map timeseries element types for backend --- cli/tjwater_cli/commands_data.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py index d08b14b..245234b 100644 --- a/cli/tjwater_cli/commands_data.py +++ b/cli/tjwater_cli/commands_data.py @@ -29,6 +29,10 @@ def _scheme_type_option(scheme_type: str | None) -> str: return scheme_type or "simulation" +def _backend_element_type(element_type: ElementType) -> str: + return "link" if element_type == ElementType.PIPE else "node" + + def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str: valid_fields = timeseries_fields_for_element_type(element_type) if property_name not in valid_fields: @@ -117,7 +121,7 @@ def data_realtime_simulation_by_id_time( path="/timeseries/realtime/simulation-results", params={ "id": id, - "type": type.value, + "type": _backend_element_type(type), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), }, require_auth=True, @@ -139,7 +143,7 @@ def data_realtime_simulation_by_time_property( method="GET", path="/timeseries/realtime/records", params={ - "type": type.value, + "type": _backend_element_type(type), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), "property": property, }, @@ -218,7 +222,7 @@ def data_scheme_simulation( "scheme_name": resolve_scheme(runtime, scheme, required=True), "scheme_type": _scheme_type_option(scheme_type), "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), - "type": type.value, + "type": _backend_element_type(type), } if query == SimulationQuery.BY_ID_TIME: if not id: From 4350612807dc95426d3fd1f347360c50cbb54b7a Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 6 Aug 2026 15:58:47 +0800 Subject: [PATCH 87/93] refactor(cli): remove deprecated Python implementation --- AGENTS.md | 4 +- README.md | 13 +- cli/.gitignore | 3 - cli/README.md | 79 --- cli/build.sh | 26 - cli/entrypoint.py | 5 - cli/pyrightconfig.json | 14 - cli/requirements-build.txt | 1 - cli/requirements.txt | 3 - cli/tests/conftest.py | 6 - cli/tests/unit/test_tjwater_cli.py | 930 --------------------------- cli/tjwater.spec | 45 -- cli/tjwater_cli/__init__.py | 3 - cli/tjwater_cli/__main__.py | 5 - cli/tjwater_cli/apps.py | 76 --- cli/tjwater_cli/commands_analysis.py | 496 -------------- cli/tjwater_cli/commands_data.py | 502 --------------- cli/tjwater_cli/commands_readonly.py | 229 ------- cli/tjwater_cli/common.py | 59 -- cli/tjwater_cli/core.py | 602 ----------------- cli/tjwater_cli/formatters.py | 15 - cli/tjwater_cli/helping.py | 414 ------------ cli/tjwater_cli/main.py | 112 ---- cli/tjwater_cli/option_types.py | 72 --- cli/tjwater_cli/registry.py | 626 ------------------ cli/tjwater_cli_endpoint_scope.md | 423 ------------ 26 files changed, 3 insertions(+), 4760 deletions(-) delete mode 100644 cli/.gitignore delete mode 100644 cli/README.md delete mode 100755 cli/build.sh delete mode 100644 cli/entrypoint.py delete mode 100644 cli/pyrightconfig.json delete mode 100644 cli/requirements-build.txt delete mode 100644 cli/requirements.txt delete mode 100644 cli/tests/conftest.py delete mode 100644 cli/tests/unit/test_tjwater_cli.py delete mode 100644 cli/tjwater.spec delete mode 100644 cli/tjwater_cli/__init__.py delete mode 100644 cli/tjwater_cli/__main__.py delete mode 100644 cli/tjwater_cli/apps.py delete mode 100644 cli/tjwater_cli/commands_analysis.py delete mode 100644 cli/tjwater_cli/commands_data.py delete mode 100644 cli/tjwater_cli/commands_readonly.py delete mode 100644 cli/tjwater_cli/common.py delete mode 100644 cli/tjwater_cli/core.py delete mode 100644 cli/tjwater_cli/formatters.py delete mode 100644 cli/tjwater_cli/helping.py delete mode 100644 cli/tjwater_cli/main.py delete mode 100644 cli/tjwater_cli/option_types.py delete mode 100644 cli/tjwater_cli/registry.py delete mode 100644 cli/tjwater_cli_endpoint_scope.md diff --git a/AGENTS.md b/AGENTS.md index 34ee773..31f61e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`. -Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed. +Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed. ## Build, Test, and Development Commands @@ -29,7 +29,7 @@ The project uses `pytest`. Name test files `test_*.py` and test functions `test_ ## Commit & Pull Request Guidelines -History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary. +History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding` or `fix(auth): validate project context`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary. Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior. diff --git a/README.md b/README.md index d905590..1662b04 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TJWaterServerBinary 内部后端 -`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务集成和命令行工具能力。该仓库用于内部开发和完整功能维护。 +`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。 ## 技术栈 @@ -23,7 +23,6 @@ app/infra/ 数据库、缓存、EPANET 和外部集成 app/services/ 业务服务编排 app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析 app/native/ 本地管网数据读写与转换 -cli/ tjwater-cli 命令行工具 tests/ 后端测试 resources/ SQL、模板和示例资源 infra/docker/ Docker Compose 编排 @@ -58,16 +57,6 @@ docker compose -f infra/docker/docker-compose.yml config - `docker build`:构建后端镜像。 - `docker compose config`:检查 compose 配置和变量展开。 -## CLI - -CLI 位于 `cli/tjwater_cli`,说明见: - -```text -cli/README.md -``` - -修改 CLI 参数、输出结构或后端接口适配时,应同步更新 CLI 测试和文档。 - ## 开发规范 - Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。 diff --git a/cli/.gitignore b/cli/.gitignore deleted file mode 100644 index 995f861..0000000 --- a/cli/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -dist/ -build/ -__pycache__/ diff --git a/cli/README.md b/cli/README.md deleted file mode 100644 index 356638a..0000000 --- a/cli/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# TJWater CLI - -独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。 - -## 构建可执行产物 - -```bash -cd TJWaterServerBinary/cli -python -m pip install -r requirements.txt -python -m pip install -r requirements-build.txt -chmod +x build.sh -./build.sh -``` - -构建完成后,直接使用编译产物: - -```bash -./dist/tjwater-cli/tjwater-cli help -``` - -这个可执行文件可以直接参与管道: - -```bash -./dist/tjwater-cli/tjwater-cli help | jq -``` - -当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。 - -如果需要在开发时直接走源码入口,也可以显式使用 Python: - -```bash -python -m tjwater_cli help -``` - -## 部署到 agent 服务器 - -最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行: - -```bash -./tjwater-cli/tjwater-cli help -``` - -如果希望打包传输: - -```bash -cd TJWaterServerBinary/cli -tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli -``` - -如果希望放到 PATH 中: - -```bash -ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli -tjwater-cli help | jq -``` - -## 运行与构建依赖 - -```bash -cd TJWaterServerBinary/cli -python -m pip install -r requirements.txt -python -m pip install -r requirements-build.txt -``` - -`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。 - -## 认证上下文 - -CLI 通过 `--auth-context` 读取 JSON 文件。常用字段: - -```json -{ - "server": "http://backend-host:8000", - "access_token": "...", - "project_id": "...", - "network": "...", - "username": "..." -} -``` diff --git a/cli/build.sh b/cli/build.sh deleted file mode 100755 index f6574a6..0000000 --- a/cli/build.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [ -n "${PYTHON:-}" ]; then - PYTHON_BIN="$PYTHON" -elif command -v python >/dev/null 2>&1; then - PYTHON_BIN="python" -else - PYTHON_BIN="python3" -fi - -cd "$ROOT" - -"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec - -BIN_PATH="$ROOT/dist/" -if [ ! -x "$BIN_PATH" ]; then - echo "build succeeded but executable was not created: $BIN_PATH" >&2 - exit 1 -fi - -"$BIN_PATH" help >/dev/null - -echo "built executable: $BIN_PATH" diff --git a/cli/entrypoint.py b/cli/entrypoint.py deleted file mode 100644 index 46d2a02..0000000 --- a/cli/entrypoint.py +++ /dev/null @@ -1,5 +0,0 @@ -from tjwater_cli.main import console_entry - - -if __name__ == "__main__": - console_entry() diff --git a/cli/pyrightconfig.json b/cli/pyrightconfig.json deleted file mode 100644 index aea88d0..0000000 --- a/cli/pyrightconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "include": [ - "tjwater_cli", - "tests" - ], - "executionEnvironments": [ - { - "root": ".", - "extraPaths": [ - "." - ] - } - ] -} diff --git a/cli/requirements-build.txt b/cli/requirements-build.txt deleted file mode 100644 index 31f7e39..0000000 --- a/cli/requirements-build.txt +++ /dev/null @@ -1 +0,0 @@ -pyinstaller>=6.11,<7 diff --git a/cli/requirements.txt b/cli/requirements.txt deleted file mode 100644 index 8eb3f9b..0000000 --- a/cli/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -click>=8.1,<9 -requests>=2.31,<3 -typer>=0.12,<1 diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py deleted file mode 100644 index 17cdbe1..0000000 --- a/cli/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -from pathlib import Path -import sys - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py deleted file mode 100644 index eaa5e93..0000000 --- a/cli/tests/unit/test_tjwater_cli.py +++ /dev/null @@ -1,930 +0,0 @@ -import json -from pathlib import Path - -from typer.testing import CliRunner - -from tjwater_cli import common, core -from tjwater_cli.main import app, main - - -runner = CliRunner() - - -class DummyResponse: - def __init__(self, *, status_code=200, json_data=None, text="", headers=None, content=None): - self.status_code = status_code - self._json_data = json_data - self.text = text - self.headers = headers or {"content-type": "application/json"} - self.content = content if content is not None else text.encode("utf-8") - - @property - def ok(self): - return 200 <= self.status_code < 300 - - def json(self): - if self._json_data is None: - raise ValueError("no json") - return self._json_data - - -def test_load_auth_context_supports_aliases(monkeypatch): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setenv("TJWATER_PROJECT_ID", "p1") - - auth = core.load_auth_context(auth_stdin=False) - - assert auth.server == "http://server" - assert auth.access_token == "abc" - assert auth.project_id == "p1" - - -def test_build_runtime_context_uses_default_server(monkeypatch): - monkeypatch.delenv("TJWATER_SERVER", raising=False) - monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False) - monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False) - monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False) - - runtime = core.build_runtime_context( - server=None, - scheme=None, - timeout=core.DEFAULT_TIMEOUT, - request_id="req-1", - ) - - assert runtime.server == core.DEFAULT_SERVER - - -def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch): - observed_runtime_ids: list[int] = [] - - def fake_request_json(ctx, **kwargs): - observed_runtime_ids.append(id(ctx)) - assert ctx.auth.access_token == "token-1" - assert kwargs["params"] == {"junction": "11"} - return {"id": "11"}, 5 - - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke( - app, - ["--auth-stdin", "network", "get-junction-properties", "--junction", "11"], - input=json.dumps( - { - "server": "http://server", - "access_token": "token-1", - "project_id": "project-1", - } - ), - ) - - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "11"} - assert len(observed_runtime_ids) == 1 - - -def test_network_get_junction_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return {"id": "J1"}, 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "J1"} - assert captured == { - "access_token": "abc", - "path": "/junctions/properties", - "params": {"junction": "J1"}, - } - - -def test_network_get_pipe_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return {"id": "P1"}, 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "P1"} - assert captured == { - "access_token": "abc", - "path": "/pipes/properties", - "params": {"pipe": "P1"}, - } - - -def test_network_get_all_pipes_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return [{"id": "P1"}], 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-all-pipes-properties"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == [{"id": "P1"}] - assert captured == { - "access_token": "abc", - "path": "/pipes", - "params": {}, - } - - -def test_network_get_reservoir_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return {"id": "R1"}, 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "R1"} - assert captured == { - "access_token": "abc", - "path": "/reservoirs/properties", - "params": {"reservoir": "R1"}, - } - - -def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return [{"id": "R1"}], 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-all-reservoirs-properties"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == [{"id": "R1"}] - assert captured == { - "access_token": "abc", - "path": "/reservoirs", - "params": {}, - } - - -def test_network_get_tank_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return {"id": "T1"}, 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "T1"} - assert captured == { - "access_token": "abc", - "path": "/tanks/properties", - "params": {"tank": "T1"}, - } - - -def test_network_get_all_tank_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return [{"id": "T1"}], 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-all-tanks-properties"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == [{"id": "T1"}] - assert captured == { - "access_token": "abc", - "path": "/tanks", - "params": {}, - } - - -def test_network_get_pump_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return {"id": "PU1"}, 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "PU1"} - assert captured == { - "access_token": "abc", - "path": "/pumps/properties", - "params": {"pump": "PU1"}, - } - - -def test_network_get_all_pump_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return [{"id": "PU1"}], 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-all-pumps-properties"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == [{"id": "PU1"}] - assert captured == { - "access_token": "abc", - "path": "/pumps", - "params": {}, - } - - -def test_network_get_valve_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return {"id": "V1"}, 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == {"id": "V1"} - assert captured == { - "access_token": "abc", - "path": "/valves/properties", - "params": {"valve": "V1"}, - } - - -def test_network_get_all_valve_properties_uses_network_context(monkeypatch): - captured = {} - - def fake_request_json(ctx, **kwargs): - captured["access_token"] = ctx.auth.access_token - captured["path"] = kwargs["path"] - captured["params"] = kwargs["params"] - return [{"id": "V1"}], 5 - - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - monkeypatch.setattr(common, "request_json", fake_request_json) - - result = runner.invoke(app, ["network", "get-all-valves-properties"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["ok"] is True - assert payload["data"] == [{"id": "V1"}] - assert captured == { - "access_token": "abc", - "path": "/valves", - "params": {}, - } - - -def test_help_outputs_json_lists_commands(): - result = runner.invoke(app, ["help"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["schema_version"] == "tjwater-cli/v1" - assert any(command["command"] == "analysis" for command in payload["commands"]) - assert all(command["command"] != "project" for command in payload["commands"]) - assert payload["menu_level"] == 1 - assert all(command["command"] != "project list" for command in payload["commands"]) - - -def test_help_option_json_is_removed(): - result = runner.invoke(app, ["help", "--json"]) - - assert result.exit_code == 2 - assert "No such option: --json" in result.output - - -def test_simulation_help_lists_subcommands(): - result = runner.invoke(app, ["simulation", "help"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["summary"] == "模拟运行与调度相关命令。" - commands = {command["command"]: command for command in payload["commands"]} - assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行" - assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time --duration " - assert "tjwater-cli" in commands["simulation run"]["example"] - assert "simulation run" in commands["simulation run"]["example"] - - -def test_nested_group_help_lists_examples(): - result = runner.invoke(app, ["analysis", "leakage", "help"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["summary"] == "漏损分析相关命令。" - commands = {command["command"]: command for command in payload["commands"]} - assert commands["analysis leakage identify"]["summary"] == "执行漏损识别" - assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01" - - -def test_analysis_help_uses_group_summaries_for_nested_groups(): - result = runner.invoke(app, ["analysis", "help"]) - payload = json.loads(result.stdout) - commands = {command["command"]: command for command in payload["commands"]} - - assert result.exit_code == 0 - assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。" - assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。" - assert "analysis burst-location" not in commands - assert "analysis risk" not in commands - assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01" - assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01" - - -def test_bare_analysis_uses_typer_help_with_descriptions(): - result = runner.invoke(app, ["analysis"]) - - assert result.exit_code == 2 - assert "分析计算与诊断相关命令。" in result.stdout - assert "burst 执行爆管分析" in result.stdout - assert "valve" in result.stdout - assert "leakage 漏损分析相关命令。" in result.stdout - assert "burst-location" not in result.stdout - assert "risk" not in result.stdout - - -def test_leaf_help_outputs_json(): - result = runner.invoke(app, ["help", "simulation", "run"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - assert payload["command"] == "simulation run" - assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询" - assert payload["usage"] == "tjwater-cli simulation run --start-time --duration " - assert len(payload["examples"]) == 1 - assert "simulation run" in payload["examples"][0] - - -def test_root_help_flag_uses_typer_style_with_examples(): - result = runner.invoke(app, ["--help"], prog_name="tjwater-cli") - - assert result.exit_code == 0 - assert "Usage: tjwater-cli" in result.stdout - assert "Examples:" in result.stdout - assert "tjwater-cli help simulation run" in result.stdout - - -def test_leaf_help_flag_includes_usage_and_example(): - result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli") - - assert result.exit_code == 0 - assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout - assert "Usage example:" in result.stdout - assert "--start-time " in result.stdout - assert "--duration" in result.stdout - assert "Examples:" in result.stdout - assert "tjwater-cli simulation run" in result.stdout - assert "START_TIME" in result.stdout - assert "DURATION" in result.stdout - - -def test_realtime_simulation_help_clarifies_type_values(): - result = runner.invoke( - app, - ["data", "timeseries", "realtime", "simulation-by-id-time", "--help"], - prog_name="tjwater-cli", - ) - - assert result.exit_code == 0 - assert "links/nodes 是子命令" in result.stdout - assert "pipe" in result.stdout - assert "junction" in result.stdout - - -def test_realtime_property_help_lists_supported_fields(): - result = runner.invoke( - app, - ["data", "timeseries", "realtime", "simulation-by-time-property", "--help"], - prog_name="tjwater-cli", - ) - - assert result.exit_code == 0 - assert "flow" in result.stdout - assert "pressure" in result.stdout - assert "actual_demand" in result.stdout - assert "velocity" in result.stdout - - -def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - burst_path = tmp_path / "burst.json" - burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8") - - def fake_request(**kwargs): - return DummyResponse(text="success", headers={"content-type": "text/plain"}) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - [ - "analysis", - "burst", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--duration", - "30", - "--burst-file", - str(burst_path), - "--scheme", - "burst_case_01", - ], - ) - - assert result.exit_code == 0 - assert '"summary": "爆管分析执行成功"' in result.stdout - assert "tjwater-cli data scheme get --name burst_case_01" in result.stdout - assert "tjwater-cli data scheme list" in result.stdout - - -def test_analysis_contaminant_sends_required_scheme_name(monkeypatch): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - captured = {} - - def fake_request(**kwargs): - captured.update(kwargs) - return DummyResponse(text="success", headers={"content-type": "text/plain"}) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - [ - "analysis", - "contaminant", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--duration", - "900", - "--source-node", - "N1", - "--concentration", - "10.0", - "--scheme", - "contam_case_01", - ], - ) - - assert result.exit_code == 0 - assert captured["params"] == { - "start_time": "2025-01-02T03:04:05+08:00", - "source": "N1", - "concentration": 10.0, - "duration": 900, - "scheme_name": "contam_case_01", - } - - -def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - captured = {} - valve_path = tmp_path / "valve.json" - valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") - - def fake_request(**kwargs): - captured.update(kwargs) - return DummyResponse(text="success", headers={"content-type": "text/plain"}) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - [ - "analysis", - "flushing", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--valve-setting-file", - str(valve_path), - "--drainage-node", - "N1", - "--flow", - "100.0", - "--duration", - "900", - "--scheme", - "flush_case_01", - ], - ) - - assert result.exit_code == 0 - assert captured["params"] == { - "start_time": "2025-01-02T03:04:05+08:00", - "valves": ["V1"], - "valves_k": [0.5], - "drainage_node_id": "N1", - "flush_flow": 100.0, - "duration": 900, - "scheme_name": "flush_case_01", - } - - -def test_analysis_valve_close_sends_required_scheme_name(monkeypatch): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - captured = {} - - def fake_request(**kwargs): - captured.update(kwargs) - return DummyResponse(text="success", headers={"content-type": "text/plain"}) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - [ - "analysis", - "valve", - "--mode", - "close", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--valve", - "V1", - "--duration", - "900", - "--scheme", - "valve_case_01", - ], - ) - - assert result.exit_code == 0 - assert captured["params"] == { - "start_time": "2025-01-02T03:04:05+08:00", - "valves": ["V1"], - "duration": 900, - "scheme_name": "valve_case_01", - } - - -def test_analysis_contaminant_requires_scheme(monkeypatch, capsys): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - - exit_code = main( - [ - "analysis", - "contaminant", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--duration", - "900", - "--source-node", - "N1", - "--concentration", - "10.0", - ], - ) - - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "SCHEME_REQUIRED"' in stdout - - -def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - valve_path = tmp_path / "valve.json" - valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8") - - exit_code = main( - [ - "analysis", - "flushing", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--valve-setting-file", - str(valve_path), - "--drainage-node", - "N1", - "--flow", - "100.0", - ], - ) - - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "SCHEME_REQUIRED"' in stdout - - -def test_analysis_valve_close_requires_scheme(monkeypatch, capsys): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - - exit_code = main( - [ - "analysis", - "valve", - "--mode", - "close", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--valve", - "V1", - "--duration", - "900", - ], - ) - - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "SCHEME_REQUIRED"' in stdout - - -def test_main_missing_option_error_includes_usage_and_next_step(capsys): - exit_code = main(["simulation", "run"]) - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"summary": "缺少参数"' in stdout - assert '"code": "MISSING_PARAMETER"' in stdout - assert '"usage": "tjwater-cli simulation run --start-time --duration "' in stdout - assert '"tjwater-cli help simulation run"' in stdout - - -def test_main_invalid_enum_value_is_rejected_before_request(capsys): - exit_code = main( - [ - "data", - "timeseries", - "realtime", - "simulation-by-id-time", - "--id", - "J1", - "--type", - "links", - "--time", - "2025-01-02T03:30:00+08:00", - ] - ) - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"summary": "参数无效"' in stdout - assert '"code": "INVALID_PARAMETER"' in stdout - assert "links" in stdout - assert "pipe" in stdout - assert "junction" in stdout - - -def test_main_invalid_pipe_property_is_rejected_before_request(capsys): - exit_code = main( - [ - "data", - "timeseries", - "realtime", - "simulation-by-time-property", - "--type", - "pipe", - "--time", - "2025-01-02T03:30:00+08:00", - "--property", - "pressure", - ] - ) - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "INVALID_PROPERTY"' in stdout - assert "flow" in stdout - assert "velocity" in stdout - - -def test_main_invalid_scada_field_is_rejected_before_request(capsys): - exit_code = main( - [ - "data", - "timeseries", - "scada", - "query", - "--device-id", - "D1", - "--start-time", - "2025-01-02T03:00:00+08:00", - "--end-time", - "2025-01-02T04:00:00+08:00", - "--field", - "flow", - ] - ) - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "INVALID_FIELD"' in stdout - assert "monitored_value" in stdout - assert "cleaned_value" in stdout - - -def test_data_scada_get_rejects_removed_kind_before_request(capsys): - exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"]) - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "INVALID_PARAMETER"' in stdout - assert "device" in stdout - assert "info" in stdout - - -def test_data_scada_list_help_only_shows_info_kind(): - result = runner.invoke(app, ["data", "scada", "list", "--help"]) - - assert result.exit_code == 0 - assert "info" in result.stdout - assert "device" not in result.stdout - assert "element" not in result.stdout - - -def test_data_scada_help_no_longer_lists_schema(): - result = runner.invoke(app, ["data", "scada", "help"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - commands = {command["command"] for command in payload["commands"]} - assert "data scada get" in commands - assert "data scada list" in commands - assert "data scada schema" not in commands - - -def test_data_scada_schema_command_is_removed(): - result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"]) - - assert result.exit_code == 2 - assert "No such command 'schema'" in result.output - - -def test_data_help_no_longer_lists_extension_or_misc(): - result = runner.invoke(app, ["data", "help"]) - payload = json.loads(result.stdout) - - assert result.exit_code == 0 - commands = {command["command"] for command in payload["commands"]} - assert "data timeseries" in commands - assert "data scada" in commands - assert "data scheme" in commands - assert "data extension" not in commands - assert "data misc" not in commands - - -def test_removed_data_extension_and_misc_commands_fail(): - extension_result = runner.invoke(app, ["data", "extension", "list"]) - misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"]) - - assert extension_result.exit_code == 2 - assert "No such command 'extension'" in extension_result.output - assert misc_result.exit_code == 2 - assert "No such command 'misc'" in misc_result.output - - -def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): - exit_code = main(["analysis"]) - stdout = capsys.readouterr().out - - assert exit_code == 0 - assert "Usage: tjwater-cli analysis" in stdout - assert "分析计算与诊断相关命令。" in stdout - assert '"ok": false' not in stdout - - -def test_simulation_run_translates_rfc3339(monkeypatch): - monkeypatch.setenv("TJWATER_SERVER", "http://server") - monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") - captured = {} - - def fake_request(**kwargs): - captured.update(kwargs) - return DummyResponse(json_data={"status": "success", "message": "Simulation started"}) - - monkeypatch.setattr(core.requests, "request", fake_request) - - result = runner.invoke( - app, - [ - "simulation", - "run", - "--start-time", - "2025-01-02T03:04:05+08:00", - "--duration", - "30", - ], - ) - - assert result.exit_code == 0 - assert captured["json"] == { - "start_time": "2025-01-02T03:04:05+08:00", - "duration": 30, - } - assert "tjwater-cli data timeseries realtime links" in result.stdout - assert "tjwater-cli data timeseries realtime nodes" in result.stdout - - -def test_removed_project_command_returns_not_found(capsys): - exit_code = main(["project", "list"]) - stdout = capsys.readouterr().out - - assert exit_code == 2 - assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout diff --git a/cli/tjwater.spec b/cli/tjwater.spec deleted file mode 100644 index 6a034b9..0000000 --- a/cli/tjwater.spec +++ /dev/null @@ -1,45 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -from PyInstaller.utils.hooks import collect_data_files - - -datas = collect_data_files("certifi") - - -a = Analysis( - ["entrypoint.py"], - pathex=["."], - binaries=[], - datas=datas, - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name="tjwater-cli", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=True, -) - -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name="tjwater-cli", -) diff --git a/cli/tjwater_cli/__init__.py b/cli/tjwater_cli/__init__.py deleted file mode 100644 index d1b1862..0000000 --- a/cli/tjwater_cli/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .main import app, main - -__all__ = ["app", "main"] diff --git a/cli/tjwater_cli/__main__.py b/cli/tjwater_cli/__main__.py deleted file mode 100644 index 8462220..0000000 --- a/cli/tjwater_cli/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .main import console_entry - - -if __name__ == "__main__": - console_entry() diff --git a/cli/tjwater_cli/apps.py b/cli/tjwater_cli/apps.py deleted file mode 100644 index cd7614d..0000000 --- a/cli/tjwater_cli/apps.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import typer - -from .formatters import TJWaterGroup - -app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup) -network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) -data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) - -app.add_typer(network_app, name="network") -app.add_typer(component_app, name="component") -component_app.add_typer(component_option_app, name="option") -app.add_typer(simulation_app, name="simulation") -app.add_typer(analysis_app, name="analysis") -analysis_app.add_typer(analysis_sensor_placement_app, name="sensor-placement") -analysis_app.add_typer(analysis_leakage_app, name="leakage") -analysis_leakage_app.add_typer(analysis_leakage_schemes_app, name="schemes") -analysis_app.add_typer(analysis_burst_detection_app, name="burst-detection") -analysis_burst_detection_app.add_typer(analysis_burst_detection_schemes_app, name="schemes") -analysis_app.add_typer(analysis_burst_location_app, name="burst-location") -analysis_burst_location_app.add_typer(analysis_burst_location_schemes_app, name="schemes") -analysis_app.add_typer(analysis_risk_app, name="risk") -app.add_typer(data_app, name="data") -data_app.add_typer(data_timeseries_app, name="timeseries") -data_timeseries_app.add_typer(data_timeseries_realtime_app, name="realtime") -data_timeseries_app.add_typer(data_timeseries_scheme_app, name="scheme") -data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada") -data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite") -data_app.add_typer(data_scada_app, name="scada") -data_app.add_typer(data_scheme_app, name="scheme") - -GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ - (network_app, ("network",)), - (component_app, ("component",)), - (component_option_app, ("component", "option")), - (simulation_app, ("simulation",)), - (analysis_app, ("analysis",)), - (analysis_sensor_placement_app, ("analysis", "sensor-placement")), - (analysis_leakage_app, ("analysis", "leakage")), - (analysis_leakage_schemes_app, ("analysis", "leakage", "schemes")), - (analysis_burst_detection_app, ("analysis", "burst-detection")), - (analysis_burst_detection_schemes_app, ("analysis", "burst-detection", "schemes")), - (analysis_burst_location_app, ("analysis", "burst-location")), - (analysis_burst_location_schemes_app, ("analysis", "burst-location", "schemes")), - (analysis_risk_app, ("analysis", "risk")), - (data_app, ("data",)), - (data_timeseries_app, ("data", "timeseries")), - (data_timeseries_realtime_app, ("data", "timeseries", "realtime")), - (data_timeseries_scheme_app, ("data", "timeseries", "scheme")), - (data_timeseries_scada_app, ("data", "timeseries", "scada")), - (data_timeseries_composite_app, ("data", "timeseries", "composite")), - (data_scada_app, ("data", "scada")), - (data_scheme_app, ("data", "scheme")), -] - -TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"} diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py deleted file mode 100644 index a933e74..0000000 --- a/cli/tjwater_cli/commands_analysis.py +++ /dev/null @@ -1,496 +0,0 @@ -from __future__ import annotations - -from datetime import timedelta -from pathlib import Path -from typing import Annotated - -import typer - -from .apps import ( - analysis_app, - analysis_burst_detection_app, - analysis_burst_detection_schemes_app, - analysis_burst_location_app, - analysis_burst_location_schemes_app, - analysis_leakage_app, - analysis_leakage_schemes_app, - analysis_risk_app, - analysis_sensor_placement_app, - simulation_app, -) -from .common import emit_api, runtime_context -from .core import ( - CLIError, - emit_success, - parse_burst_file, - parse_optional_dataset_file, - parse_time_with_timezone, - parse_valve_setting_file, - request_json, - resolve_scheme, -) -from .option_types import DataSource, ValveMode - - -@simulation_app.command("run") -def simulation_run( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - duration: Annotated[int, typer.Option("--duration", help="持续分钟数")], -) -> None: - runtime = runtime_context(ctx) - parsed = parse_time_with_timezone(start_time, option_name="--start-time") - end_time = (parsed + timedelta(minutes=duration)).isoformat() - body = { - "start_time": parsed.replace(microsecond=0).isoformat(), - "duration": duration, - } - emit_api( - ctx, - summary="触发模拟成功", - method="POST", - path="/simulation-runs", - json_body=body, - require_auth=True, - next_commands=[ - f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", - f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", - ], - ) - - -@analysis_app.command("burst") -def analysis_burst( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - duration: Annotated[int, typer.Option("--duration", help="持续秒数")], - burst_file: Annotated[Path, typer.Option("--burst-file", help="爆管输入 JSON 文件")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - ids, sizes = parse_burst_file(burst_file) - scheme_name = resolve_scheme(runtime, scheme, required=True) - params = { - "modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "burst_id": ids, - "burst_size": sizes, - "modify_total_duration": duration, - "scheme_name": scheme_name, - } - emit_api( - ctx, - summary="爆管分析执行成功", - method="POST", - path="/burst-analyses", - params=params, - require_auth=True, - next_commands=[ - f"tjwater-cli data scheme get --name {scheme_name}", - "tjwater-cli data scheme list", - ], - ) - - -@analysis_app.command("valve") -def analysis_valve( - ctx: typer.Context, - mode: Annotated[ValveMode, typer.Option("--mode", help="分析模式,仅支持 close|isolation")], - start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None, - valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None, - element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, - disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None, - duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None, - scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - if mode == ValveMode.CLOSE: - if not start_time or not valve: - raise CLIError( - "CLI 参数错误", - code="INVALID_VALVE_CLOSE_ARGS", - message="close mode requires --start-time and at least one --valve", - exit_code=2, - ) - params = { - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "valves": valve, - "duration": duration or 900, - "scheme_name": resolve_scheme(runtime, scheme, required=True), - } - emit_api( - ctx, - summary="阀门关闭分析执行成功", - method="POST", - path="/valve-isolation-analyses", - params=params, - require_auth=True, - ) - return - if mode == ValveMode.ISOLATION: - if not element: - raise CLIError( - "CLI 参数错误", - code="INVALID_VALVE_ISOLATION_ARGS", - message="isolation mode requires at least one --element", - exit_code=2, - ) - params = {"accident_element": element} - if disabled_valve: - params["disabled_valves"] = disabled_valve - emit_api( - ctx, - summary="阀门隔离分析执行成功", - method="POST", - path="/valve-isolation-analyses", - params=params, - require_auth=True, - ) - return - raise AssertionError(f"unreachable valve mode: {mode}") - - -@analysis_app.command("flushing") -def analysis_flushing( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - valve_setting_file: Annotated[Path, typer.Option("--valve-setting-file", help="阀门开度 JSON 文件")], - drainage_node: Annotated[str, typer.Option("--drainage-node", help="排污节点")], - flow: Annotated[float, typer.Option("--flow", help="冲洗流量")], - duration: Annotated[int | None, typer.Option("--duration", help="持续秒数")] = None, - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - valves, openings = parse_valve_setting_file(valve_setting_file) - params = { - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "valves": valves, - "valves_k": openings, - "drainage_node_id": drainage_node, - "flush_flow": flow, - "duration": duration or 900, - "scheme_name": resolve_scheme(runtime, scheme, required=True), - } - emit_api( - ctx, - summary="冲洗分析执行成功", - method="POST", - path="/flushing-analyses", - params=params, - require_auth=True, - ) - - -@analysis_app.command("age") -def analysis_age( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - duration: Annotated[int, typer.Option("--duration", help="持续秒数")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="水龄分析执行成功", - method="POST", - path="/water-age-analyses", - params={ - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "duration": duration, - }, - require_auth=True, - ) - - -@analysis_app.command("contaminant") -def analysis_contaminant( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - duration: Annotated[int, typer.Option("--duration", help="持续秒数")], - source_node: Annotated[str, typer.Option("--source-node", help="污染源节点")], - concentration: Annotated[float, typer.Option("--concentration", help="浓度")], - pattern: Annotated[str | None, typer.Option("--pattern", help="模式 ID")] = None, - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - params = { - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "source": source_node, - "concentration": concentration, - "duration": duration, - "scheme_name": resolve_scheme(runtime, scheme, required=True), - } - if pattern: - params["pattern"] = pattern - emit_api( - ctx, - summary="污染物模拟执行成功", - method="POST", - path="/contaminant-simulations", - params=params, - require_auth=True, - ) - - -@analysis_sensor_placement_app.command("kmeans") -def analysis_sensor_placement_kmeans( - ctx: typer.Context, - count: Annotated[int, typer.Option("--count", help="传感器数量")], - min_diameter: Annotated[int, typer.Option("--min-diameter", help="最小管径")] = 0, - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - body = { - "scheme_name": resolve_scheme(runtime, scheme, required=True), - "sensor_number": count, - "min_diameter": min_diameter, - } - emit_api( - ctx, - summary="传感器选址执行成功", - method="POST", - path="/pressure-sensor-placement-kmeans", - json_body=body, - require_auth=True, - ) - - -@analysis_leakage_app.command("identify") -def analysis_leakage_identify( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - body = { - "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - "scheme_name": resolve_scheme(runtime, scheme, required=True), - } - emit_api( - ctx, - summary="漏损识别执行成功", - method="POST", - path="/leakage-identifications", - json_body=body, - require_auth=True, - ) - - -@analysis_leakage_schemes_app.command("list") -def analysis_leakage_schemes_list(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取漏损方案列表成功", - method="GET", - path="/schemes", - params={ - "scheme_type": "dma_leak_identification", - }, - require_auth=True, - ) - - -@analysis_leakage_schemes_app.command("get") -def analysis_leakage_schemes_get( - ctx: typer.Context, - scheme_name: Annotated[str, typer.Argument(help="方案名称")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取漏损方案详情成功", - method="GET", - path=f"/schemes/{scheme_name}", - params={ - "scheme_type": "dma_leak_identification", - }, - require_auth=True, - ) - - -@analysis_burst_detection_app.command("detect") -def analysis_burst_detection_detect( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, -) -> None: - runtime = runtime_context(ctx) - body = { - "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - "scheme_name": resolve_scheme(runtime, scheme, required=True), - } - emit_api( - ctx, - summary="爆管检测执行成功", - method="POST", - path="/burst-detections", - json_body=body, - require_auth=True, - ) - - -@analysis_burst_detection_schemes_app.command("list") -def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取爆管检测方案列表成功", - method="GET", - path="/schemes", - params={ - "scheme_type": "burst_detection", - }, - require_auth=True, - ) - - -@analysis_burst_detection_schemes_app.command("get") -def analysis_burst_detection_schemes_get( - ctx: typer.Context, - scheme_name: Annotated[str, typer.Argument(help="方案名称")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取爆管检测方案详情成功", - method="GET", - path=f"/schemes/{scheme_name}", - params={ - "scheme_type": "burst_detection", - }, - require_auth=True, - ) - - -@analysis_burst_location_app.command("locate") -def analysis_burst_location_locate( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], - burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - data_source: Annotated[DataSource, typer.Option("--data-source", help="数据来源,仅支持 monitoring|simulation")] = DataSource.MONITORING, - pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None, - flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None, - pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None, - flow_file: Annotated[Path | None, typer.Option("--flow-file", help="包含 burst_flow/normal_flow 的 JSON 文件")] = None, - use_scada_flow: Annotated[bool, typer.Option("--use-scada-flow", help="启用 SCADA 流量")] = False, -) -> None: - runtime = runtime_context(ctx) - pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {} - flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {} - body = { - "scheme_name": resolve_scheme(runtime, scheme, required=True), - "data_source": data_source.value, - "scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - "burst_leakage": burst_leakage, - "use_scada_flow": use_scada_flow, - } - if pressure_scada_id: - body["pressure_scada_ids"] = pressure_scada_id - if flow_scada_id: - body["flow_scada_ids"] = flow_scada_id - if isinstance(pressure_payload, dict): - body.update({key: value for key, value in pressure_payload.items() if key in {"burst_pressure", "normal_pressure"}}) - if isinstance(flow_payload, dict): - body.update({key: value for key, value in flow_payload.items() if key in {"burst_flow", "normal_flow"}}) - emit_api( - ctx, - summary="爆管定位执行成功", - method="POST", - path="/burst-locations", - json_body=body, - require_auth=True, - ) - - -@analysis_burst_location_schemes_app.command("list") -def analysis_burst_location_schemes_list(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取爆管定位方案列表成功", - method="GET", - path="/schemes", - params={ - "scheme_type": "burst_location", - }, - require_auth=True, - ) - - -@analysis_burst_location_schemes_app.command("get") -def analysis_burst_location_schemes_get( - ctx: typer.Context, - scheme_name: Annotated[str, typer.Argument(help="方案名称")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取爆管定位方案详情成功", - method="GET", - path=f"/schemes/{scheme_name}", - params={ - "scheme_type": "burst_location", - }, - require_auth=True, - ) - - -@analysis_risk_app.command("pipe-now") -def analysis_risk_pipe_now( - ctx: typer.Context, - pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取当前管道风险成功", - method="GET", - path="/pipes/risk-probability-now", - params={"pipe_id": pipe}, - require_auth=True, - ) - - -@analysis_risk_app.command("pipe-history") -def analysis_risk_pipe_history( - ctx: typer.Context, - pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取历史管道风险成功", - method="GET", - path="/pipes/risk-probability", - params={"pipe_id": pipe}, - require_auth=True, - ) - - -@analysis_risk_app.command("network") -def analysis_risk_network(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - probabilities, duration_prob = request_json( - runtime, - method="GET", - path="/network-pipe-risk-probability-nows", - require_auth=True, - ) - geometries, duration_geo = request_json( - runtime, - method="GET", - path="/pipes/risk-probability-geometries", - require_auth=True, - ) - emit_success( - summary="读取全网风险成功", - data={"probabilities": probabilities, "geometries": geometries}, - ctx=runtime, - duration_ms=duration_prob + duration_geo, - ) diff --git a/cli/tjwater_cli/commands_data.py b/cli/tjwater_cli/commands_data.py deleted file mode 100644 index 245234b..0000000 --- a/cli/tjwater_cli/commands_data.py +++ /dev/null @@ -1,502 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -import typer - -from .apps import ( - data_scada_app, - data_scheme_app, - data_timeseries_composite_app, - data_timeseries_realtime_app, - data_timeseries_scada_app, - data_timeseries_scheme_app, -) -from .common import emit_api, runtime_context -from .core import CLIError, parse_time_with_timezone, resolve_scheme -from .option_types import ( - CompositeKind, - ElementType, - JUNCTION_TIMESERIES_FIELDS, - SCADA_TIMESERIES_FIELDS, - ScadaListKind, - SimulationQuery, - timeseries_fields_for_element_type, -) - - -def _scheme_type_option(scheme_type: str | None) -> str: - return scheme_type or "simulation" - - -def _backend_element_type(element_type: ElementType) -> str: - return "link" if element_type == ElementType.PIPE else "node" - - -def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str: - valid_fields = timeseries_fields_for_element_type(element_type) - if property_name not in valid_fields: - raise CLIError( - "CLI 参数错误", - code="INVALID_PROPERTY", - message=f"{option_name} for --type {element_type.value} must be one of: {', '.join(valid_fields)}", - exit_code=2, - ) - return property_name - - -def _validate_node_field(field_name: str, *, option_name: str) -> str: - if field_name not in JUNCTION_TIMESERIES_FIELDS: - raise CLIError( - "CLI 参数错误", - code="INVALID_FIELD", - message=f"{option_name} must be one of: {', '.join(JUNCTION_TIMESERIES_FIELDS)}", - exit_code=2, - ) - return field_name - - -def _validate_scada_field(field_name: str, *, option_name: str) -> str: - if field_name not in SCADA_TIMESERIES_FIELDS: - raise CLIError( - "CLI 参数错误", - code="INVALID_FIELD", - message=f"{option_name} must be one of: {', '.join(SCADA_TIMESERIES_FIELDS)}", - exit_code=2, - ) - return field_name - - -@data_timeseries_realtime_app.command("links") -def data_realtime_links( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], -) -> None: - emit_api( - ctx, - summary="读取实时管道数据成功", - method="GET", - path="/timeseries/realtime/links", - params={ - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - }, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_realtime_app.command("nodes") -def data_realtime_nodes( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], -) -> None: - emit_api( - ctx, - summary="读取实时节点数据成功", - method="GET", - path="/timeseries/realtime/nodes", - params={ - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - }, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_realtime_app.command("simulation-by-id-time") -def data_realtime_simulation_by_id_time( - ctx: typer.Context, - id: Annotated[str, typer.Option("--id", help="元素 ID")], - type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")], - time: Annotated[str, typer.Option("--time", help="查询时间")], -) -> None: - emit_api( - ctx, - summary="读取实时模拟数据成功", - method="GET", - path="/timeseries/realtime/simulation-results", - params={ - "id": id, - "type": _backend_element_type(type), - "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), - }, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_realtime_app.command("simulation-by-time-property") -def data_realtime_simulation_by_time_property( - ctx: typer.Context, - type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")], - time: Annotated[str, typer.Option("--time", help="查询时间")], - property: Annotated[str, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")], -) -> None: - property = _validate_element_property(type, property, option_name="--property") - emit_api( - ctx, - summary="读取实时属性聚合数据成功", - method="GET", - path="/timeseries/realtime/records", - params={ - "type": _backend_element_type(type), - "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), - "property": property, - }, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_scheme_app.command("links") -def data_scheme_links( - ctx: typer.Context, - start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取方案管道数据成功", - method="GET", - path="/timeseries/schemes/links", - params={ - "scheme_name": resolve_scheme(runtime, scheme, required=True), - "scheme_type": _scheme_type_option(scheme_type), - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - }, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_scheme_app.command("node-field") -def data_scheme_node_field( - ctx: typer.Context, - node: Annotated[str, typer.Option("--node", help="节点 ID")], - field: Annotated[str, typer.Option("--field", help="字段名,仅支持 actual_demand|total_head|pressure|quality")], - start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, -) -> None: - runtime = runtime_context(ctx) - field = _validate_node_field(field, option_name="--field") - emit_api( - ctx, - summary="读取方案节点字段成功", - method="GET", - path=f"/timeseries/schemes/nodes/{node}/field", - params={ - "field": field, - "scheme_name": resolve_scheme(runtime, scheme, required=True), - "scheme_type": _scheme_type_option(scheme_type), - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - }, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_scheme_app.command("simulation") -def data_scheme_simulation( - ctx: typer.Context, - query: Annotated[SimulationQuery, typer.Option("--query", help="查询模式,仅支持 by-id-time|by-scheme-time-property")], - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, - id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None, - time: Annotated[str, typer.Option("--time", help="查询时间")] = "", - type: Annotated[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE, - property: Annotated[str | None, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")] = None, -) -> None: - runtime = runtime_context(ctx) - params = { - "scheme_name": resolve_scheme(runtime, scheme, required=True), - "scheme_type": _scheme_type_option(scheme_type), - "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), - "type": _backend_element_type(type), - } - if query == SimulationQuery.BY_ID_TIME: - if not id: - raise CLIError( - "CLI 参数错误", - code="ID_REQUIRED", - message="--id is required for --query by-id-time", - exit_code=2, - ) - params["id"] = id - emit_api( - ctx, - summary="读取方案单点模拟数据成功", - method="GET", - path="/timeseries/schemes/simulation-results", - params=params, - require_auth=True, - require_project=True, - ) - return - if query == SimulationQuery.BY_SCHEME_TIME_PROPERTY: - if not property: - raise CLIError( - "CLI 参数错误", - code="PROPERTY_REQUIRED", - message="--property is required for --query by-scheme-time-property", - exit_code=2, - ) - property = _validate_element_property(type, property, option_name="--property") - params["property"] = property - emit_api( - ctx, - summary="读取方案属性聚合数据成功", - method="GET", - path="/timeseries/schemes/records", - params=params, - require_auth=True, - require_project=True, - ) - return - raise AssertionError(f"unreachable query variant: {query}") - - -@data_timeseries_scada_app.command("query") -def data_scada_query( - ctx: typer.Context, - device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")], - start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], - field: Annotated[str | None, typer.Option("--field", help="字段名,仅支持 monitored_value|cleaned_value")] = None, -) -> None: - path = "/timeseries/scada-readings/fields" if field else "/timeseries/scada-readings" - params = { - "device_ids": ",".join(device_id), - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - } - if field: - field = _validate_scada_field(field, option_name="--field") - params["field"] = field - emit_api( - ctx, - summary="读取 SCADA 时序成功", - method="GET", - path=path, - params=params, - require_auth=True, - require_project=True, - ) - - -@data_timeseries_composite_app.callback(invoke_without_command=True) -def data_timeseries_composite( - ctx: typer.Context, - kind: Annotated[CompositeKind | None, typer.Option("--kind", help="复合查询类型,仅支持 scada-simulation|element-simulation|element-scada")] = None, - feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None, - start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None, - end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None, - pipe: Annotated[str | None, typer.Option("--pipe", help="pipeline-health 用管道 ID")] = None, - scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, - scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, - use_cleaned: Annotated[bool, typer.Option("--use-cleaned", help="element-scada 使用清洗值")] = False, -) -> None: - _ = pipe - if ctx.invoked_subcommand is not None: - return - if not kind or not start_time or not end_time: - raise CLIError( - "CLI 参数错误", - code="INVALID_COMPOSITE_ARGS", - message="composite query requires --kind, --start-time, and --end-time", - exit_code=2, - ) - runtime = runtime_context(ctx) - params = { - "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), - "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - } - if kind == CompositeKind.SCADA_SIMULATION: - if not feature: - raise CLIError( - "CLI 参数错误", - code="FEATURE_REQUIRED", - message="--feature is required for scada-simulation", - exit_code=2, - ) - params["device_ids"] = ",".join(feature) - scheme_name = resolve_scheme(runtime, scheme) - if scheme_name: - params["scheme_name"] = scheme_name - params["scheme_type"] = _scheme_type_option(scheme_type) - emit_api( - ctx, - summary="读取复合 SCADA-模拟数据成功", - method="GET", - path="/timeseries/views/scada-simulations", - params=params, - require_auth=True, - require_project=True, - ) - return - if kind == CompositeKind.ELEMENT_SIMULATION: - if not feature: - raise CLIError( - "CLI 参数错误", - code="FEATURE_REQUIRED", - message="--feature is required for element-simulation", - exit_code=2, - ) - params["feature_infos"] = ",".join(feature) - scheme_name = resolve_scheme(runtime, scheme) - if scheme_name: - params["scheme_name"] = scheme_name - params["scheme_type"] = _scheme_type_option(scheme_type) - emit_api( - ctx, - summary="读取复合元素模拟数据成功", - method="GET", - path="/timeseries/views/element-simulations", - params=params, - require_auth=True, - require_project=True, - ) - return - if kind == CompositeKind.ELEMENT_SCADA: - if not feature or len(feature) != 1: - raise CLIError( - "CLI 参数错误", - code="FEATURE_REQUIRED", - message="element-scada requires exactly one --feature as element_id", - exit_code=2, - ) - params["element_id"] = feature[0] - params["use_cleaned"] = use_cleaned - emit_api( - ctx, - summary="读取元素关联 SCADA 数据成功", - method="GET", - path="/timeseries/views/element-scada-readings", - params=params, - require_auth=True, - require_project=True, - ) - return - raise AssertionError(f"unreachable composite kind: {kind}") - - -@data_timeseries_composite_app.command("pipeline-health") -def data_composite_pipeline_health( - ctx: typer.Context, - pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], - start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], - end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], -) -> None: - _ = pipe, start_time - emit_api( - ctx, - summary="读取管道健康预测成功", - method="GET", - path="/pipeline-health-predictions", - params={ - "query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), - }, - require_auth=True, - require_project=True, - ) - - -def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: - mapping = { - ("info", "get"): ("/scada-info/detail", {"id_param": "id"}), - ("info", "list"): ("/scada-info", {}), - } - result = mapping.get((kind, action)) - if result is None: - raise CLIError( - "CLI 参数错误", - code="INVALID_SCADA_KIND", - message=f"unsupported scada {action} kind: {kind}", - exit_code=2, - ) - return result - - -@data_scada_app.command("get") -def data_scada_get( - ctx: typer.Context, - kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")], - id: Annotated[str, typer.Option("--id", help="记录 ID")], -) -> None: - runtime = runtime_context(ctx) - path, meta = _scada_mapping(kind.value, "get") - params = {meta["id_param"]: id} - emit_api( - ctx, - summary="读取 SCADA 数据成功", - method="GET", - path=path, - params=params, - require_auth=True, - ) - - -@data_scada_app.command("list") -def data_scada_list( - ctx: typer.Context, - kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")], -) -> None: - runtime = runtime_context(ctx) - path, _ = _scada_mapping(kind.value, "list") - emit_api( - ctx, - summary="读取 SCADA 列表成功", - method="GET", - path=path, - require_auth=True, - ) - - -@data_scheme_app.command("schema") -def data_scheme_schema(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取方案 schema 成功", - method="GET", - path="/network-schemas/scheme", - require_auth=True, - ) - - -@data_scheme_app.command("get") -def data_scheme_get( - ctx: typer.Context, - name: Annotated[str, typer.Option("--name", help="方案名称")], -) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取方案成功", - method="GET", - path="/schemes/detail", - params={"schema_name": name}, - require_auth=True, - ) - - -@data_scheme_app.command("list") -def data_scheme_list(ctx: typer.Context) -> None: - runtime = runtime_context(ctx) - emit_api( - ctx, - summary="读取方案列表成功", - method="GET", - path="/schemes", - require_auth=True, - ) diff --git a/cli/tjwater_cli/commands_readonly.py b/cli/tjwater_cli/commands_readonly.py deleted file mode 100644 index 035fc41..0000000 --- a/cli/tjwater_cli/commands_readonly.py +++ /dev/null @@ -1,229 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -import typer - -from .apps import component_option_app, network_app -from .common import emit_api -from .core import CLIError -from .option_types import ComponentOptionKind - - -@network_app.command("get-junction-properties") -def network_get_junction_properties( - ctx: typer.Context, - junction: Annotated[str, typer.Option("--junction", help="节点 ID")], -) -> None: - emit_api( - ctx, - summary="读取节点属性成功", - method="GET", - path="/junctions/properties", - params={"junction": junction}, - require_auth=True, - ) - - -@network_app.command("get-pipe-properties") -def network_get_pipe_properties( - ctx: typer.Context, - pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], -) -> None: - emit_api( - ctx, - summary="读取管道属性成功", - method="GET", - path="/pipes/properties", - params={"pipe": pipe}, - require_auth=True, - ) - - -@network_app.command("get-all-pipes-properties") -def network_get_all_pipes_properties(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取全部管道属性成功", - method="GET", - path="/pipes", - params={}, - require_auth=True, - ) - - -@network_app.command("get-reservoir-properties") -def network_get_reservoir_properties( - ctx: typer.Context, - reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")], -) -> None: - emit_api( - ctx, - summary="读取水库属性成功", - method="GET", - path="/reservoirs/properties", - params={"reservoir": reservoir}, - require_auth=True, - ) - - -@network_app.command("get-all-reservoirs-properties") -def network_get_all_reservoir_properties(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取全部水库属性成功", - method="GET", - path="/reservoirs", - params={}, - require_auth=True, - ) - - -@network_app.command("get-tank-properties") -def network_get_tank_properties( - ctx: typer.Context, - tank: Annotated[str, typer.Option("--tank", help="水箱 ID")], -) -> None: - emit_api( - ctx, - summary="读取水箱属性成功", - method="GET", - path="/tanks/properties", - params={"tank": tank}, - require_auth=True, - ) - - -@network_app.command("get-all-tanks-properties") -def network_get_all_tank_properties(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取全部水箱属性成功", - method="GET", - path="/tanks", - params={}, - require_auth=True, - ) - - -@network_app.command("get-pump-properties") -def network_get_pump_properties( - ctx: typer.Context, - pump: Annotated[str, typer.Option("--pump", help="水泵 ID")], -) -> None: - emit_api( - ctx, - summary="读取水泵属性成功", - method="GET", - path="/pumps/properties", - params={"pump": pump}, - require_auth=True, - ) - - -@network_app.command("get-all-pumps-properties") -def network_get_all_pump_properties(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取全部水泵属性成功", - method="GET", - path="/pumps", - params={}, - require_auth=True, - ) - - -@network_app.command("get-valve-properties") -def network_get_valve_properties( - ctx: typer.Context, - valve: Annotated[str, typer.Option("--valve", help="阀门 ID")], -) -> None: - emit_api( - ctx, - summary="读取阀门属性成功", - method="GET", - path="/valves/properties", - params={"valve": valve}, - require_auth=True, - ) - - -@network_app.command("get-all-valves-properties") -def network_get_all_valve_properties(ctx: typer.Context) -> None: - emit_api( - ctx, - summary="读取全部阀门属性成功", - method="GET", - path="/valves", - params={}, - require_auth=True, - ) - - -@component_option_app.command("schema") -def component_option_schema( - ctx: typer.Context, - kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")], - pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, -) -> None: - path = _component_option_path(kind.value, schema=True) - params: dict[str, str] = {} - if kind == ComponentOptionKind.PUMP_ENERGY and pump: - params["pump"] = pump - emit_api( - ctx, - summary="读取选项 schema 成功", - method="GET", - path=path, - params=params, - require_auth=True, - ) - - -@component_option_app.command("get") -def component_option_get( - ctx: typer.Context, - kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")], - pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None, -) -> None: - path = _component_option_path(kind.value, schema=False) - params: dict[str, str] = {} - if kind == ComponentOptionKind.PUMP_ENERGY: - if not pump: - raise CLIError( - "CLI 参数错误", - code="PUMP_REQUIRED", - message="--pump is required when --kind pump-energy", - exit_code=2, - ) - params["pump"] = pump - emit_api( - ctx, - summary="读取选项属性成功", - method="GET", - path=path, - params=params, - require_auth=True, - ) - - -def _component_option_path(kind: str, *, schema: bool) -> str: - routes = { - ("time", True): "/network-schemas/time", - ("time", False): "/network-options/time", - ("energy", True): "/network-schemas/energy", - ("energy", False): "/network-options/energy", - ("pump-energy", True): "/network-schemas/pump-energy", - ("pump-energy", False): "/network-options/pump-energy", - ("network", True): "/network-schemas/option", - ("network", False): "/network-options", - } - path = routes.get((kind, schema)) - if path is None: - raise CLIError( - "CLI 参数错误", - code="INVALID_KIND", - message="--kind must be one of time, energy, pump-energy, network", - exit_code=2, - ) - return path diff --git a/cli/tjwater_cli/common.py b/cli/tjwater_cli/common.py deleted file mode 100644 index 7a1e9c2..0000000 --- a/cli/tjwater_cli/common.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import typer - -from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_json - - -def runtime_context(ctx: typer.Context): - obj = ctx.obj - if not isinstance(obj, dict): - obj = {} - ctx.obj = obj - - cached_runtime = obj.get("_runtime_context") - if cached_runtime is not None: - return cached_runtime - - runtime = build_runtime_context( - server=obj.get("server"), - auth_stdin=obj.get("auth_stdin", False), - scheme=obj.get("scheme"), - timeout=obj.get("timeout", DEFAULT_TIMEOUT), - request_id=obj.get("request_id"), - ) - obj["_runtime_context"] = runtime - return runtime - - -def emit_api( - ctx: typer.Context, - *, - summary: str, - method: str, - path: str, - params: dict[str, Any] | None = None, - json_body: Any = None, - require_auth: bool = True, - require_project: bool = False, - next_commands: list[str] | None = None, -) -> None: - runtime = runtime_context(ctx) - data, duration_ms = request_json( - runtime, - method=method, - path=path, - params=params, - json_body=json_body, - require_auth=require_auth, - require_project=require_project, - ) - emit_success( - summary=summary, - data=data, - ctx=runtime, - duration_ms=duration_ms, - next_commands=next_commands, - ) diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py deleted file mode 100644 index eb5056b..0000000 --- a/cli/tjwater_cli/core.py +++ /dev/null @@ -1,602 +0,0 @@ -from __future__ import annotations - -import json -import os -import sys -import time -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Mapping - -import requests -import typer - -SCHEMA_VERSION = "tjwater-cli/v1" -CLI_NAME = "tjwater-cli" -DEFAULT_TIMEOUT = 180 -DEFAULT_SERVER = "http://192.168.1.114:8000" -class CLIError(Exception): - def __init__( - self, - summary: str, - *, - code: str, - message: str, - exit_code: int, - retryable: bool = False, - next_commands: list[str] | None = None, - data: Any = None, - ) -> None: - super().__init__(message) - self.summary = summary - self.code = code - self.message = message - self.exit_code = exit_code - self.retryable = retryable - self.next_commands = next_commands or [] - self.data = data - - -@dataclass(frozen=True) -class AuthContext: - server: str | None = None - access_token: str | None = None - project_id: str | None = None - headers: dict[str, str] = field(default_factory=dict) - - -@dataclass(frozen=True) -class RuntimeContext: - server: str | None - auth: AuthContext - scheme: str | None - timeout: int - request_id: str - - -@dataclass(frozen=True) -class CommandOptionDoc: - name: str - description: str - required: bool = False - repeated: bool = False - default: Any = None - - -@dataclass(frozen=True) -class CommandDoc: - path: tuple[str, ...] - summary: str - description: str - options: tuple[CommandOptionDoc, ...] = () - examples: tuple[str, ...] = () - next_commands: tuple[str, ...] = () - output: str = "标准 JSON 输出" - - -def _pick(mapping: Mapping[str, Any], *keys: str) -> Any: - for key in keys: - value = mapping.get(key) - if value not in (None, ""): - return value - return None - - -def load_auth_context(auth_stdin: bool = False) -> AuthContext: - if auth_stdin: - raw = json.loads(sys.stdin.read()) - else: - extra_headers = os.getenv("TJWATER_EXTRA_HEADERS") - raw = { - "server": os.getenv("TJWATER_SERVER"), - "access_token": os.getenv("TJWATER_ACCESS_TOKEN"), - "project_id": os.getenv("TJWATER_PROJECT_ID"), - "headers": json.loads(extra_headers) if extra_headers else {}, - } - - headers = raw.get("headers") or {} - if not isinstance(headers, dict): - raise CLIError( - "认证失败", - code="AUTH_CONTEXT_INVALID", - message="auth context headers must be a JSON object", - exit_code=3, - ) - - return AuthContext( - server=_pick(raw, "server", "base_url"), - access_token=_pick(raw, "access_token", "token", "accessToken"), - project_id=_pick(raw, "project_id", "projectId", "x_project_id"), - headers={str(key): str(value) for key, value in headers.items()}, - ) - - -def build_runtime_context( - *, - server: str | None, - auth_stdin: bool = False, - scheme: str | None, - timeout: int, - request_id: str | None, -) -> RuntimeContext: - auth = load_auth_context(auth_stdin=auth_stdin) - resolved_request_id = request_id or str(uuid.uuid4()) - return RuntimeContext( - server=server or auth.server or DEFAULT_SERVER, - auth=auth, - scheme=scheme, - timeout=timeout, - request_id=resolved_request_id, - ) - - -def require_server(ctx: RuntimeContext) -> str: - if ctx.server: - return ctx.server.rstrip("/") - raise CLIError( - "认证失败", - code="SERVER_REQUIRED", - message="missing server URL; use --server or include server in auth context", - exit_code=3, - ) - - -def require_access_token(ctx: RuntimeContext) -> str: - if ctx.auth.access_token: - return ctx.auth.access_token - raise CLIError( - "认证失败", - code="UNAUTHENTICATED", - message="missing access token for agent context", - exit_code=3, - next_commands=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"], - ) - - -def require_project_id(ctx: RuntimeContext) -> str: - if ctx.auth.project_id: - return ctx.auth.project_id - raise CLIError( - "认证失败", - code="PROJECT_CONTEXT_REQUIRED", - message="missing project_id for agent context", - exit_code=3, - next_commands=["add project_id to auth context"], - ) - - -def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None: - scheme = explicit_scheme or ctx.scheme - if required and not scheme: - raise CLIError( - "CLI 参数错误", - code="SCHEME_REQUIRED", - message="missing scheme; use --scheme", - exit_code=2, - ) - return scheme - - -def parse_time_with_timezone(value: str, *, option_name: str) -> datetime: - try: - parsed = datetime.fromisoformat(value) - except ValueError as exc: - raise CLIError( - "CLI 参数错误", - code="INVALID_TIME", - message=f"{option_name} must be a valid ISO 8601 / RFC 3339 timestamp", - exit_code=2, - ) from exc - if parsed.tzinfo is None: - raise CLIError( - "CLI 参数错误", - code="TIMEZONE_REQUIRED", - message=f"{option_name} must include an explicit timezone offset", - exit_code=2, - ) - return parsed - - -def read_json_input(path: Path, *, label: str) -> Any: - try: - return json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise CLIError( - "CLI 参数错误", - code="INPUT_NOT_FOUND", - message=f"{label} file not found: {path}", - exit_code=2, - ) from exc - except json.JSONDecodeError as exc: - raise CLIError( - "CLI 参数错误", - code="INPUT_INVALID_JSON", - message=f"{label} file must be valid JSON: {path}", - exit_code=2, - ) from exc - - -def parse_burst_file(path: Path) -> tuple[list[str], list[float]]: - raw = read_json_input(path, label="burst") - if isinstance(raw, dict) and "bursts" in raw: - raw = raw["bursts"] - if isinstance(raw, dict) and "burst_id" in raw and "burst_size" in raw: - ids = [str(item) for item in raw["burst_id"]] - sizes = [float(item) for item in raw["burst_size"]] - if len(ids) != len(sizes): - raise CLIError( - "CLI 参数错误", - code="BURST_FILE_INVALID", - message="burst file burst_id and burst_size must have the same length", - exit_code=2, - ) - return ids, sizes - if isinstance(raw, list): - ids: list[str] = [] - sizes: list[float] = [] - for item in raw: - if not isinstance(item, dict) or "id" not in item or "size" not in item: - raise CLIError( - "CLI 参数错误", - code="BURST_FILE_INVALID", - message="burst file items must contain id and size", - exit_code=2, - ) - ids.append(str(item["id"])) - sizes.append(float(item["size"])) - return ids, sizes - raise CLIError( - "CLI 参数错误", - code="BURST_FILE_INVALID", - message="burst file must be a JSON array or object with burst_id/burst_size", - exit_code=2, - ) - - -def parse_valve_setting_file(path: Path) -> tuple[list[str], list[float]]: - raw = read_json_input(path, label="valve-setting") - if isinstance(raw, dict) and "valves" in raw and "valves_k" in raw: - valves = [str(item) for item in raw["valves"]] - openings = [float(item) for item in raw["valves_k"]] - if len(valves) != len(openings): - raise CLIError( - "CLI 参数错误", - code="VALVE_SETTING_INVALID", - message="valves and valves_k must have the same length", - exit_code=2, - ) - return valves, openings - if isinstance(raw, list): - valves: list[str] = [] - openings: list[float] = [] - for item in raw: - if not isinstance(item, dict) or "valve" not in item or "opening" not in item: - raise CLIError( - "CLI 参数错误", - code="VALVE_SETTING_INVALID", - message="valve-setting items must contain valve and opening", - exit_code=2, - ) - valves.append(str(item["valve"])) - openings.append(float(item["opening"])) - return valves, openings - raise CLIError( - "CLI 参数错误", - code="VALVE_SETTING_INVALID", - message="valve-setting file must be a JSON array or object with valves/valves_k", - exit_code=2, - ) - - -def parse_optional_dataset_file(path: Path | None, *, label: str) -> Any: - if path is None: - return None - return read_json_input(path, label=label) - - -def build_headers( - ctx: RuntimeContext, - *, - require_auth: bool, - require_project: bool, -) -> dict[str, str]: - headers = { - "Accept": "application/json, text/plain, */*", - "X-Request-Id": ctx.request_id, - } - headers.update(ctx.auth.headers) - if require_auth: - headers["Authorization"] = f"Bearer {require_access_token(ctx)}" - elif ctx.auth.access_token: - headers["Authorization"] = f"Bearer {ctx.auth.access_token}" - if require_project: - headers["X-Project-Id"] = require_project_id(ctx) - elif ctx.auth.project_id: - headers["X-Project-Id"] = ctx.auth.project_id - return headers - - -def _extract_error_message(response: requests.Response) -> str: - try: - payload = response.json() - except ValueError: - text = response.text.strip() - return text or f"http {response.status_code}" - - if isinstance(payload, dict): - detail = payload.get("detail") - if isinstance(detail, str): - return detail - if isinstance(detail, list): - return "; ".join(json.dumps(item, ensure_ascii=False) for item in detail) - message = payload.get("message") - if isinstance(message, str): - return message - return json.dumps(payload, ensure_ascii=False) - - -def map_http_status_to_exit_code(status_code: int) -> int: - if status_code in (400, 422): - return 2 - if status_code == 401: - return 3 - if status_code == 403: - return 4 - if status_code == 404: - return 5 - if status_code in (409, 412): - return 6 - return 7 - - -def _parse_response_body(response: requests.Response) -> Any: - if response.status_code == 204 or not response.content: - return {} - content_type = response.headers.get("content-type", "").lower() - if "application/json" in content_type: - payload = response.json() - if isinstance(payload, dict) and payload.get("status") == "error": - raise CLIError( - "服务端错误", - code="SERVER_ERROR", - message=str(payload.get("message") or "server returned error status"), - exit_code=7, - data=payload, - ) - return payload - text = response.text - if text: - return {"report": text} - return {} - - -def _prepare_public_request( - method: str, - path: str, - params: dict[str, Any] | None, - json_body: Any, -) -> tuple[str, str, dict[str, Any] | None, Any]: - return method.upper(), path.rstrip("/") or "/", params or None, json_body - - -def request_json( - ctx: RuntimeContext, - *, - method: str, - path: str, - params: dict[str, Any] | None = None, - json_body: Any = None, - require_auth: bool = True, - require_project: bool = False, -) -> tuple[Any, int]: - require_server(ctx) - method, path, params, json_body = _prepare_public_request( - method, - path, - params, - json_body, - ) - url = f"{require_server(ctx)}/api/v1{path}" - headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) - started = time.monotonic() - try: - response = requests.request( - method=method.upper(), - url=url, - params=params, - json=json_body, - headers=headers, - timeout=ctx.timeout, - ) - except requests.Timeout as exc: - raise CLIError( - "请求超时", - code="REQUEST_TIMEOUT", - message=f"request timed out after {ctx.timeout} seconds", - exit_code=7, - retryable=True, - ) from exc - except requests.RequestException as exc: - raise CLIError( - "连接失败", - code="REQUEST_FAILED", - message=str(exc), - exit_code=7, - retryable=True, - ) from exc - duration_ms = int((time.monotonic() - started) * 1000) - - if not response.ok: - raise CLIError( - "请求失败", - code=f"HTTP_{response.status_code}", - message=_extract_error_message(response), - exit_code=map_http_status_to_exit_code(response.status_code), - retryable=response.status_code >= 500, - ) - return _parse_response_body(response), duration_ms - - -def request_bytes( - ctx: RuntimeContext, - *, - method: str, - path: str, - params: dict[str, Any] | None = None, - require_auth: bool = True, - require_project: bool = False, -) -> tuple[bytes, int]: - require_server(ctx) - method, path, params, _ = _prepare_public_request( - method, - path, - params, - None, - ) - url = f"{require_server(ctx)}/api/v1{path}" - headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) - started = time.monotonic() - try: - response = requests.request( - method=method.upper(), - url=url, - params=params, - headers=headers, - timeout=ctx.timeout, - ) - except requests.Timeout as exc: - raise CLIError( - "请求超时", - code="REQUEST_TIMEOUT", - message=f"request timed out after {ctx.timeout} seconds", - exit_code=7, - retryable=True, - ) from exc - except requests.RequestException as exc: - raise CLIError( - "连接失败", - code="REQUEST_FAILED", - message=str(exc), - exit_code=7, - retryable=True, - ) from exc - duration_ms = int((time.monotonic() - started) * 1000) - - if not response.ok: - raise CLIError( - "请求失败", - code=f"HTTP_{response.status_code}", - message=_extract_error_message(response), - exit_code=map_http_status_to_exit_code(response.status_code), - retryable=response.status_code >= 500, - ) - return response.content, duration_ms - - -def build_success_payload( - *, - summary: str, - data: Any, - server: str | None, - request_id: str, - duration_ms: int, - next_commands: list[str] | None = None, -) -> dict[str, Any]: - return { - "ok": True, - "schema_version": SCHEMA_VERSION, - "summary": summary, - "data": data, - "metadata": { - "request_id": request_id, - "server": server, - "duration_ms": duration_ms, - "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), - }, - "next_commands": next_commands or [], - } - - -def build_failure_payload( - *, - summary: str, - code: str, - message: str, - retryable: bool, - server: str | None, - request_id: str | None, - next_commands: list[str] | None = None, - data: Any = None, -) -> dict[str, Any]: - return { - "ok": False, - "schema_version": SCHEMA_VERSION, - "summary": summary, - "error": { - "code": code, - "message": message, - "retryable": retryable, - }, - "data": data, - "metadata": { - "request_id": request_id, - "server": server, - "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), - }, - "next_commands": next_commands or [], - } - - -def emit_success( - *, - summary: str, - data: Any, - ctx: RuntimeContext, - duration_ms: int, - next_commands: list[str] | None = None, -) -> None: - typer.echo( - json.dumps( - build_success_payload( - summary=summary, - data=data, - server=ctx.server, - request_id=ctx.request_id, - duration_ms=duration_ms, - next_commands=next_commands, - ), - ensure_ascii=False, - ) - ) - - -def emit_failure( - *, - summary: str, - code: str, - message: str, - exit_code: int, - retryable: bool, - server: str | None, - request_id: str | None, - next_commands: list[str] | None = None, - data: Any = None, -) -> int: - typer.echo( - json.dumps( - build_failure_payload( - summary=summary, - code=code, - message=message, - retryable=retryable, - server=server, - request_id=request_id, - next_commands=next_commands, - data=data, - ), - ensure_ascii=False, - ) - ) - return exit_code diff --git a/cli/tjwater_cli/formatters.py b/cli/tjwater_cli/formatters.py deleted file mode 100644 index bf94950..0000000 --- a/cli/tjwater_cli/formatters.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -import click -import typer.core - - -class TJWaterGroup(typer.core.TyperGroup): - def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: - super().format_help(ctx, formatter) - from .helping import build_group_help_appendix - - appendix = build_group_help_appendix(ctx) - if appendix: - formatter.write_paragraph() - formatter.write_text(appendix) diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py deleted file mode 100644 index 5a6895c..0000000 --- a/cli/tjwater_cli/helping.py +++ /dev/null @@ -1,414 +0,0 @@ -from __future__ import annotations - -import json -from typing import Annotated, Any - -import click -import typer - -from .apps import GROUP_HELP_APPS, TOP_LEVEL_COMMANDS, app -from .core import CLIError -from .registry import ( - get_command_doc, - get_group_summary, - has_subcommands, - is_hidden_path, - list_capabilities, - list_subcommands, -) - - -def _click_root_command() -> click.Command: - # Must stay lazy: the click tree is only complete after command modules import. - return typer.main.get_command(app) - - -def _normalize_command_path(tokens: list[str]) -> tuple[str, ...]: - while tokens and tokens[0] not in TOP_LEVEL_COMMANDS: - tokens = tokens[1:] - return tuple(tokens) - - -def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]: - if click_ctx is None: - return () - return _normalize_command_path(click_ctx.command_path.split()) - - -def _build_click_context(path: tuple[str, ...]) -> click.Context | None: - root = _click_root_command() - ctx: click.Context = click.Context(root, info_name="tjwater-cli") - command: click.Command = root - for token in path: - if not isinstance(command, click.Group): - return None - next_command = command.commands.get(token) - if next_command is None: - return None - ctx = click.Context(next_command, info_name=token, parent=ctx) - command = next_command - return ctx - - -def build_usage(path: tuple[str, ...]) -> str | None: - ctx = _build_click_context(path) - if ctx is None: - return None - parts = ["tjwater-cli", *path] - for parameter in ctx.command.params: - if not isinstance(parameter, click.Option): - continue - if "--help" in parameter.opts: - continue - option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") - if parameter.is_flag: - parts.append(f"--{option_name}" if parameter.required else f"[--{option_name}]") - continue - placeholder = option_name.upper().replace("-", "_") - if parameter.required: - parts.extend([f"--{option_name}", f"<{placeholder}>"]) - else: - parts.append(f"[--{option_name} <{placeholder}>]") - return " ".join(parts) - - -def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]: - ctx = _build_click_context(path) - if ctx is None: - return [] - options: list[dict[str, Any]] = [] - for parameter in ctx.command.params: - if not isinstance(parameter, click.Option): - continue - if "--help" in parameter.opts: - continue - cli_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") - options.append( - { - "name": cli_name, - "description": parameter.help or "", - "required": parameter.required, - "repeated": parameter.multiple, - "default": parameter.default, - } - ) - return options - - -def _sample_option_value(path: tuple[str, ...], option_name: str) -> str: - path_specific_samples: dict[tuple[tuple[str, ...], str], str] = { - (("component", "option", "schema"), "kind"): "time", - (("component", "option", "get"), "kind"): "time", - (("data", "timeseries", "composite"), "kind"): "scada-simulation", - (("data", "scada", "get"), "kind"): "info", - (("data", "scada", "list"), "kind"): "info", - } - if (path, option_name) in path_specific_samples: - return path_specific_samples[(path, option_name)] - if option_name == "start-time": - return "2025-01-02T03:04:05+08:00" - if option_name == "end-time": - return "2025-01-02T04:04:05+08:00" - if option_name == "date": - return "2025-01-02" - if option_name == "duration": - return "30" - if option_name == "kind": - return "time" - if option_name == "mode": - return "close" - if option_name == "scheme": - return "baseline" - if option_name == "output": - return "./demo.inp" if "export-inp" in path else "./output.json" - if option_name == "pump": - return "PUMP-1" - if option_name == "node": - return "J1" - if option_name == "source-node": - return "J1" - if option_name == "drainage-node": - return "J2" - if option_name in {"link", "pipe", "pipe-id", "element-id", "element"}: - return "P1" - if option_name == "flow": - return "120.5" - if option_name == "concentration": - return "0.8" - if option_name == "device-id": - return "SCADA-001" - if option_name == "burst-file": - return "./burst.json" - if option_name == "valve-setting-file": - return "./valves.json" - if option_name.endswith("-file"): - return "./input.json" - if option_name.endswith("-id"): - return "demo-id" - return "demo" - - -def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None = None) -> str: - ctx = _build_click_context(path) - required_option_names: list[str] = [] - if ctx is not None: - required_option_names = [ - next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") - for parameter in ctx.command.params - if isinstance(parameter, click.Option) and "--help" not in parameter.opts and parameter.required - ] - if existing_examples: - for example in existing_examples: - has_required_options = all(f"--{option_name}" in example for option_name in required_option_names) - if has_required_options: - return example - parts = ["tjwater-cli", *path] - if ctx is None: - return " ".join(parts) - for parameter in ctx.command.params: - if not isinstance(parameter, click.Option): - continue - if "--help" in parameter.opts or not parameter.required: - continue - option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") - parts.extend([f"--{option_name}", _sample_option_value(path, option_name)]) - return " ".join(parts) - - -def _enrich_leaf_payload(payload: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]: - enriched = dict(payload) - enriched["usage"] = build_usage(path) or payload.get("usage") - click_options = _click_option_docs(path) - if click_options: - enriched["options"] = click_options - enriched["examples"] = payload.get("examples") or [] - if not enriched["examples"] or all("<" in example and ">" in example for example in enriched["examples"]): - enriched["examples"] = [_build_example(path, existing_examples=enriched["examples"])] - return enriched - - -def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]: - enriched = dict(payload) - commands: list[dict[str, Any]] = [] - for command in payload.get("commands", []): - command_item = dict(command) - path = tuple(command_item["command"].split()) - doc = get_command_doc(path) - if doc is None and has_subcommands(path): - command_item["usage"] = f"tjwater-cli {' '.join(path)} help" - command_item["example"] = f"tjwater-cli {' '.join(path)} help" - else: - existing_examples = [] if doc is None else list(doc.get("examples", [])) - command_item["usage"] = build_usage(path) or command_item.get("usage") - command_item["example"] = _build_example(path, existing_examples=existing_examples) - commands.append(command_item) - enriched["commands"] = commands - return enriched - - -def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, bool]: - if not path: - return list_capabilities(), True - payload = get_command_doc(path) - if payload is not None: - return _enrich_leaf_payload(payload, path), False - if has_subcommands(path): - return _enrich_index_payload(list_subcommands(path, get_group_summary(path))), True - return None, False - - -def emit_help_payload(payload: dict[str, Any]) -> None: - typer.echo(json.dumps(payload, ensure_ascii=False)) - - -def merge_next_commands(*groups: list[str] | None) -> list[str]: - merged: list[str] = [] - seen: set[str] = set() - for group in groups: - for command in group or []: - if command in seen: - continue - seen.add(command) - merged.append(command) - return merged - - -def merge_error_data(primary: Any, secondary: Any) -> Any: - if primary is None: - return secondary - if secondary is None: - return primary - if isinstance(primary, dict) and isinstance(secondary, dict): - return {**secondary, **primary} - return primary - - -def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str]]: - command_path = context_command_path(click_ctx) - usage = build_usage(command_path) if command_path else None - if command_path: - if command_path[-1] == "help": - group_path = command_path[:-1] - if group_path: - return ( - { - "command_group": " ".join(group_path), - "usage": f"tjwater-cli {' '.join(group_path)} help", - "examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"], - }, - merge_next_commands( - [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"], - ["tjwater-cli help"], - ), - ) - payload, is_index = resolve_help_payload(command_path) - if payload is not None and not is_index: - return ( - { - "command": payload["command"], - "usage": payload.get("usage") or usage, - "examples": payload.get("examples", []), - }, - merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]), - ) - if payload is not None and is_index: - return ( - { - "command_group": " ".join(command_path), - "usage": f"tjwater-cli {' '.join(command_path)} help", - "examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"], - }, - merge_next_commands( - [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"], - ["tjwater-cli help"], - ), - ) - return ({"usage": usage} if usage else None, ["tjwater-cli help"]) - - -def classify_click_error(exc: click.ClickException) -> tuple[str, str]: - if isinstance(exc, click.NoSuchOption): - return "未知选项", "UNKNOWN_OPTION" - if isinstance(exc, click.MissingParameter): - return "缺少参数", "MISSING_PARAMETER" - if isinstance(exc, click.BadParameter): - return "参数无效", "INVALID_PARAMETER" - message = exc.format_message() - if "No such command" in message: - return "未找到命令", "COMMAND_NOT_FOUND" - return "CLI 参数错误", "USAGE_ERROR" - - -def _build_root_help_epilog() -> str: - return "\n".join( - [ - "\b", - "Examples:", - " tjwater-cli help", - " tjwater-cli help simulation run", - " tjwater-cli simulation run --help", - ] - ) - - -def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str: - lines = ["\b"] - description = payload.get("description") - usage = payload.get("usage") - examples = payload.get("examples", []) - next_commands = payload.get("next_commands", []) - if description: - lines.extend([f"Description: {description}", ""]) - if usage: - lines.extend([f"Usage example: {usage}", ""]) - if examples: - lines.append("Examples:") - lines.extend(f" {example}" for example in examples) - lines.append("") - if next_commands: - lines.append("Next steps:") - lines.extend(f" {command}" for command in next_commands) - lines.append("") - lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"]) - return "\n".join(lines) - - -def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str: - lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"] - for command in payload.get("commands", [])[:2]: - example = command.get("example") - if example: - lines.append(f" {example}") - return "\n".join(lines) - - -def build_group_help_appendix(click_ctx: click.Context | None) -> str | None: - path = context_command_path(click_ctx) - if not path: - return _build_root_help_epilog() - payload, is_index = resolve_help_payload(path) - if payload is None or not is_index: - return None - return _build_group_help_epilog(path, payload) - - -def make_group_help_handler(path_prefix: tuple[str, ...]): - def group_help() -> None: - payload, is_index = resolve_help_payload(path_prefix) - if payload is None: - raise CLIError( - "未找到命令", - code="COMMAND_NOT_FOUND", - message=f"unknown command path: {' '.join(path_prefix)}", - exit_code=2, - next_commands=["tjwater-cli help"], - ) - emit_help_payload(payload) - - group_help.__name__ = f"{'_'.join(path_prefix)}_help" - return group_help - - -def register_group_help_commands() -> None: - for group_app, path_prefix in GROUP_HELP_APPS: - group_app.command("help")(make_group_help_handler(path_prefix)) - - -def apply_typer_help_metadata() -> None: - app.help = "\n".join( - [ - "TJWater agent CLI", - "", - "Examples:", - " tjwater-cli help", - " tjwater-cli help simulation run", - " tjwater-cli simulation run --help", - ] - ) - app.short_help = "TJWater agent CLI" - for group_app, path_prefix in GROUP_HELP_APPS: - for command_info in group_app.registered_commands: - command_path = (*path_prefix, command_info.name) - if command_info.name == "help": - command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。" - command_info.short_help = command_info.help - command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"]) - command_info.hidden = False - continue - payload = get_command_doc(command_path) - command_info.help = None if payload is None else str(payload.get("summary", "")) - command_info.short_help = command_info.help - command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload) - command_info.hidden = is_hidden_path(command_path) - for group_info in group_app.registered_groups: - group_path = (*path_prefix, group_info.name) - summary = get_group_summary(group_path) - group_info.help = summary - group_info.short_help = summary - group_info.hidden = is_hidden_path(group_path) - for group_info in app.registered_groups: - group_path = (group_info.name,) - summary = get_group_summary(group_path) - group_info.help = summary - group_info.short_help = summary - group_info.hidden = is_hidden_path(group_path) diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py deleted file mode 100644 index 50a7bfc..0000000 --- a/cli/tjwater_cli/main.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path -from typing import Annotated - -import click -import typer -from click.exceptions import NoArgsIsHelpError - -from . import commands_analysis, commands_data, commands_readonly # noqa: F401 -from .apps import app -from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure -from .helping import ( - apply_typer_help_metadata, - build_error_guidance, - classify_click_error, - emit_help_payload, - merge_error_data, - merge_next_commands, - register_group_help_commands, - resolve_help_payload, -) - - -@app.callback() -def root_callback( - ctx: typer.Context, - server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None, - auth_stdin: Annotated[bool, typer.Option("--auth-stdin", help="从标准输入读取认证上下文 JSON")] = False, - scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None, - timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT, - request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None, -) -> None: - ctx.obj = { - "server": server, - "auth_stdin": auth_stdin, - "scheme": scheme, - "timeout": timeout, - "request_id": request_id, - } - - -register_group_help_commands() - - -@app.command("help", context_settings={"allow_extra_args": True}) -def help_command(ctx: typer.Context) -> None: - command_path = list(ctx.args) - payload, is_index = resolve_help_payload(tuple(command_path)) - if payload is None: - emit_failure( - summary="未找到命令", - code="COMMAND_NOT_FOUND", - message=f"unknown command path: {' '.join(command_path)}", - exit_code=2, - retryable=False, - server=None, - request_id=None, - data={ - "usage": "tjwater-cli help ", - "examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"], - }, - next_commands=["tjwater-cli help", "tjwater-cli help simulation"], - ) - raise typer.Exit(code=2) - emit_help_payload(payload) - - -# Must run at import time because tests call runner.invoke(app, ...) directly. -apply_typer_help_metadata() - - -def main(argv: list[str] | None = None) -> int: - try: - app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False) - return 0 - except CLIError as exc: - click_ctx = click.get_current_context(silent=True) - error_data, next_commands = build_error_guidance(click_ctx) - return emit_failure( - summary=exc.summary, - code=exc.code, - message=exc.message, - exit_code=exc.exit_code, - retryable=exc.retryable, - server=None, - request_id=None, - next_commands=merge_next_commands(exc.next_commands, next_commands), - data=merge_error_data(exc.data, error_data), - ) - except NoArgsIsHelpError: - return 0 - except click.ClickException as exc: - click_ctx = click.get_current_context(silent=True) or exc.ctx - error_data, next_commands = build_error_guidance(click_ctx) - summary, code = classify_click_error(exc) - return emit_failure( - summary=summary, - code=code, - message=exc.format_message(), - exit_code=2, - retryable=False, - server=None, - request_id=None, - next_commands=next_commands, - data=error_data, - ) - - -def console_entry() -> None: - raise SystemExit(main()) diff --git a/cli/tjwater_cli/option_types.py b/cli/tjwater_cli/option_types.py deleted file mode 100644 index 8f83f67..0000000 --- a/cli/tjwater_cli/option_types.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from enum import Enum - - -class ElementType(str, Enum): - PIPE = "pipe" - JUNCTION = "junction" - - -class SimulationQuery(str, Enum): - BY_ID_TIME = "by-id-time" - BY_SCHEME_TIME_PROPERTY = "by-scheme-time-property" - - -class CompositeKind(str, Enum): - SCADA_SIMULATION = "scada-simulation" - ELEMENT_SIMULATION = "element-simulation" - ELEMENT_SCADA = "element-scada" - - -class ComponentOptionKind(str, Enum): - TIME = "time" - ENERGY = "energy" - PUMP_ENERGY = "pump-energy" - NETWORK = "network" - - -class ValveMode(str, Enum): - CLOSE = "close" - ISOLATION = "isolation" - - -class DataSource(str, Enum): - MONITORING = "monitoring" - SIMULATION = "simulation" - - -class ScadaListKind(str, Enum): - INFO = "info" - - -PIPE_TIMESERIES_FIELDS: tuple[str, ...] = ( - "flow", - "friction", - "headloss", - "quality", - "reaction", - "setting", - "status", - "velocity", -) - -JUNCTION_TIMESERIES_FIELDS: tuple[str, ...] = ( - "actual_demand", - "total_head", - "pressure", - "quality", -) - -SCADA_TIMESERIES_FIELDS: tuple[str, ...] = ( - "monitored_value", - "cleaned_value", -) - - -def timeseries_fields_for_element_type(element_type: ElementType) -> tuple[str, ...]: - if element_type == ElementType.PIPE: - return PIPE_TIMESERIES_FIELDS - if element_type == ElementType.JUNCTION: - return JUNCTION_TIMESERIES_FIELDS - raise AssertionError(f"unreachable element type: {element_type}") diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py deleted file mode 100644 index 288344f..0000000 --- a/cli/tjwater_cli/registry.py +++ /dev/null @@ -1,626 +0,0 @@ -from __future__ import annotations - -from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION - -GROUP_SUMMARIES: dict[tuple[str, ...], str] = { - ("network",): "管网节点、管线等基础属性查询命令。", - ("component",): "组件选项与配置读取命令。", - ("component", "option"): "组件选项查询命令。", - ("simulation",): "模拟运行与调度相关命令。", - ("analysis",): "分析计算与诊断相关命令。", - ("analysis", "leakage"): "漏损分析相关命令。", - ("analysis", "leakage", "schemes"): "漏损方案查询命令。", - ("analysis", "burst-detection"): "爆管检测相关命令。", - ("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。", - ("analysis", "burst-location"): "爆管定位相关命令。", - ("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。", - ("analysis", "risk"): "风险分析相关命令。", - ("analysis", "sensor-placement"): "传感器选址相关命令。", - ("data",): "时序、SCADA 和方案数据查询命令。", - ("data", "timeseries"): "时序数据查询命令。", - ("data", "timeseries", "realtime"): "实时模拟时序查询命令。", - ("data", "timeseries", "scheme"): "方案时序查询命令。", - ("data", "timeseries", "scada"): "SCADA 时序查询命令。", - ("data", "timeseries", "composite"): "复合时序查询命令。", - ("data", "scada"): "SCADA 元数据查询命令。", - ("data", "scheme"): "方案数据查询命令。", -} - -HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( - ("analysis", "burst-location"), - ("analysis", "risk"), -) - -COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { - ("network", "get-junction-properties"): CommandDoc( - path=("network", "get-junction-properties"), - summary="读取节点属性", - description="调用 GET /api/v1/junctions/{junction_id}/properties。", - options=(CommandOptionDoc("junction", "节点 ID", required=True),), - examples=("tjwater-cli network get-junction-properties --junction J1",), - ), - ("network", "get-pipe-properties"): CommandDoc( - path=("network", "get-pipe-properties"), - summary="读取管道属性", - description="调用 GET /api/v1/pipes/{pipe_id}/properties。", - options=(CommandOptionDoc("pipe", "管道 ID", required=True),), - examples=("tjwater-cli network get-pipe-properties --pipe P1",), - ), - ("network", "get-all-pipes-properties"): CommandDoc( - path=("network", "get-all-pipes-properties"), - summary="读取全部管道属性", - description="调用 GET /api/v1/pipes/properties。", - examples=("tjwater-cli network get-all-pipes-properties",), - ), - ("network", "get-reservoir-properties"): CommandDoc( - path=("network", "get-reservoir-properties"), - summary="读取水库属性", - description="调用 GET /api/v1/reservoirs/{reservoir_id}/properties。", - options=(CommandOptionDoc("reservoir", "水库 ID", required=True),), - examples=("tjwater-cli network get-reservoir-properties --reservoir R1",), - ), - ("network", "get-all-reservoirs-properties"): CommandDoc( - path=("network", "get-all-reservoirs-properties"), - summary="读取全部水库属性", - description="调用 GET /api/v1/reservoirs/properties。", - examples=("tjwater-cli network get-all-reservoirs-properties",), - ), - ("network", "get-tank-properties"): CommandDoc( - path=("network", "get-tank-properties"), - summary="读取水箱属性", - description="调用 GET /api/v1/tanks/{tank_id}/properties。", - options=(CommandOptionDoc("tank", "水箱 ID", required=True),), - examples=("tjwater-cli network get-tank-properties --tank T1",), - ), - ("network", "get-all-tanks-properties"): CommandDoc( - path=("network", "get-all-tanks-properties"), - summary="读取全部水箱属性", - description="调用 GET /api/v1/tanks/properties。", - examples=("tjwater-cli network get-all-tanks-properties",), - ), - ("network", "get-pump-properties"): CommandDoc( - path=("network", "get-pump-properties"), - summary="读取水泵属性", - description="调用 GET /api/v1/pumps/{pump_id}/properties。", - options=(CommandOptionDoc("pump", "水泵 ID", required=True),), - examples=("tjwater-cli network get-pump-properties --pump PU1",), - ), - ("network", "get-all-pumps-properties"): CommandDoc( - path=("network", "get-all-pumps-properties"), - summary="读取全部水泵属性", - description="调用 GET /api/v1/pumps/properties。", - examples=("tjwater-cli network get-all-pumps-properties",), - ), - ("network", "get-valve-properties"): CommandDoc( - path=("network", "get-valve-properties"), - summary="读取阀门属性", - description="调用 GET /api/v1/valves/{valve_id}/properties。", - options=(CommandOptionDoc("valve", "阀门 ID", required=True),), - examples=("tjwater-cli network get-valve-properties --valve V1",), - ), - ("network", "get-all-valves-properties"): CommandDoc( - path=("network", "get-all-valves-properties"), - summary="读取全部阀门属性", - description="调用 GET /api/v1/valves/properties。", - examples=("tjwater-cli network get-all-valves-properties",), - ), - ("component", "option", "schema"): CommandDoc( - path=("component", "option", "schema"), - summary="读取选项 schema", - description="kind 支持 time、energy、pump-energy、network。", - options=( - CommandOptionDoc("kind", "选项类型", required=True), - CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), - ), - examples=( - "tjwater-cli component option schema --kind time", - "tjwater-cli component option schema --kind energy", - "tjwater-cli component option schema --kind pump-energy --pump PUMP1", - "tjwater-cli component option schema --kind network", - ), - ), - ("component", "option", "get"): CommandDoc( - path=("component", "option", "get"), - summary="读取选项属性", - description="kind 支持 time、energy、pump-energy、network。", - options=( - CommandOptionDoc("kind", "选项类型", required=True), - CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), - ), - examples=( - "tjwater-cli component option get --kind time", - "tjwater-cli component option get --kind energy", - "tjwater-cli component option get --kind pump-energy --pump PUMP1", - "tjwater-cli component option get --kind network", - ), - ), - ("simulation", "run"): CommandDoc( - path=("simulation", "run"), - summary="触发指定绝对时间的模拟运行", - description="把显式带时区的 RFC3339 start-time 直接传给 POST /api/v1/simulation-runs;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("duration", "持续分钟数", required=True), - ), - examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",), - next_commands=( - "tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", - "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", - ), - output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询", - ), - ("analysis", "burst"): CommandDoc( - path=("analysis", "burst"), - summary="执行爆管分析", - description="读取 burst-file 的 burst_id[] / burst_size[] 并调用 POST /api/v1/burst-analyses;接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("duration", "持续秒数", required=True), - CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True), - CommandOptionDoc("scheme", "方案名称"), - ), - examples=( - "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", - "tjwater-cli data scheme get --name burst_case_01", - "tjwater-cli data scheme list", - ), - ), - ("analysis", "valve"): CommandDoc( - path=("analysis", "valve"), - summary="阀门工况分析。", - description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。", - options=( - CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'isolation'", required=True), - CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"), - CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True), - CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True), - CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True), - CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"), - CommandOptionDoc(name="scheme", description="close 模式方案名称"), - ), - examples=( - "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", - "tjwater-cli analysis valve --mode isolation --element E1 --element E2", - "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3", - ), - ), - ("analysis", "flushing"): CommandDoc( - path=("analysis", "flushing"), - summary="执行冲洗分析", - description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True), - CommandOptionDoc("drainage-node", "排污节点 ID", required=True), - CommandOptionDoc("flow", "冲洗流量", required=True), - CommandOptionDoc("duration", "持续秒数,默认 900"), - CommandOptionDoc("scheme", "方案名称", required=True), - ), - examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",), - ), - ("analysis", "age"): CommandDoc( - path=("analysis", "age"), - summary="执行水龄分析", - description="调用 POST /api/v1/water-age-analyses。duration 单位为秒。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("duration", "持续秒数", required=True), - ), - examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",), - ), - ("analysis", "contaminant"): CommandDoc( - path=("analysis", "contaminant"), - summary="执行污染物模拟", - description="调用 POST /api/v1/contaminant-simulations。duration 单位为秒。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("duration", "持续秒数", required=True), - CommandOptionDoc("source-node", "污染源节点 ID", required=True), - CommandOptionDoc("concentration", "浓度值", required=True), - CommandOptionDoc("pattern", "模式 ID"), - CommandOptionDoc("scheme", "方案名称", required=True), - ), - examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",), - ), - ("analysis", "sensor-placement", "kmeans"): CommandDoc( - path=("analysis", "sensor-placement", "kmeans"), - summary="执行 KMeans 传感器选址", - description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。", - options=( - CommandOptionDoc("count", "传感器数量", required=True), - CommandOptionDoc("min-diameter", "最小管径,默认 0"), - CommandOptionDoc("scheme", "方案名称"), - ), - examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",), - ), - ("analysis", "leakage", "identify"): CommandDoc( - path=("analysis", "leakage", "identify"), - summary="执行漏损识别", - description="把 CLI 时间映射到 scada_start / scada_end。", - options=( - CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), - CommandOptionDoc("scheme", "方案名称"), - ), - examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",), - ), - ("analysis", "leakage", "schemes", "list"): CommandDoc( - path=("analysis", "leakage", "schemes", "list"), - summary="列出漏损方案", - description="调用 GET /api/v1/schemes,并传入 scheme_type=dma_leak_identification。", - examples=("tjwater-cli analysis leakage schemes list",), - ), - ("analysis", "leakage", "schemes", "get"): CommandDoc( - path=("analysis", "leakage", "schemes", "get"), - summary="读取漏损方案详情", - description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=dma_leak_identification。", - examples=("tjwater-cli analysis leakage schemes get my_scheme",), - ), - ("analysis", "burst-detection", "detect"): CommandDoc( - path=("analysis", "burst-detection", "detect"), - summary="执行爆管检测", - description="调用 POST /api/v1/burst-detections。", - options=( - CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), - CommandOptionDoc("scheme", "方案名称"), - ), - examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",), - ), - ("analysis", "burst-detection", "schemes", "list"): CommandDoc( - path=("analysis", "burst-detection", "schemes", "list"), - summary="列出爆管检测方案", - description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_detection。", - examples=("tjwater-cli analysis burst-detection schemes list",), - ), - ("analysis", "burst-detection", "schemes", "get"): CommandDoc( - path=("analysis", "burst-detection", "schemes", "get"), - summary="读取爆管检测方案详情", - description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_detection。", - examples=("tjwater-cli analysis burst-detection schemes get my_scheme",), - ), - ("analysis", "burst-location", "locate"): CommandDoc( - path=("analysis", "burst-location", "locate"), - summary="执行爆管定位", - description="调用 POST /api/v1/burst-locations;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。", - options=( - CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True), - CommandOptionDoc("burst-leakage", "爆管漏水量", required=True), - CommandOptionDoc("scheme", "方案名称"), - CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"), - CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True), - CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True), - CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"), - CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"), - CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"), - ), - examples=( - "tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01", - "tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json", - ), - ), - ("analysis", "burst-location", "schemes", "list"): CommandDoc( - path=("analysis", "burst-location", "schemes", "list"), - summary="列出爆管定位方案", - description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_location。", - examples=("tjwater-cli analysis burst-location schemes list",), - ), - ("analysis", "burst-location", "schemes", "get"): CommandDoc( - path=("analysis", "burst-location", "schemes", "get"), - summary="读取爆管定位方案详情", - description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_location。", - examples=("tjwater-cli analysis burst-location schemes get my_scheme",), - ), - ("analysis", "risk", "pipe-now"): CommandDoc( - path=("analysis", "risk", "pipe-now"), - summary="读取单条管道当前风险", - description="调用 GET /api/v1/pipes/risk-probability-now。", - options=(CommandOptionDoc("pipe", "管道 ID", required=True),), - examples=("tjwater-cli analysis risk pipe-now --pipe P1",), - ), - ("analysis", "risk", "pipe-history"): CommandDoc( - path=("analysis", "risk", "pipe-history"), - summary="读取单条管道历史风险", - description="调用 GET /api/v1/pipes/risk-probability。", - options=(CommandOptionDoc("pipe", "管道 ID", required=True),), - examples=("tjwater-cli analysis risk pipe-history --pipe P1",), - ), - ("analysis", "risk", "network"): CommandDoc( - path=("analysis", "risk", "network"), - summary="读取全网风险", - description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。", - examples=("tjwater-cli analysis risk network",), - ), - ("data", "timeseries", "realtime", "links"): CommandDoc( - path=("data", "timeseries", "realtime", "links"), - summary="查询实时管道时序", - description="调用 GET /api/v1/timeseries/realtime/links。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - ), - examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), - ), - ("data", "timeseries", "realtime", "nodes"): CommandDoc( - path=("data", "timeseries", "realtime", "nodes"), - summary="查询实时节点时序", - description="调用 GET /api/v1/timeseries/realtime/nodes。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - ), - examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), - ), - ("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc( - path=("data", "timeseries", "realtime", "simulation-by-id-time"), - summary="按元素和时间查询实时模拟结果", - description="调用 GET /api/v1/timeseries/realtime/by-element。", - options=( - CommandOptionDoc("id", "元素 ID", required=True), - CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), - CommandOptionDoc("time", "显式带时区的查询时间", required=True), - ), - examples=( - "tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", - "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00", - ), - ), - ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( - path=("data", "timeseries", "realtime", "simulation-by-time-property"), - summary="按时间和属性查询实时模拟结果", - description="调用 GET /api/v1/timeseries/realtime/by-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", - options=( - CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True), - CommandOptionDoc("time", "显式带时区的查询时间", required=True), - CommandOptionDoc("property", "属性名;会按 type 校验可选值", required=True), - ), - examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",), - ), - ("data", "timeseries", "scheme", "links"): CommandDoc( - path=("data", "timeseries", "scheme", "links"), - summary="查询方案管道时序", - description="调用 GET /api/v1/timeseries/schemes/links。", - options=( - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - CommandOptionDoc("scheme", "方案名称"), - CommandOptionDoc("scheme-type", "方案类型"), - ), - examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",), - ), - ("data", "timeseries", "scheme", "node-field"): CommandDoc( - path=("data", "timeseries", "scheme", "node-field"), - summary="查询方案节点字段时序", - description="调用 GET /api/v1/timeseries/schemes/nodes/{node_id}/{field}。field 仅支持 actual_demand、total_head、pressure、quality。", - options=( - CommandOptionDoc("node", "节点 ID", required=True), - CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True), - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - CommandOptionDoc("scheme", "方案名称"), - CommandOptionDoc("scheme-type", "方案类型"), - ), - examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",), - ), - ("data", "timeseries", "scheme", "simulation"): CommandDoc( - path=("data", "timeseries", "scheme", "simulation"), - summary="查询方案模拟数据", - description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。", - options=( - CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True), - CommandOptionDoc("scheme", "方案名称"), - CommandOptionDoc("scheme-type", "方案类型"), - CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"), - CommandOptionDoc("time", "显式带时区的查询时间", required=True), - CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"), - CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"), - ), - examples=( - "tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", - "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme", - ), - ), - ("data", "timeseries", "scada", "query"): CommandDoc( - path=("data", "timeseries", "scada", "query"), - summary="查询 SCADA 时序", - description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。", - options=( - CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True), - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"), - ), - examples=( - "tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", - "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value", - ), - ), - ("data", "timeseries", "composite"): CommandDoc( - path=("data", "timeseries", "composite"), - summary="执行复合时序查询", - description="kind 支持 scada-simulation、element-simulation、element-scada。", - options=( - CommandOptionDoc("kind", "复合查询类型", required=True), - CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_id,element-simulation 为 element_id:property,element-scada 为 element_id)", repeated=True), - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - CommandOptionDoc("scheme", "方案名称"), - CommandOptionDoc("scheme-type", "方案类型"), - CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"), - ), - examples=( - "tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", - "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", - "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned", - ), - ), - ("data", "timeseries", "composite", "pipeline-health"): CommandDoc( - path=("data", "timeseries", "composite", "pipeline-health"), - summary="查询管道健康预测", - description="调用 GET /api/v1/pipeline-health-predictions。", - options=( - CommandOptionDoc("pipe", "管道 ID", required=True), - CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), - CommandOptionDoc("end-time", "显式带时区的结束时间", required=True), - ), - examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), - ), - ("data", "scada", "get"): CommandDoc( - path=("data", "scada", "get"), - summary="读取单条 SCADA 元数据", - description="kind 仅支持 info。", - options=( - CommandOptionDoc("kind", "SCADA 数据类型", required=True), - CommandOptionDoc("id", "记录 ID", required=True), - ), - examples=("tjwater-cli data scada get --kind info --id SCADA-001",), - ), - ("data", "scada", "list"): CommandDoc( - path=("data", "scada", "list"), - summary="列出 SCADA 元数据", - description="kind 仅支持 info。", - options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), - examples=("tjwater-cli data scada list --kind info",), - ), - ("data", "scheme", "schema"): CommandDoc( - path=("data", "scheme", "schema"), - summary="读取方案 schema", - description="调用 GET /api/v1/network-schemas/scheme。", - examples=("tjwater-cli data scheme schema",), - ), - ("data", "scheme", "get"): CommandDoc( - path=("data", "scheme", "get"), - summary="读取单条方案", - description="调用 GET /api/v1/schemes/detail。", - options=(CommandOptionDoc("name", "方案名称", required=True),), - examples=("tjwater-cli data scheme get --name my_scheme",), - ), - ("data", "scheme", "list"): CommandDoc( - path=("data", "scheme", "list"), - summary="列出方案", - description="调用 GET /api/v1/schemes。", - examples=("tjwater-cli data scheme list",), - ), -} - - -def _build_examples(doc: CommandDoc) -> list[str]: - return list(doc.examples) if doc.examples else [_build_usage(doc)] - - -def _is_hidden_path(path: tuple[str, ...]) -> bool: - return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES) - - -def is_hidden_path(path: tuple[str, ...]) -> bool: - return _is_hidden_path(path) - - -def has_subcommands(path_prefix: tuple[str, ...]) -> bool: - return any( - not _is_hidden_path(doc.path) - and doc.path[: len(path_prefix)] == path_prefix - and len(doc.path) > len(path_prefix) - for doc in COMMAND_DOCS.values() - ) - - -def get_group_summary(path_prefix: tuple[str, ...]) -> str: - return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令") - - -def list_capabilities() -> dict[str, object]: - seen: set[tuple[str, ...]] = set() - commands: list[dict[str, str]] = [] - for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path): - if _is_hidden_path(doc.path): - continue - prefix = doc.path[:1] - if prefix in seen: - continue - seen.add(prefix) - commands.append( - { - "command": " ".join(prefix), - "summary": get_group_summary(prefix), - } - ) - return { - "ok": True, - "schema_version": SCHEMA_VERSION, - "summary": "可用一级菜单", - "menu_level": 1, - "commands": commands, - } - - -def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None: - if _is_hidden_path(path): - return None - doc = COMMAND_DOCS.get(path) - if doc is None: - return None - return { - "ok": True, - "schema_version": SCHEMA_VERSION, - "summary": doc.summary, - "command": " ".join(doc.path), - "description": doc.description, - "usage": _build_usage(doc), - "options": [ - { - "name": option.name, - "description": option.description, - "required": option.required, - "repeated": option.repeated, - "default": option.default, - } - for option in doc.options - ], - "examples": _build_examples(doc), - "next_commands": list(doc.next_commands), - "output": doc.output, - } - - -def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]: - seen: set[str] = set() - commands: list[dict[str, str]] = [] - for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path): - if _is_hidden_path(doc.path): - continue - if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix): - continue - subcommand = doc.path[len(path_prefix)] - if subcommand in seen: - continue - seen.add(subcommand) - current_path = (*path_prefix, subcommand) - is_group = has_subcommands(current_path) - usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc)) - commands.append( - { - "command": " ".join(current_path), - "summary": get_group_summary(current_path) if is_group else doc.summary, - "usage": usage, - "example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0], - } - ) - return { - "ok": True, - "schema_version": SCHEMA_VERSION, - "summary": summary or get_group_summary(path_prefix), - "commands": commands, - } - - -def _build_usage(doc: CommandDoc) -> str: - parts = ["tjwater-cli", *doc.path] - for option in doc.options: - placeholder = option.name.upper().replace("-", "_") - if option.required: - parts.extend([f"--{option.name}", f"<{placeholder}>"]) - else: - parts.append(f"[--{option.name} <{placeholder}>]") - return " ".join(parts) diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md deleted file mode 100644 index bd6d36a..0000000 --- a/cli/tjwater_cli_endpoint_scope.md +++ /dev/null @@ -1,423 +0,0 @@ -# Agent CLI 接口范围确认 - -本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。 - -## 结论 - -首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 - -```text -tjwater-cli network -tjwater-cli component -tjwater-cli simulation -tjwater-cli analysis -tjwater-cli data -tjwater-cli help -``` - -首批默认不暴露: - -- 会修改 network 的接口:`add*`、`set*`、`delete*`、`generate*` -- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制 -- 数据写入/清理接口:insert、update、delete、clean、clear、batch store -- 用户管理接口:创建、更新、删除、激活、停用 -- 快照回滚和批量命令执行接口:undo、redo、pick、batch - -## 设计原则 - -- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。 -- 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。 -- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。 -- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。 -- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`。 -- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。 -- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。 -- 高频命令可以提供 alias,但文档和 skill 只写规范命令。 - -## 分级约束 - -| 顶层命令 | 二级范围 | 说明 | -|---|---|---| -| `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 | -| `component` | `option` | EPANET 选项设置,只读 | -| `simulation` | `run` | 模拟运行 | -| `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 | -| `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 | -| `help` | `COMMAND` | Agent 能力发现和命令说明 | - -命令深度建议: - -- 常规命令不超过 3 层:`tjwater-cli component option get` -- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links` -- `risk` 归入 `analysis risk` -- `scada`、`scheme`、`extension` 归入 `data` - -## 全局上下文与通用参数 - -首批 CLI 建议统一支持以下全局参数: - -```text ---server URL ---auth-context PATH ---scheme SCHEME ---timeout SEC ---request-id ID -``` - -参数含义: - -| 参数 | 含义 | 作用域 | 说明 | -|---|---|---|---| -| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 | -| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 | -| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 | -| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 | -| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 | - -约束: - -- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。 -- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。 -- `--server`、`--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。 -- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。 - -参数表达建议: - -- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。 -- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。 -- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。 -- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。 - -## 首批 CLI 范围 - -### Network - -来源: - -```text -app/api/v1/endpoints/network/*.py -``` - -| 命令 | 覆盖接口 | 说明 | -|---|---|---| -| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | -| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | -| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 | -| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 | - -暂不暴露: - -```text -add* -set* -delete* -generate* -POST /generatedistrictmeteringarea/ -POST /generatesubdistrictmeteringarea/ -POST /generateservicearea/ -POST /generatevirtualdistrict/ -``` - -备注:`GET /settitle/` 语义是修改标题,首批不暴露。 - -### Component - -来源: - -```text -app/api/v1/endpoints/components/*.py -``` - -| 命令 | 覆盖接口 | 说明 | -|---|---|---| -| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema | -| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 | -| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema | -| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 | -| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema | -| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 | -| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema | -| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 | - -暂不暴露: - -```text -POST /addcurve/ -POST /setcurveproperties/ -POST /deletecurve/ -POST /addpattern/ -POST /setpatternproperties/ -POST /deletepattern/ -POST /settimeproperties/ -POST /setenergyproperties/ -GET /setpumpenergyproperties// -POST /setoptionproperties/ -POST /setcontrolproperties/ -POST /setruleproperties/ -POST /setqualityproperties/ -POST /setemitterproperties/ -POST /setsource/ -POST /addsource/ -POST /deletesource/ -POST /setreaction/ -POST /setpipereaction/ -POST /settankreaction/ -POST /setmixing/ -POST /addmixing/ -POST /deletemixing/ -POST /setvertexproperties/ -POST /addvertex/ -POST /deletevertex/ -POST /setlabelproperties/ -POST /addlabel/ -POST /deletelabel/ -POST /setbackdropproperties/ -``` - -备注: - -- `options` 当前实际只读接口分为 4 组:`time`、`energy`、`pump-energy`、`network`。 -- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`。 -- 后端现有路径 `GET /getpumpenergyproperties//` 和 `GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。 - -### Simulation / Analysis / Risk - -来源: - -```text -app/api/v1/endpoints/simulation.py -app/api/v1/endpoints/leakage.py -app/api/v1/endpoints/burst_detection.py -app/api/v1/endpoints/burst_location.py -app/api/v1/endpoints/risk.py -``` - -| 命令 | 覆盖接口 | 说明 | -|---|---|---| -| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /simulations/run-by-date` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | -| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst-analysis` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | -| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve-isolation-analysis` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 | -| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing-analysis` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | -| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | -| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant-simulation` | 污染物模拟 | -| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | -| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | -| `tjwater-cli analysis leakage schemes list\|get` | `GET /schemes?scheme_type=dma_leak_identification`、`GET /schemes/{scheme_name}?scheme_type=dma_leak_identification` | 漏损方案查询 | -| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | -| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /schemes?scheme_type=burst_detection`、`GET /schemes/{scheme_name}?scheme_type=burst_detection` | 爆管检测方案查询 | -| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | -| `tjwater-cli analysis burst-location schemes list\|get` | `GET /schemes?scheme_type=burst_location`、`GET /schemes/{scheme_name}?scheme_type=burst_location` | 爆管定位方案查询 | -| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | -| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | -| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | - -暂缓或暂不暴露: - -```text -POST /network_project/ -GET /runproject/ -POST /network_update/ -POST /project_management/ -POST /sensorplacementscheme/create -POST /pump_failure/ -POST /pressure_regulation/ -POST /scheduling_analysis/ -POST /daily_scheduling_analysis/ -``` - -执行模型: - -- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。 -- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。 -- 当前 `simulations/run-by-date` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。 -- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。 -- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。 -- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。 - -### Data - -来源: - -```text -app/api/v1/endpoints/timeseries/*.py -app/api/v1/endpoints/scada.py -app/api/v1/endpoints/schemes.py -app/api/v1/endpoints/extension.py -app/api/v1/endpoints/misc.py -app/api/v1/endpoints/project_data.py -``` - -| 命令 | 覆盖接口 | 说明 | -|---|---|---| -| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 | -| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 | -| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 | -| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 | -| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | -| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | -| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 | -| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | -| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | -| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | -| `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/`、`GET /getallscadainfo/` | `SCADA info` 元数据 | -| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | - -- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。 -- `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 - -暂不暴露: - -```text -POST /realtime/*/batch -DELETE /realtime/* -PATCH /realtime/* -POST /realtime/simulation/store -POST /scheme/*/batch -PATCH /scheme/* -DELETE /scheme/* -POST /scheme/simulation/store -POST /scada/batch -PATCH /scada/{device_id}/field -DELETE /scada/by-id-time-range -POST /composite/clean-scada -POST /setscadadevice/ -POST /addscadadevice/ -POST /deletescadadevice/ -POST /cleanscadadevice/ -POST /setscadadevicedata/ -POST /addscadadevicedata/ -POST /deletescadadevicedata/ -POST /cleanscadadevicedata/ -POST /setscadaelement/ -POST /addscadaelement/ -POST /deletescadaelement/ -POST /cleanscadaelement/ -POST /setextensiondata/ -POST /test_dict/ -GET /getjson/ -``` - -### 不纳入首批 CLI 的运维接口 - -来源: - -```text -app/api/v1/endpoints/snapshots.py -app/api/v1/endpoints/cache.py -app/api/v1/endpoints/audit.py -``` - -这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。 - -暂不暴露: - -```text -GET /getcurrentoperationid/ -GET /getsnapshots/ -GET /havesnapshot/ -GET /havesnapshotforoperation/ -GET /havesnapshotforcurrentoperation/ -GET /getrestoreoperation/ -POST /undo/ -POST /redo/ -POST /takesnapshot*/ -POST /picksnapshot/ -POST /pickoperation/ -GET /syncwithserver/ -POST /batch/ -POST /compressedbatch/ -POST /setrestoreoperation/ -GET /queryredis/ -POST /clearrediskey/ -POST /clearrediskeys/ -POST /clearallredis/ -GET /audit/logs -GET /audit/logs/my -GET /audit/logs/count -``` - -## Help - -`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。 - -| 命令 | 说明 | -|---|---| -| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | -| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 | - -输出补充约束: - -- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。 -- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`。 -- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。 - -## 输出规范 - -进程级契约: - -- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。 -- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。 -- 退出码必须稳定,不能简单透传底层 HTTP status。 - -建议退出码: - -| 退出码 | 含义 | -|---|---| -| `0` | 成功 | -| `2` | CLI 参数错误 / 用法错误 | -| `3` | 认证失败 | -| `4` | 权限不足 | -| `5` | 资源不存在 | -| `6` | 冲突、前置条件不满足或非法状态 | -| `7` | 服务端错误 | - -成功: - -```json -{ - "ok": true, - "schema_version": "tjwater-cli/v1", - "summary": "读取成功", - "data": {}, - "metadata": {}, - "next_commands": [] -} -``` - -失败: - -```json -{ - "ok": false, - "schema_version": "tjwater-cli/v1", - "summary": "认证失败", - "error": { - "code": "UNAUTHENTICATED", - "message": "missing access token for agent context", - "retryable": false - }, - "data": null, - "metadata": {}, - "next_commands": [ - "tjwater-cli --auth-context /path/to/auth-context.json" - ] -} -``` - -补充约束: - -- `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。 -- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。 -- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。 - -## 后续开放条件 - -如后续要开放写操作,需要单独设计: - -- 权限校验 -- dry-run / preview -- 显式确认机制 -- 审计日志 -- 变更快照 -- 回滚策略 -- Agent 可读的错误恢复建议 From a7e1ce6ef4c6324c80c12c88a19395497292feed Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 6 Aug 2026 20:32:57 +0800 Subject: [PATCH 88/93] fix(db): enforce metadata membership foreign keys --- .../sql/004_metadata_auth_management.sql | 17 ----------- .../005_metadata_project_configuration.sql | 15 +++++++++- tests/auth/test_rbac_migration.py | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/resources/sql/004_metadata_auth_management.sql b/resources/sql/004_metadata_auth_management.sql index 3e58a88..16fc753 100644 --- a/resources/sql/004_metadata_auth_management.sql +++ b/resources/sql/004_metadata_auth_management.sql @@ -51,20 +51,3 @@ ALTER TABLE users CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id); CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active); - -CREATE TABLE IF NOT EXISTS user_project_membership ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - project_id UUID NOT NULL, - project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL, - CONSTRAINT user_project_membership_role_check - CHECK ( - project_role IN ('member', 'viewer') - ), - CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_project_membership_user_id - ON user_project_membership(user_id); -CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id - ON user_project_membership(project_id); diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql index d3f9e12..bda3627 100644 --- a/resources/sql/005_metadata_project_configuration.sql +++ b/resources/sql/005_metadata_project_configuration.sql @@ -25,7 +25,6 @@ CREATE TABLE IF NOT EXISTS projects ( ); CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); -CREATE INDEX IF NOT EXISTS idx_projects_code ON projects(code); DROP TRIGGER IF EXISTS update_projects_updated_at ON projects; CREATE TRIGGER update_projects_updated_at @@ -33,6 +32,20 @@ CREATE TRIGGER update_projects_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TABLE IF NOT EXISTS user_project_membership ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL, + CONSTRAINT user_project_membership_role_check + CHECK (project_role IN ('member', 'viewer')), + CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id) +); + +-- The unique (user_id, project_id) index already supports user-side lookups. +CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id + ON user_project_membership(project_id); + CREATE TABLE IF NOT EXISTS project_databases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, diff --git a/tests/auth/test_rbac_migration.py b/tests/auth/test_rbac_migration.py index 8111be4..a8cd3ac 100644 --- a/tests/auth/test_rbac_migration.py +++ b/tests/auth/test_rbac_migration.py @@ -1,6 +1,35 @@ from pathlib import Path +def test_metadata_schema_creates_membership_after_referenced_tables(): + auth_sql = Path("resources/sql/004_metadata_auth_management.sql").read_text( + encoding="utf-8" + ) + project_sql = Path( + "resources/sql/005_metadata_project_configuration.sql" + ).read_text(encoding="utf-8") + + assert "CREATE TABLE IF NOT EXISTS user_project_membership" not in auth_sql + assert project_sql.index("CREATE TABLE IF NOT EXISTS projects") < project_sql.index( + "CREATE TABLE IF NOT EXISTS user_project_membership" + ) + assert "user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE" in project_sql + assert ( + "project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE" + in project_sql + ) + + +def test_metadata_schema_avoids_indexes_duplicated_by_unique_constraints(): + project_sql = Path( + "resources/sql/005_metadata_project_configuration.sql" + ).read_text(encoding="utf-8") + + assert "idx_projects_code" not in project_sql + assert "idx_user_project_membership_user_id" not in project_sql + assert "idx_user_project_membership_project_id" in project_sql + + def test_rbac_migration_normalizes_legacy_roles(): sql = Path("resources/sql/006_metadata_rbac_roles.sql").read_text( encoding="utf-8" From e4975b7be3d3dc9eee49b42fd7d9b2618161bb13 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 11 Aug 2026 10:10:21 +0800 Subject: [PATCH 89/93] fix(container): exclude runtime configuration from image --- .dockerignore | 7 +++++-- Dockerfile | 6 ++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.dockerignore b/.dockerignore index 25bdfa1..af63b08 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,9 +11,12 @@ dist/ package/ temp/ data/ -# db_inp/ +db_inp/ inp/ -# .env +.env +.env.* +logs/ +coverage/ *.pyc *.dump app/algorithms/health/model/my_survival_forest_model_quxi.joblib diff --git a/Dockerfile b/Dockerfile index bea8fce..f144e59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,13 +14,11 @@ COPY requirements.txt . RUN pip install --no-cache-dir uv RUN uv pip install --system --no-cache-dir -r requirements.txt -# 将代码放入子目录 'app',将数据放入子目录 'db_inp' -# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 +# 本地数据目录和环境变量在运行时通过 Compose 挂载或注入, +# 不应进入镜像构建上下文。 COPY app ./app RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \ rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip -# COPY db_inp ./db_inp -COPY .env . RUN mkdir -p db_inp temp data inp # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 From 69a7d53aff8c099ac9863798522d93ebfdb31b50 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 11 Aug 2026 10:17:01 +0800 Subject: [PATCH 90/93] ci: replace webhook deployment with v2 workflow --- .gitea/workflows/package.yml | 268 ++--------------------------------- 1 file changed, 13 insertions(+), 255 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index de41372..ade3d04 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -1,263 +1,21 @@ -name: Server CI/CD +name: Server CI/CD v2 on: push: tags: - "v*" - - "latest" workflow_dispatch: {} jobs: - docker-image: - runs-on: ubuntu-22.04 - if: startsWith(github.ref, 'refs/tags/') - permissions: - contents: read - defaults: - run: - shell: bash - - steps: - - name: Checkout repository - uses: https://gitea.waternetwork.cn/actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Normalize image metadata - env: - RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} - RAW_REPOSITORY: ${{ github.repository }} - RAW_REF_NAME: ${{ github.ref_name }} - run: | - RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - - if [ -z "${RAW_REGISTRY_HOST}" ]; then - echo "Missing required repository variable: REGISTRY_HOST" - exit 1 - fi - - REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}" - REGISTRY_HOST="${REGISTRY_HOST#https://}" - REGISTRY_HOST="${REGISTRY_HOST%/}" - - if [ -z "${REGISTRY_HOST}" ]; then - echo "Repository variable REGISTRY_HOST resolves to an empty host" - exit 1 - fi - - REPOSITORY_PATH="${RAW_REPOSITORY#/}" - IMAGE_OWNER="${REPOSITORY_PATH%%/*}" - IMAGE_REPOSITORY_PATH="$(printf '%s' "${IMAGE_OWNER}/tjwater-backend" | tr '[:upper:]' '[:lower:]')" - IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}" - IMAGE_TAG="${RAW_REF_NAME}" - { - echo "REGISTRY_HOST=${REGISTRY_HOST}" - echo "REPOSITORY_PATH=${REPOSITORY_PATH}" - echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}" - echo "IMAGE_NAME=${IMAGE_NAME}" - echo "IMAGE_TAG=${IMAGE_TAG}" - echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" - } >> "$GITHUB_ENV" - - - name: Login to Gitea Container Registry - env: - REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} - GITEA_SERVER_URL: ${{ github.server_url }} - run: | - if [ -z "${REGISTRY_HOST:-}" ]; then - echo "Missing resolved environment value: REGISTRY_HOST" - exit 1 - fi - - if [ -z "${REGISTRY_USERNAME}" ]; then - echo "Missing required repository secret: REGISTRY_USERNAME" - exit 1 - fi - - if [ -z "${REGISTRY_PASSWORD}" ]; then - echo "Missing required repository secret: REGISTRY_PASSWORD" - exit 1 - fi - - echo "Registry username: ${REGISTRY_USERNAME}" - echo "Image target: ${IMAGE_REF}" - - API_SERVER_URL="${GITEA_SERVER_URL%/}" - api_user="$(curl -fsS \ - -H "Authorization: token ${REGISTRY_PASSWORD}" \ - "${API_SERVER_URL}/api/v1/user" \ - | sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - | head -n 1 || true)" - - if [ -n "${api_user}" ]; then - echo "Registry token resolves to Gitea user: ${api_user}" - else - echo "Could not resolve Gitea user from REGISTRY_PASSWORD token; docker login may still use a password or a token without API access." - fi - - echo "Logging into registry host: ${REGISTRY_HOST}" - echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \ - --username "${REGISTRY_USERNAME}" \ - --password-stdin - - - name: Materialize runtime env file - env: - TJWATER_SERVER_ENV: ${{ secrets.TJWATER_SERVER_ENV }} - run: | - if [ -z "${TJWATER_SERVER_ENV}" ]; then - echo "Missing required repository secret: TJWATER_SERVER_ENV" - echo "Store the backend .env file content as a multiline Gitea repository secret named TJWATER_SERVER_ENV." - exit 1 - fi - - printf '%s\n' "${TJWATER_SERVER_ENV}" > .env - chmod 600 .env - - required_env_keys=( - ENVIRONMENT - NETWORK_NAME - DB_NAME - DB_HOST - DB_PORT - DB_USER - DB_PASSWORD - TIMESCALEDB_DB_NAME - TIMESCALEDB_DB_HOST - TIMESCALEDB_DB_PORT - TIMESCALEDB_DB_USER - TIMESCALEDB_DB_PASSWORD - METADATA_DB_NAME - METADATA_DB_HOST - METADATA_DB_PORT - METADATA_DB_USER - METADATA_DB_PASSWORD - DATABASE_ENCRYPTION_KEY - ) - - missing_keys=() - for key in "${required_env_keys[@]}"; do - if ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" .env; then - missing_keys+=("$key") - fi - done - - if [ "${#missing_keys[@]}" -gt 0 ]; then - echo "TJWATER_SERVER_ENV is missing required keys: ${missing_keys[*]}" - exit 1 - fi - - - name: Validate workspace - run: | - if [ ! -f ./Dockerfile ]; then - echo "Dockerfile not found in workspace. Repository checkout may have failed or produced an unexpected workspace." - exit 1 - fi - - - name: Build and Push Image - run: | - if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then - echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG" - exit 1 - fi - - push_with_retry() { - image_ref="$1" - attempt=1 - max_attempts=3 - - while [ "$attempt" -le "$max_attempts" ]; do - if docker push "$image_ref"; then - return 0 - fi - - if [ "$attempt" -eq "$max_attempts" ]; then - return 1 - fi - - echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..." - attempt=$((attempt + 1)) - sleep 10 - done - } - - if [ "${IMAGE_TAG}" = "latest" ]; then - docker build \ - -f ./Dockerfile \ - -t "${IMAGE_NAME}:latest" \ - . - push_with_retry "${IMAGE_NAME}:latest" - else - docker build \ - -f ./Dockerfile \ - -t "${IMAGE_NAME}:${IMAGE_TAG}" \ - -t "${IMAGE_NAME}:latest" \ - . - push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" - push_with_retry "${IMAGE_NAME}:latest" - fi - - - name: Notify Deploy Server - run: | - post_deploy_webhook() { - label="$1" - payload="$2" - webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}" - token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" - - webhook_url=$(echo "$webhook_url" | xargs) - - if [ -z "$webhook_url" ]; then - echo "Missing required repository variable: DEPLOY_WEBHOOK_URL" - return 1 - fi - - if [ -z "$token" ]; then - echo "Missing required repository secret: DEPLOY_WEBHOOK_TOKEN" - return 1 - fi - - echo "[$label] Calling webhook: $webhook_url" - - http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" \ - -d "$payload") - - echo "[$label] webhook HTTP status: ${http_code}" - if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then - return 0 - fi - - echo "[$label] response headers:" - cat /tmp/deploy_headers.txt - echo "[$label] response body:" - cat /tmp/deploy_response.txt - return 1 - } - - PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" - FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}" - - echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}" - echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}" - if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then - exit 0 - fi - - echo "Primary webhook request failed, retrying with lowercase repo path..." - echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}" - if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then - exit 0 - fi - - echo "Deploy webhook failed after primary and fallback attempts." - exit 1 - - deploy-fallback-log: - runs-on: ubuntu-22.04 - needs: docker-image - if: failure() - steps: - - name: Deployment not triggered - run: echo "Image build/push failed, deployment webhook was not called." + build-test-publish-and-deploy: + uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main + with: + image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend + dockerfile: Dockerfile + build_context: . + deploy_service: backend + deploy_host: 192.168.1.114 + secrets: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }} From c250e97b87cc87b4e06282b460e1112f0710fe6f Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 11 Aug 2026 11:11:39 +0800 Subject: [PATCH 91/93] ci(backend): block releases missing frontend API contract --- .gitea/workflows/package.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index ade3d04..8a2ce3a 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -13,6 +13,12 @@ jobs: image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend dockerfile: Dockerfile build_context: . + test_command: | + test -f app/api/v1/endpoints/access.py + grep -Fq 'api_router.include_router(access.router' app/api/v1/router.py + grep -Fq '@router.get("/projects"' app/api/v1/endpoints/meta.py + grep -Fq '@router.get("/projects/current"' app/api/v1/endpoints/project.py + grep -Fq '@router.post("/audit-events"' app/api/v1/endpoints/audit.py deploy_service: backend deploy_host: 192.168.1.114 secrets: From 2581631b5140623fb971cf6aa088e026352b9197 Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 17 Aug 2026 18:28:57 +0800 Subject: [PATCH 92/93] =?UTF-8?q?feat(simulation):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=86=B2=E6=B4=97=E9=98=80=E9=97=A8=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/algorithms/simulation/scenarios.py | 4 + app/api/v1/endpoints/simulation.py | 89 +++++++++-- app/services/simulation.py | 31 +++- contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 95 +++++++++--- tests/api/test_simulation_endpoints.py | 140 ++++++++++++++++++ tests/unit/test_scheme_simulation_timestep.py | 65 ++++++++ 7 files changed, 397 insertions(+), 29 deletions(-) diff --git a/app/algorithms/simulation/scenarios.py b/app/algorithms/simulation/scenarios.py index 28f9954..2a36544 100644 --- a/app/algorithms/simulation/scenarios.py +++ b/app/algorithms/simulation/scenarios.py @@ -316,6 +316,7 @@ def flushing_analysis( flushing_flow: float = 0, scheme_name: str = None, username: str | None = None, + valve_control: dict[str, dict] = None, ) -> None: """ 管道冲洗模拟 @@ -323,6 +324,7 @@ def flushing_analysis( :param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00' :param modify_total_duration: 模拟总历时,秒 :param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度 + :param valve_control: dict中可分别指定阀门的status、setting和k :param drainage_node_ID: 冲洗排放口所在节点ID :param flushing_flow: 冲洗水量,传入参数单位为m3/h :param scheme_name: 方案名称 @@ -334,6 +336,7 @@ def flushing_analysis( scheme_detail: dict = { "duration": modify_total_duration, "valve_opening": modify_valve_opening, + "valve_control": valve_control, "drainage_node_ID": drainage_node_ID, "flushing_flow": flushing_flow, } @@ -450,6 +453,7 @@ def flushing_analysis( modify_pattern_start_time=modify_pattern_start_time, modify_total_duration=modify_total_duration, modify_valve_opening=modify_valve_opening, + valve_control=valve_control, scheme_type="flushing_analysis", scheme_name=scheme_name, ) diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 3720639..b561359 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, List, Literal, Optional from datetime import datetime, timedelta import json import threading @@ -302,12 +302,20 @@ async def valve_isolation_endpoint( return result -@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") +@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") async def fastapi_flushing_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"), - valves: List[str] = Query(..., description="要开启的阀门ID列表"), - valves_k: List[float] = Query(..., description="对应各阀门的开度列表(0-1)"), + valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"), + valves_k: List[float] | None = Query( + None, description="对应各阀门的开度列表(0-1,可选,与valves同时提供)" + ), + valve_statuses: List[Literal["OPEN", "CLOSED", "ACTIVE"]] | None = Query( + None, description="对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)" + ), + valve_settings: List[str] | None = Query( + None, description="对应各阀门的设置值列表(ACTIVE状态下必填)" + ), drainage_node_ID: str = Query(..., description="排污节点ID"), flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"), duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"), @@ -319,8 +327,10 @@ async def fastapi_flushing_analysis( - **network**: 管网名称(或数据库名称) - **start_time**: 冲洗开始时间 - - **valves**: 要开启的阀门ID列表 - - **valves_k**: 各阀门的开度列表(0-1,与valves对应) + - **valves**: 参与控制的阀门ID列表(可选) + - **valves_k**: 各阀门的开度列表(0-1,可选,与valves同时提供) + - **valve_statuses**: 各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选) + - **valve_settings**: 各阀门的设置值列表(ACTIVE状态下必填) - **drainage_node_ID**: 排污节点ID - **flush_flow**: 冲洗流量(L/s) - **duration**: 模拟持续时间(秒,可选,默认900) @@ -328,14 +338,75 @@ async def fastapi_flushing_analysis( 支持多阀联合冲洗操作。 """ - valve_opening = { - valve_id: float(valves_k[idx]) for idx, valve_id in enumerate(valves) - } + valve_opening = None + valve_control = None + if valve_statuses is not None and valves_k is not None: + raise HTTPException( + status_code=422, + detail="valve_statuses 和 valves_k 不能同时提供", + ) + if valve_settings is not None and valve_statuses is None: + raise HTTPException( + status_code=422, + detail="valve_settings 必须与 valve_statuses 同时提供", + ) + if valves is None: + if ( + valves_k is not None + or valve_statuses is not None + or valve_settings is not None + ): + raise HTTPException( + status_code=422, + detail="阀门控制参数必须与 valves 同时提供", + ) + elif valve_statuses is not None: + if len(valves) != len(valve_statuses): + raise HTTPException( + status_code=422, detail="valves 和 valve_statuses 的数量必须一致" + ) + if valve_settings is not None and len(valves) != len(valve_settings): + raise HTTPException( + status_code=422, detail="valves 和 valve_settings 的数量必须一致" + ) + + settings = valve_settings or [""] * len(valves) + valve_control = {} + for valve_id, raw_status, raw_setting in zip( + valves, valve_statuses, settings + ): + status = raw_status + setting = raw_setting.strip() + if status == "ACTIVE" and not setting: + raise HTTPException( + status_code=422, + detail=f"ACTIVE 状态的阀门 {valve_id} 必须提供设置值", + ) + + control: dict[str, str] = {"status": status} + if status == "ACTIVE": + control["setting"] = setting + valve_control[valve_id] = control + elif valves_k is not None: + if len(valves) != len(valves_k): + raise HTTPException( + status_code=422, detail="valves 和 valves_k 的数量必须一致" + ) + valve_opening = { + valve_id: float(valve_k) + for valve_id, valve_k in zip(valves, valves_k) + } + else: + raise HTTPException( + status_code=422, + detail="提供 valves 时必须同时提供 valve_statuses 或 valves_k", + ) result = flushing_analysis( name=network, modify_pattern_start_time=start_time, modify_total_duration=duration or 900, modify_valve_opening=valve_opening, + valve_control=valve_control, drainage_node_ID=drainage_node_ID, flushing_flow=flush_flow, scheme_name=scheme_name, diff --git a/app/services/simulation.py b/app/services/simulation.py index f567930..d56776c 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -686,6 +686,28 @@ def get_history_pattern_info(project_name, pattern_name): return flow_list, factor_list +def _apply_valve_control( + project_name: str, valve_control: dict[str, dict] +) -> None: + """Apply explicit valve status, setting, and opening controls.""" + for valve_name, control in valve_control.items(): + valve_status = get_status(project_name, valve_name) + if "status" in control: + valve_status["status"] = control["status"] + if "setting" in control: + valve_status["setting"] = control["setting"] + if "k" in control: + valve_k = control["k"] + if valve_k == 0: + valve_status["status"] = "CLOSED" + else: + valve_status["setting"] = 0.1036 * pow(valve_k, -3.105) + + cs = ChangeSet() + cs.append(valve_status) + set_status(project_name, cs) + + # 2025/01/11 def run_simulation( name: str, @@ -701,6 +723,7 @@ def run_simulation( modify_valve_opening: dict[str, float] = None, scheme_type: str = None, scheme_name: str = None, + valve_control: dict[str, dict] = None, ) -> None: """ 传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果 @@ -715,6 +738,7 @@ def run_simulation( :param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern :param modify_variable_pump_pattern: dict中包含多个水泵模式,str为变频水泵的id,list为修改后的pattern :param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度 + :param valve_control: dict中可分别指定阀门的status、setting和k;存在时优先于modify_valve_opening :param scheme_type: 模拟方案类型 :param scheme_name:模拟方案名称 :return: @@ -1200,8 +1224,11 @@ def run_simulation( cs = ChangeSet() cs.append(pump_pattern) set_pattern(name_c, cs) - # 修改阀门(valve)的状态setting和status - if modify_valve_opening: + # 显式阀门控制沿用 run_simulation_ex 的处理顺序和覆盖规则。 + if valve_control is not None: + _apply_valve_control(name_c, valve_control) + # 保留原开度参数逻辑,兼容现有方案调用。 + elif modify_valve_opening: for valve_name in modify_valve_opening.keys(): if not np.isnan(modify_valve_opening[valve_name]): valve_status = get_status(name_c, valve_name) diff --git a/contracts/manifest.json b/contracts/manifest.json index cd3362f..4f0aab1 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f" + "sha256": "9ad12d3cd789fd42c341faec5b859d76bceeabc74399e3991e62ace1129e69a2" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index cd08f0e..81b3eb5 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -11038,7 +11038,7 @@ }, "/api/v1/flushing-analyses": { "post": { - "description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", + "description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", "operationId": "post_flushing_analyses", "parameters": [ { @@ -11053,31 +11053,92 @@ } }, { - "description": "要开启的阀门ID列表", + "description": "参与控制的阀门ID列表(可选)", "in": "query", "name": "valves", - "required": true, + "required": false, "schema": { - "description": "要开启的阀门ID列表", - "items": { - "type": "string" - }, - "title": "Valves", - "type": "array" + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "参与控制的阀门ID列表(可选)", + "title": "Valves" } }, { - "description": "对应各阀门的开度列表(0-1)", + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", "in": "query", "name": "valves_k", - "required": true, + "required": false, "schema": { - "description": "对应各阀门的开度列表(0-1)", - "items": { - "type": "number" - }, - "title": "Valves K", - "type": "array" + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", + "title": "Valves K" + } + }, + { + "description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)", + "in": "query", + "name": "valve_statuses", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "enum": [ + "OPEN", + "CLOSED", + "ACTIVE" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)", + "title": "Valve Statuses" + } + }, + { + "description": "对应各阀门的设置值列表(ACTIVE状态下必填)", + "in": "query", + "name": "valve_settings", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的设置值列表(ACTIVE状态下必填)", + "title": "Valve Settings" } }, { diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index b9e563f..a4dab84 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -383,6 +383,7 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): "modify_pattern_start_time": "2025-01-02T03:04:05+08:00", "modify_total_duration": 900, "modify_valve_opening": {"V1": 0.5}, + "valve_control": None, "drainage_node_ID": "N1", "flushing_flow": 100.0, "scheme_name": "flush_case_01", @@ -390,6 +391,145 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch): } +def test_flushing_endpoint_allows_omitting_valves(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_flushing_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "drainage_node_ID": "N1", + "scheme_name": "flush_without_valves", + }, + ) + + assert response.status_code == 200 + assert response.text == "ok" + assert captured["modify_valve_opening"] is None + assert captured["valve_control"] is None + assert captured["drainage_node_ID"] == "N1" + + +def test_flushing_endpoint_passes_explicit_valve_control(monkeypatch): + module = _load_simulation_module(monkeypatch) + captured = {} + + def fake_flushing_analysis(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params=[ + ("network", "demo"), + ("start_time", "2025-01-02T03:04:05+08:00"), + ("valves", "V1"), + ("valves", "V2"), + ("valve_statuses", "ACTIVE"), + ("valve_statuses", "CLOSED"), + ("valve_settings", "2.5"), + ("valve_settings", ""), + ("drainage_node_ID", "N1"), + ("scheme_name", "flush_with_valve_control"), + ], + ) + + assert response.status_code == 200 + assert captured["modify_valve_opening"] is None + assert captured["valve_control"] == { + "V1": {"status": "ACTIVE", "setting": "2.5"}, + "V2": {"status": "CLOSED"}, + } + + +def test_flushing_endpoint_requires_setting_for_active_valve(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": "V1", + "valve_statuses": "ACTIVE", + "drainage_node_ID": "N1", + "scheme_name": "flush_without_active_setting", + }, + ) + + assert response.status_code == 422 + + +def test_flushing_endpoint_rejects_mixed_valve_control_modes(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": "V1", + "valves_k": 0.5, + "valve_statuses": "ACTIVE", + "valve_settings": "2.5", + "drainage_node_ID": "N1", + "scheme_name": "flush_with_mixed_controls", + }, + ) + + assert response.status_code == 422 + + +def test_flushing_endpoint_rejects_settings_without_statuses(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "valves": "V1", + "valves_k": 0.5, + "valve_settings": "2.5", + "drainage_node_ID": "N1", + "scheme_name": "flush_with_orphan_settings", + }, + ) + + assert response.status_code == 422 + + +def test_flushing_endpoint_requires_drainage_node(monkeypatch): + module = _load_simulation_module(monkeypatch) + client = _build_authenticated_client(module) + + response = client.post( + "/api/v1/flushing-analyses", + params={ + "network": "demo", + "start_time": "2025-01-02T03:04:05+08:00", + "scheme_name": "flush_without_drainage_node", + }, + ) + + assert response.status_code == 422 + + def test_contaminant_endpoint_passes_current_username(monkeypatch): module = _load_simulation_module(monkeypatch) captured = {} diff --git a/tests/unit/test_scheme_simulation_timestep.py b/tests/unit/test_scheme_simulation_timestep.py index 72c37c6..8057b0a 100644 --- a/tests/unit/test_scheme_simulation_timestep.py +++ b/tests/unit/test_scheme_simulation_timestep.py @@ -1,3 +1,4 @@ +import inspect import json from datetime import timedelta @@ -7,6 +8,70 @@ from app.infra.db.timescaledb.repositories.scheme import SchemeRepository from app.services.time_api import parse_utc_time +def test_run_simulation_exposes_explicit_valve_control(): + from app.services import simulation + + parameters = inspect.signature(simulation.run_simulation).parameters + + assert "valve_control" in parameters + + +def test_apply_valve_control_matches_run_simulation_ex_semantics(monkeypatch): + from app.services import simulation + + updates: dict[str, dict] = {} + + monkeypatch.setattr( + simulation, + "get_status", + lambda project_name, valve_name: { + "link": valve_name, + "status": "OPEN", + "setting": 1.0, + }, + ) + monkeypatch.setattr( + simulation, + "set_status", + lambda project_name, changeset: updates.update( + { + changeset.operations[0]["link"]: changeset.operations[0].copy() + } + ), + ) + + simulation._apply_valve_control( + "demo", + { + "V-status": {"status": "ACTIVE"}, + "V-setting": {"setting": 2.5}, + "V-closed": {"status": "ACTIVE", "setting": 9.0, "k": 0}, + "V-k": {"status": "ACTIVE", "setting": 9.0, "k": 0.5}, + }, + ) + + assert updates["V-status"] == { + "link": "V-status", + "status": "ACTIVE", + "setting": 1.0, + } + assert updates["V-setting"] == { + "link": "V-setting", + "status": "OPEN", + "setting": 2.5, + } + assert updates["V-closed"] == { + "link": "V-closed", + "status": "CLOSED", + "setting": 9.0, + } + assert updates["V-k"] == { + "link": "V-k", + "status": "ACTIVE", + "setting": 0.1036 * pow(0.5, -3.105), + } + + def _node_result(periods: int) -> list[dict]: return [ { From 8853877fcd14d817b3be7611f4ab9c407f28c8c8 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 18 Aug 2026 17:51:29 +0800 Subject: [PATCH 93/93] fix(security): close backend merge blockers --- app/api/pagination.py | 14 +++++ app/api/v1/endpoints/audit.py | 21 +++++++- app/api/v1/rest_router.py | 12 +++-- app/api/v1/router.py | 4 +- app/core/audit.py | 1 + app/infra/db/influxdb/info.py | 11 ++-- app/native/wndb/database.py | 24 ++++++--- app/native/wndb/s0_base.py | 29 +++++++---- app/native/wndb/s24_coordinates.py | 10 +++- app/native/wndb/s2_junctions.py | 2 +- contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 22 ++------ tests/api/test_audit_middleware.py | 15 ++++++ tests/api/test_openapi_contract.py | 78 +++++++++++++++++++++++++++- tests/unit/test_wndb_query_safety.py | 21 ++++++++ 15 files changed, 216 insertions(+), 50 deletions(-) create mode 100644 app/api/pagination.py create mode 100644 tests/unit/test_wndb_query_safety.py diff --git a/app/api/pagination.py b/app/api/pagination.py new file mode 100644 index 0000000..0db2e47 --- /dev/null +++ b/app/api/pagination.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class PaginatedList(list[T], Generic[T]): + """A page of items carrying the total count from its data source.""" + + def __init__(self, items: Iterable[T], *, total: int) -> None: + super().__init__(items) + self.total = total diff --git a/app/api/v1/endpoints/audit.py b/app/api/v1/endpoints/audit.py index fb47d3c..8b1aa51 100644 --- a/app/api/v1/endpoints/audit.py +++ b/app/api/v1/endpoints/audit.py @@ -10,6 +10,7 @@ from app.auth.metadata_dependencies import ( get_current_metadata_admin, get_current_metadata_user, ) +from app.api.pagination import PaginatedList from app.core.audit import AuditAction, log_audit_event from app.domain.schemas.audit import AuditLogResponse from app.infra.db.metadb.database import get_metadata_session @@ -46,7 +47,7 @@ async def get_audit_logs( _current_user=Depends(get_current_metadata_admin), audit_repo: AuditRepository = Depends(get_audit_repository), ) -> list[AuditLogResponse]: - return await audit_repo.get_logs( + items = await audit_repo.get_logs( user_id=user_id, project_id=project_id, action=action, @@ -56,6 +57,15 @@ async def get_audit_logs( skip=skip, limit=limit, ) + total = await audit_repo.get_log_count( + user_id=user_id, + project_id=project_id, + action=action, + resource_type=resource_type, + start_time=start_time, + end_time=end_time, + ) + return PaginatedList(items, total=total) @router.get( @@ -119,7 +129,7 @@ async def get_my_audit_logs( current_user=Depends(get_current_metadata_user), audit_repo: AuditRepository = Depends(get_audit_repository), ) -> list[AuditLogResponse]: - return await audit_repo.get_logs( + items = await audit_repo.get_logs( user_id=current_user.id, action=action, start_time=start_time, @@ -127,3 +137,10 @@ async def get_my_audit_logs( skip=skip, limit=limit, ) + total = await audit_repo.get_log_count( + user_id=current_user.id, + action=action, + start_time=start_time, + end_time=end_time, + ) + return PaginatedList(items, total=total) diff --git a/app/api/v1/rest_router.py b/app/api/v1/rest_router.py index 232ad2d..00ea840 100644 --- a/app/api/v1/rest_router.py +++ b/app/api/v1/rest_router.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, JsonValue, create_model from starlette.responses import Response from app.api.problem_details import ProblemDetails +from app.api.pagination import PaginatedList from app.api.v1.router import api_router as handler_api_router from app.auth.metadata_dependencies import get_current_metadata_user from app.auth.project_dependencies import ProjectContext, get_project_context @@ -41,8 +42,8 @@ _PUBLIC_PARAMETER_RENAMES = { "burst_ID": "burst_id", "drainage_node_ID": "drainage_node_id", } -_MODEL_NAME_IS_NETWORK = {"RunSimulationManuallyByDate"} -_MODEL_USERNAME_FROM_AUTH: set[str] = set() +_MODEL_NAME_IS_NETWORK = {"RunSimulationManuallyByDate", "PressureSensorPlacement"} +_MODEL_USERNAME_FROM_AUTH = {"PressureSensorPlacement"} def _clean_name(name: str) -> str: @@ -230,9 +231,14 @@ def _with_pagination(endpoint): if not isinstance(result, list): return result if handler_handles_pagination: + if not isinstance(result, PaginatedList): + raise RuntimeError( + f"Paginated handler {endpoint.__name__!r} must return " + "PaginatedList with the real total" + ) return Page( items=result, - total=offset + len(result), + total=result.total, limit=limit or len(result), offset=offset, ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index b77b425..f2b73ef 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -53,6 +53,7 @@ from app.api.v1.endpoints.timeseries import ( ) from app.auth.permissions import ( BURST_RUN, + ENVIRONMENT_MANAGE, OPTIMIZATION_RUN, RISK_RUN, SCADA_CLEAN, @@ -88,6 +89,7 @@ simulation_access = Depends( webgis_view_access = Depends(require_permission(WEBGIS_VIEW)) simulation_run_access = Depends(require_permission(SIMULATION_RUN)) +environment_manage_access = Depends(require_permission(ENVIRONMENT_MANAGE)) burst_run_access = Depends(require_permission(BURST_RUN)) risk_run_access = Depends(require_permission(RISK_RUN)) optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN)) @@ -169,7 +171,7 @@ api_router.include_router( api_router.include_router( cache.router, tags=["Cache"], - dependencies=[simulation_run_access], + dependencies=[environment_manage_access], ) api_router.include_router( web_search.router, diff --git a/app/core/audit.py b/app/core/audit.py index d3d881a..9c48d9e 100644 --- a/app/core/audit.py +++ b/app/core/audit.py @@ -130,6 +130,7 @@ def sanitize_sensitive_data(data: dict) -> dict: "token", "api_key", "apikey", + "dsn", "credit_card", "ssn", "social_security", diff --git a/app/infra/db/influxdb/info.py b/app/infra/db/influxdb/info.py index 8ea0439..b330bc3 100644 --- a/app/infra/db/influxdb/info.py +++ b/app/infra/db/influxdb/info.py @@ -1,6 +1,5 @@ -# influxdb数据库连接信息 -url = "http://127.0.0.1:8086" # 替换为你的InfluxDB实例地址 -token = "kMPX2V5HsbzPpUT2B9HPBu1sTG1Emf-lPlT2UjxYnGAuocpXq_f_0lK4HHs-TbbKyjsZpICkMsyXG_V2D7P7yQ==" # 替换为你的InfluxDB Token -# _ENCODED_TOKEN = "eEdETTVSWnFSSkF1ekFHUy1vdFhVZEMyTkZkWTc1cUpBalJMcUFCNHA1V2NJSUFsSVVwT3BUOF95QTE2QU9IbUpXZXJ3UV8wOGd3Yjg0c3k0MmpuWlE9PQ==" -# token = base64.b64decode(_ENCODED_TOKEN).decode("utf-8") -org = "TJWATERORG" # 替换为你的Organization名称 +from app.core.config import settings + +url = settings.INFLUXDB_URL +token = settings.INFLUXDB_TOKEN +org = settings.INFLUXDB_ORG diff --git a/app/native/wndb/database.py b/app/native/wndb/database.py index 6d2893b..5009418 100644 --- a/app/native/wndb/database.py +++ b/app/native/wndb/database.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping, Sequence from typing import Any from psycopg.rows import dict_row, Row from .connection import project_connection @@ -82,27 +83,38 @@ class DbChangeSet: return DbChangeSet(redo_sql, undo_sql, redo_cs_s, undo_cs_s) -def read(name: str, sql: str) -> Row: +QueryParams = Sequence[Any] | Mapping[str, Any] + + +def _execute(cur, sql: str, params: QueryParams | None = None): + return cur.execute(sql, params) if params is not None else cur.execute(sql) + + +def read(name: str, sql: str, params: QueryParams | None = None) -> Row: with project_connection(name) as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql) + _execute(cur, sql, params) row = cur.fetchone() if row == None: raise Exception(sql) return row -def read_all(name: str, sql: str) -> list[Row]: +def read_all( + name: str, sql: str, params: QueryParams | None = None +) -> list[Row]: with project_connection(name) as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql) + _execute(cur, sql, params) return cur.fetchall() -def try_read(name: str, sql: str) -> Row | None: +def try_read( + name: str, sql: str, params: QueryParams | None = None +) -> Row | None: with project_connection(name) as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql) + _execute(cur, sql, params) return cur.fetchone() diff --git a/app/native/wndb/s0_base.py b/app/native/wndb/s0_base.py index 2986af2..02ee733 100644 --- a/app/native/wndb/s0_base.py +++ b/app/native/wndb/s0_base.py @@ -1,3 +1,4 @@ +from psycopg import sql from psycopg.rows import dict_row, Row from .connection import project_connection from .database import read @@ -49,7 +50,12 @@ ELEMENT_TYPES : dict[str, int] = { def _get_from(name: str, id: str, base_type: str) -> Row | None: with project_connection(name) as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(f"select * from {base_type} where id = '{id}'") + cur.execute( + sql.SQL("select * from {} where id = %s").format( + sql.Identifier(base_type) + ), + (id,), + ) return cur.fetchone() @@ -243,11 +249,17 @@ def get_node_links(name: str, id: str) -> list[str]: with project_connection(name) as conn: with conn.cursor(row_factory=dict_row) as cur: links: list[str] = [] - for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall(): + for p in cur.execute( + "select id from pipes where node1 = %s or node2 = %s", (id, id) + ).fetchall(): links.append(p['id']) - for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall(): + for p in cur.execute( + "select id from pumps where node1 = %s or node2 = %s", (id, id) + ).fetchall(): links.append(p['id']) - for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall(): + for p in cur.execute( + "select id from valves where node1 = %s or node2 = %s", (id, id) + ).fetchall(): links.append(p['id']) return links @@ -255,16 +267,15 @@ def get_node_links(name: str, id: str) -> list[str]: def get_link_nodes(name: str, id: str) -> list[str]: row = {} if is_pipe(name, id): - row = read(name, f"select node1, node2 from pipes where id = '{id}'") + row = read(name, "select node1, node2 from pipes where id = %s", (id,)) elif is_pump(name, id): - row = read(name, f"select node1, node2 from pumps where id = '{id}'") + row = read(name, "select node1, node2 from pumps where id = %s", (id,)) elif is_valve(name, id): - row = read(name, f"select node1, node2 from valves where id = '{id}'") + row = read(name, "select node1, node2 from valves where id = %s", (id,)) return [str(row['node1']), str(row['node2'])] def get_region_type(name: str, id: str)->str: if(is_region(name,id)): - type = read(name, f"select type from _region where id = '{id}'") + type = read(name, "select type from _region where id = %s", (id,)) return type - diff --git a/app/native/wndb/s24_coordinates.py b/app/native/wndb/s24_coordinates.py index df0fcc6..46f2bf5 100644 --- a/app/native/wndb/s24_coordinates.py +++ b/app/native/wndb/s24_coordinates.py @@ -23,7 +23,11 @@ def from_postgis_point(coord: str) -> dict[str, float]: def get_node_coord(name: str, node: str) -> dict[str, float]: - row = try_read(name, f"select st_astext(coord) as coord_geom from coordinates where node = '{node}'") + row = try_read( + name, + "select st_astext(coord) as coord_geom from coordinates where node = %s", + (node,), + ) if row == None: write(name, sql_insert_coord(node, 0.0, 0.0)) return {'x': 0.0, 'y': 0.0} @@ -66,7 +70,9 @@ def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) - def node_has_coord(name: str, node: str) -> bool: - return try_read(name, f"select node from coordinates where node = '{node}'") != None + return try_read( + name, "select node from coordinates where node = %s", (node,) + ) != None #-------------------------------------------------------------- diff --git a/app/native/wndb/s2_junctions.py b/app/native/wndb/s2_junctions.py index 9007229..ac59a8e 100644 --- a/app/native/wndb/s2_junctions.py +++ b/app/native/wndb/s2_junctions.py @@ -12,7 +12,7 @@ def get_junction_schema(name: str) -> dict[str, dict[str, Any]]: def get_junction(name: str, id: str) -> dict[str, Any]: - j = try_read(name, f"select * from junctions where id = '{id}'") + j = try_read(name, "select * from junctions where id = %s", (id,)) if j == None: return {} xy = get_node_coord(name, id) diff --git a/contracts/manifest.json b/contracts/manifest.json index 4f0aab1..782a78b 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "9ad12d3cd789fd42c341faec5b859d76bceeabc74399e3991e62ace1129e69a2" + "sha256": "df7ae927dcf5ae32c3c1ad9be3245b1b78b984ce1902dd91e6313770860e0d48" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 81b3eb5..1c05cfd 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -1859,7 +1859,7 @@ "title": "PressureRegulationRest", "type": "object" }, - "PressureSensorPlacement": { + "PressureSensorPlacementRest": { "properties": { "min_diameter": { "default": 0, @@ -1867,11 +1867,6 @@ "title": "Min Diameter", "type": "integer" }, - "name": { - "description": "管网名称(或数据库名称)", - "title": "Name", - "type": "string" - }, "scheme_name": { "description": "方案名称", "title": "Scheme Name", @@ -1881,20 +1876,13 @@ "description": "传感器数量", "title": "Sensor Number", "type": "integer" - }, - "username": { - "description": "用户名", - "title": "Username", - "type": "string" } }, "required": [ - "name", "scheme_name", - "sensor_number", - "username" + "sensor_number" ], - "title": "PressureSensorPlacement", + "title": "PressureSensorPlacementRest", "type": "object" }, "ProblemDetails": { @@ -24894,7 +24882,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PressureSensorPlacement", + "$ref": "#/components/schemas/PressureSensorPlacementRest", "description": "传感器放置分析参数" } } @@ -25134,7 +25122,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PressureSensorPlacement", + "$ref": "#/components/schemas/PressureSensorPlacementRest", "description": "传感器放置分析参数" } } diff --git a/tests/api/test_audit_middleware.py b/tests/api/test_audit_middleware.py index 96f53ac..4a143c7 100644 --- a/tests/api/test_audit_middleware.py +++ b/tests/api/test_audit_middleware.py @@ -7,6 +7,21 @@ from fastapi.testclient import TestClient from app.infra.audit import middleware as audit_middleware from app.infra.audit.middleware import AuditMiddleware +from app.core.audit import sanitize_sensitive_data + + +def test_sanitize_sensitive_data_redacts_database_dsn() -> None: + raw_dsn = "postgresql://alice:supersecret@db.internal/project" + + sanitized = sanitize_sensitive_data( + {"dsn": raw_dsn, "database": {"readonly_dsn": raw_dsn}} + ) + + assert sanitized == { + "dsn": "***REDACTED***", + "database": {"readonly_dsn": "***REDACTED***"}, + } + assert raw_dsn not in str(sanitized) def test_post_streaming_response_survives_audit_body_capture(monkeypatch): diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index c7542dd..33aeb40 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timezone from pathlib import Path +from unittest.mock import Mock from uuid import uuid4 import pytest @@ -11,8 +12,11 @@ from fastapi.testclient import TestClient from app.api.v1.endpoints import schemes as schemes_endpoint from app.api.v1.endpoints import simulation as simulation_endpoint +from app.api.v1.endpoints import cache as cache_endpoint +from app.api.pagination import PaginatedList from app.api.v1.rest_router import api_router, build_rest_router from app.api.v1.router import api_router as source_api_router +from app.auth.metadata_dependencies import get_current_metadata_user from app.auth.project_dependencies import ProjectContext, get_project_context from scripts.check_openapi import current_contract_bytes, validate @@ -130,6 +134,12 @@ def test_rest_contract_uses_header_project_context() -> None: assert "network" not in schema.get("properties", {}) assert "network_name" not in schema.get("properties", {}) + placement_schema = document["components"]["schemas"][ + "PressureSensorPlacementRest" + ] + assert "name" not in placement_schema["properties"] + assert "username" not in placement_schema["properties"] + assert "/api/v1/burst-analysis" not in document["paths"] assert "/api/v1/getpipeproperties/" not in document["paths"] @@ -280,7 +290,7 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None: limit: int = Query(2, ge=1, le=10), ) -> list[int]: records = [10, 20, 30, 40] - return records[skip : skip + limit] + return PaginatedList(records[skip : skip + limit], total=len(records)) app = FastAPI(redirect_slashes=False) app.include_router(build_rest_router(source_router.routes), prefix="/api/v1") @@ -293,12 +303,76 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None: assert response.status_code == 200 assert response.json() == { "items": [20, 30], - "total": 3, + "total": 4, "limit": 2, "offset": 1, } +def test_sensor_placement_body_uses_authenticated_project_and_user( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + def fake_pressure_sensor_placement_kmeans(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + simulation_endpoint, + "pressure_sensor_placement_kmeans", + fake_pressure_sensor_placement_kmeans, + ) + app = FastAPI(redirect_slashes=False) + app.include_router(api_router, prefix="/api/v1") + app.dependency_overrides[get_project_context] = lambda: ProjectContext( + project_id=uuid4(), + project_code="project_a", + user_id=uuid4(), + project_role="member", + ) + app.dependency_overrides[get_current_metadata_user] = lambda: type( + "User", (), {"username": "alice"} + )() + + response = TestClient(app, raise_server_exceptions=False).post( + "/api/v1/pressure-sensor-placement-kmeans", + json={ + "scheme_name": "placement_01", + "sensor_number": 5, + "min_diameter": 100, + }, + ) + + assert response.status_code == 200 + assert captured == { + "name": "project_a", + "scheme_name": "placement_01", + "sensor_number": 5, + "min_diameter": 100, + "username": "alice", + } + + +def test_cache_management_requires_environment_permission(monkeypatch) -> None: + flushdb = Mock(return_value=True) + monkeypatch.setattr(cache_endpoint.redis_client, "flushdb", flushdb) + app = FastAPI(redirect_slashes=False) + app.include_router(api_router, prefix="/api/v1") + app.dependency_overrides[get_project_context] = lambda: ProjectContext( + project_id=uuid4(), + project_code="project_a", + user_id=uuid4(), + project_role="member", + ) + + response = TestClient(app, raise_server_exceptions=False).delete( + "/api/v1/all-redis" + ) + + assert response.status_code == 403 + flushdb.assert_not_called() + + def test_rest_runtime_json_encodes_untyped_datetime_response() -> None: source_router = APIRouter() diff --git a/tests/unit/test_wndb_query_safety.py b/tests/unit/test_wndb_query_safety.py new file mode 100644 index 0000000..6162871 --- /dev/null +++ b/tests/unit/test_wndb_query_safety.py @@ -0,0 +1,21 @@ +from app.native.wndb import s2_junctions + + +def test_get_junction_binds_untrusted_identifier(monkeypatch) -> None: + calls: list[tuple[str, str, tuple[str, ...]]] = [] + malicious_id = "J-1'; DELETE FROM junctions; --" + + def fake_try_read(name, statement, params): + calls.append((name, statement, params)) + return None + + monkeypatch.setattr(s2_junctions, "try_read", fake_try_read) + + assert s2_junctions.get_junction("project_a", malicious_id) == {} + assert calls == [ + ( + "project_a", + "select * from junctions where id = %s", + (malicious_id,), + ) + ]