diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af63b08 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.github +.gitea +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +build/ +dist/ +package/ +temp/ +data/ +db_inp/ +inp/ +.env +.env.* +logs/ +coverage/ +*.pyc +*.dump +app/algorithms/health/model/my_survival_forest_model_quxi.joblib diff --git a/.env.example b/.env.example index 9133314..75f7c1b 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,16 @@ # TJWater Server 环境变量配置模板 # 复制此文件为 .env 并填写实际值 -ENVIRONMENT="local" +# CI/CD: 将生产 .env 的完整内容保存为 Gitea 仓库密钥 TJWATER_SERVER_ENV。 +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 格式,生产环境必须替换为独立密钥 # 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -ENCRYPTION_KEY= -DATABASE_ENCRYPTION_KEY="rJC2VqLg4KrlSq+DGJcYm869q4v5KB2dFAeuQTe0I50=" +# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置 +DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key" # ============================================ # 数据库配置 (PostgreSQL) @@ -48,4 +45,19 @@ 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 + +# ============================================ +# 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 + +# ============================================ +# 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/.gitea/workflows/package.yml b/.gitea/workflows/package.yml new file mode 100644 index 0000000..8a2ce3a --- /dev/null +++ b/.gitea/workflows/package.yml @@ -0,0 +1,27 @@ +name: Server CI/CD v2 + +on: + push: + tags: + - "v*" + workflow_dispatch: {} + +jobs: + 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: . + 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: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }} 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. 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/.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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..31f61e7 --- /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`. 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` 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. + +## 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/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md new file mode 100644 index 0000000..8b04db3 --- /dev/null +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -0,0 +1,117 @@ +# 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. + +## 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 +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`. + +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` + +## Secret Handling + +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`. +- Admin responses return only `has_dsn`. +- Audit logs record whether a secret was updated, but never store plaintext DSNs + or other secrets. + +Generate the database encryption key 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` +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. `006` extends existing membership constraints with +the fixed delivery roles. + +## Frontend System Management + +`/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/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/Dockerfile b/Dockerfile index cc8290b..f144e59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,25 @@ -FROM continuumio/miniconda3:latest +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 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 +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 -COPY db_inp ./db_inp -COPY temp ./temp -COPY .env . +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 +RUN mkdir -p db_inp temp data inp # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 ENV PYTHONPATH=/app diff --git a/README.md b/README.md new file mode 100644 index 0000000..1662b04 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# 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/ 本地管网数据读写与转换 +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 配置和变量展开。 + +## 开发规范 + +- 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 和仓库变量中。 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/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/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/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/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/app/algorithms/sensor/__init__.py b/app/algorithms/sensor/__init__.py index d6c1a48..b500fc4 100644 --- a/app/algorithms/sensor/__init__.py +++ b/app/algorithms/sensor/__init__.py @@ -1,14 +1,77 @@ -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 ( + SensorPlacementConflictError, + 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: + 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: + 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 +79,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 +112,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/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..618c08a 100644 --- a/app/algorithms/sensor/sensitivity.py +++ b/app/algorithms/sensor/sensitivity.py @@ -1,653 +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: 节点坐标 + + 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 + + +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) + + 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] + + 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, + ) + + +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 + + +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], + ) + ) + quotas[active_components[order[:sensor_num]]] = 1 + return candidate_components, quotas + + quotas[active_components] = 1 + remaining = sensor_num - len(active_components) + while remaining > 0: + available = active_components[ + quotas[active_components] < capacities[active_components] + ] + 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], + ) + whole_count = int(whole.sum()) + if whole_count: + quotas[available] += whole + remaining -= whole_count + continue + + 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 + + return candidate_components, quotas + + +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. """ - # 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 + + if sensor_num <= 0: + raise ValueError("监测点数量必须大于 0") + if min_diameter < 0: + raise ValueError("最小管径不能小于 0") + + total_started = perf_counter() + simulation_started = total_started + results = _run_hydraulic_simulation(wn) + simulation_seconds = perf_counter() - simulation_started + + preparation_started = perf_counter() + prepared = _prepare_network(wn, results, min_diameter=min_diameter) + preparation_seconds = perf_counter() - preparation_started + + sensitivity_started = perf_counter() + log_sensitivity = _estimate_log_pressure_sensitivity(prepared) + sensitivity_seconds = perf_counter() - sensitivity_started + + distance_started = perf_counter() + hydraulic_distance_sums = _estimate_hydraulic_distance_sums(prepared) + distance_seconds = perf_counter() - distance_started + + selection_started = perf_counter() + selected = _select_sensor_nodes( + prepared, + log_sensitivity, + hydraulic_distance_sums, + sensor_num=sensor_num, + ) + selection_seconds = perf_counter() - selection_started + + 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 -# 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 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, + ) -def skater_partition(G, n_clusters): - """ - 使用 SKATER 算法对输入的无向图 G 进行区域划分, - 保证每个划分区域在图论意义上是连通的, - 同时依据节点坐标的空间信息进行划分。 - - 参数: - 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) - 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): - - # 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)) - # 泵、起终点为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)) - - # === 改动新增部分:筛选管径小于 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()] - 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) - 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 - - def Jaco(self, hL: pandas.DataFrame): - """ - 计算灵敏度矩阵(节点压力对粗糙度变化的响应) - :param hL: 水力距离矩阵 - :return: - """ - # global result - # A:numpy.matrix, 节点-管道关系矩阵 - A = self.A2 - wn = self.wn - - 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 - - -# 2025/03/12 -# Step4: 传感器布置优化 -# Sensorplacement -# weight:分配权重 -# sensor:传感器布置的位置 -class Sensorplacement(wn_func): - """ - Sensorplacement 类继承了 wn_func 类,并且用于计算和优化传感器布置的位置。 - """ - def __init__(self, wn: wntr.network.WaterNetworkModel, sensornum: int, min_diameter: int): - """ - - :param wn: 由wntr生成的模型 - :param sensornum: 传感器的数量 - :param min_diameter: 安装的最小管径 - """ - wn_func.__init__(self, wn, min_diameter=min_diameter) - self.sensornum = sensornum - - # 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') # 存储节点总灵敏度 - - # 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 = {} - - # 改动 - 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 - - # 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]) - - for key, value in group.items(): - if Smaxnode in value: - sumSS = sumSS.drop(index=group[key]) - continue - - sumSS = sumSS.sort_values(by=[0], ascending=[False]) - - return sensorindex, sensorindex_2 - - -# 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/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/algorithms/simulation/scenarios.py b/app/algorithms/simulation/scenarios.py index 239dab6..2a36544 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,8 @@ def flushing_analysis( drainage_node_ID: str = None, flushing_flow: float = 0, scheme_name: str = None, + username: str | None = None, + valve_control: dict[str, dict] = None, ) -> None: """ 管道冲洗模拟 @@ -318,14 +324,19 @@ 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: 方案名称 :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, + "valve_control": valve_control, "drainage_node_ID": drainage_node_ID, "flushing_flow": flushing_flow, } @@ -442,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, ) @@ -455,7 +467,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 +485,7 @@ def contaminant_simulation( concentration: float, # 污染源浓度,单位mg/L scheme_name: str = None, source_pattern: str = None, # 污染源时间变化模式名称 + username: str | None = None, ) -> None: """ 污染模拟 @@ -486,6 +499,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 +624,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, ) @@ -662,7 +678,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/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/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 new file mode 100644 index 0000000..7792856 --- /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 new file mode 100644 index 0000000..b8bc55b --- /dev/null +++ b/app/api/v1/endpoints/admin_metadata.py @@ -0,0 +1,696 @@ +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, + 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 _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 _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 "DATABASE_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("/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("/admin/user-syncs", 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("/admin/user-syncs/batches", 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("/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), + 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("/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), +) -> List[AdminProjectResponse]: + projects = await metadata_repo.list_project_records() + return [_project_response(project) for project in projects] + + +@router.post( + "/admin/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, + creator_user_id=current_user.id, + ) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project code or 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( + "/admin/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 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( + "/admin/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( + "/admin/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( + "/admin/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( + "/admin/projects/{project_id}/databases/{db_role}/health-checks", + 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("/admin/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("/admin/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( + "/admin/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( + "/admin/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( + "/admin/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("/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(...), + 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/endpoints/agent_auth.py b/app/api/v1/endpoints/agent_auth.py new file mode 100644 index 0000000..9e2a970 --- /dev/null +++ b/app/api/v1/endpoints/agent_auth.py @@ -0,0 +1,53 @@ +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, +) +from app.auth.permissions import permissions_for_context + +router = APIRouter() + + +class AgentAuthContextResponse(BaseModel): + user_id: str + keycloak_sub: str + username: str + role: str + is_superuser: bool + project_id: str + network: str + project_role: str + permissions: list[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), + 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..8b1aa51 100644 --- a/app/api/v1/endpoints/audit.py +++ b/app/api/v1/endpoints/audit.py @@ -1,56 +1,53 @@ -""" -审计日志 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.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 -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) @router.get( - "/logs", + "/audit-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]: + items = await audit_repo.get_logs( user_id=user_id, project_id=project_id, action=action, @@ -60,29 +57,32 @@ async def get_audit_logs( skip=skip, limit=limit, ) - return logs + 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( - "/logs/count", + "/audit-logs/count", summary="获取审计日志总数", 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 +94,42 @@ async def get_audit_logs_count( return {"count": count} +@router.post("/audit-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", + "/audit-logs/mine", 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]: + items = await audit_repo.get_logs( user_id=current_user.id, action=action, start_time=start_time, @@ -122,4 +137,10 @@ async def get_my_audit_logs( skip=skip, limit=limit, ) - return logs + 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/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/burst_detection.py b/app/api/v1/endpoints/burst_detection.py index 37d7849..d9a0d17 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, ) @@ -30,6 +28,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="传感器节点列表") @@ -40,7 +48,7 @@ class BurstDetectionRequest(BaseModel): @router.post( - "/detect/", + "/burst-detections", summary="执行爆管检测", description="基于压力观测数据和其他参数执行爆管检测分析" ) @@ -68,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 bc4023c..e6f52d0 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, ) @@ -29,8 +27,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="模拟方案名称") @@ -38,7 +38,7 @@ class BurstLocationRequest(BaseModel): @router.post( - "/locate/", + "/burst-locations", summary="执行爆管定位", description="基于压力和流量数据定位管网中的爆管位置" ) @@ -66,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/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/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 - ) 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 new file mode 100644 index 0000000..c436d9e --- /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( + "/geocoding-requests", + 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/endpoints/leakage.py b/app/api/v1/endpoints/leakage.py index 0261e26..1af055e 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, ) @@ -40,7 +38,7 @@ class LeakageIdentifyRequest(BaseModel): @router.post( - "/identify/", + "/leakage-identifications", summary="执行漏损识别", description="基于压力观测数据和遗传算法识别管网中的漏损位置和大小" ) @@ -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/meta.py b/app/api/v1/endpoints/meta.py index 455189e..a84b197 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 @@ -15,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, ) @@ -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), @@ -33,38 +33,26 @@ 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, 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, ) -@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), @@ -100,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), @@ -110,7 +98,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/api/v1/endpoints/misc.py b/app/api/v1/endpoints/misc.py index 1ebb083..255fe47 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 @@ -12,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 +27,7 @@ async def fastapi_get_json(): ) -@router.get("/getallsensorplacements/", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息") +@router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息") 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]: """ 测试字典处理 @@ -63,24 +60,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/model_import.py b/app/api/v1/endpoints/model_import.py new file mode 100644 index 0000000..ffd84e8 --- /dev/null +++ b/app/api/v1/endpoints/model_import.py @@ -0,0 +1,188 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile +from uuid import UUID, uuid4 + +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Path as ApiPath, + 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 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-imports", + 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.patch( + "/admin/projects/{project_id}/model-imports", + 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} 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 8a99743..3b7a90f 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.dependencies import get_current_user as verify_token -from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime -import msgpack router = APIRouter() @@ -35,7 +31,7 @@ router = APIRouter() # return set_coord(network, ChangeSet(props)) @router.get( - "/getnodecoord/", + "/node-coords", summary="获取节点坐标", description="获取指定节点的地理坐标(X, Y)" ) @@ -48,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="获取指定地理范围内的网络节点和管线" ) @@ -63,34 +59,7 @@ async def fastapi_get_network_in_extent( return get_network_in_extent(network, x1, y1, x2, y2) @router.get( - "/getnetworkgeometries/", - dependencies=[Depends(verify_token)], - 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/", + "/majornode-coords", summary="获取主要节点坐标", description="获取直径大于等于指定值的节点坐标" ) @@ -102,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" ) @@ -114,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 1097ca5..833852b 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, @@ -49,19 +45,7 @@ router = APIRouter() ############################################################ @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/", + "/network-schemas/region", summary="获取区域属性架构", description="获取指定水网的区域属性架构定义" ) @@ -72,7 +56,7 @@ async def fastapi_get_region_schema( return get_region_schema(network) @router.get( - "/getregion/", + "/regions/detail", summary="获取区域信息", description="获取指定ID的区域详细信息" ) @@ -83,8 +67,8 @@ async def fastapi_get_region( """获取区域的详细信息。""" return get_region(network, id) -@router.post( - "/setregion/", +@router.patch( + "/regions", response_model=None, summary="设置区域属性", description="修改指定区域的属性信息" @@ -98,7 +82,7 @@ async def fastapi_set_region( return set_region(network, ChangeSet(props)) @router.post( - "/addregion/", + "/regions", response_model=None, summary="添加新区域", description="向水网添加一个新的区域" @@ -111,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="删除指定的区域" @@ -125,64 +109,13 @@ 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/", +@router.post( + "/district-metering-areas/for-region", summary="计算区域内DMA分区", description="为指定区域计算区域计量(DMA)分区方案" ) @@ -208,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)分区方案" ) @@ -232,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)属性架构定义" ) @@ -243,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)详细信息" ) @@ -254,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的属性信息" @@ -269,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)" @@ -289,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)" @@ -304,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列表" ) @@ -315,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的详细信息" ) @@ -326,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分区方案" @@ -343,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分区" @@ -365,20 +298,19 @@ async def fastapi_generate_sub_district_metering_area( # service_area 34 ############################################################ -@router.get( - "/calculateservicearea/", +@router.post( + "/service-area-calculations", 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/", + "/network-schemas/service-area", summary="获取服务区属性架构", description="获取指定水网的服务区属性架构定义" ) @@ -389,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的服务区详细信息" ) @@ -400,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="修改指定服务区的属性信息" @@ -415,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="向水网添加一个新的服务区" @@ -428,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="删除指定的服务区" @@ -443,7 +375,7 @@ async def fastapi_delete_service_area( return delete_service_area(network, ChangeSet(props)) @router.get( - "/getallserviceareas/", + "/service-areas", summary="获取所有服务区", description="获取指定水网中的所有服务区信息" ) @@ -454,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="根据参数自动生成水网的服务区分区" @@ -471,20 +403,20 @@ async def fastapi_generate_service_area( # virtual_district 35 ############################################################ -@router.get( - "/calculatevirtualdistrict/", +@router.post( + "/virtual-district-calculations", 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) @router.get( - "/getvirtualdistrictschema/", + "/network-schemas/virtual-district", summary="获取虚拟分区属性架构", description="获取指定水网的虚拟分区属性架构定义" ) @@ -495,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的虚拟分区详细信息" ) @@ -506,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="修改指定虚拟分区的属性信息" @@ -521,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="向水网添加一个新的虚拟分区" @@ -534,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="删除指定的虚拟分区" @@ -549,7 +481,7 @@ async def fastapi_delete_virtual_district( return delete_virtual_district(network, ChangeSet(props)) @router.get( - "/getallvirtualdistrict/", + "/virtual-districts", summary="获取所有虚拟分区", description="获取指定水网中的所有虚拟分区信息" ) @@ -560,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="根据参数自动生成虚拟分区方案" @@ -574,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 95584b6..aeeebfb 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -1,10 +1,14 @@ 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.domain.schemas.metadata import ProjectMetaResponse, GeoServerConfigResponse +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 from app.infra.db.timescaledb.database import get_database_instance as get_ts_db @@ -18,7 +22,6 @@ from app.services.tjnetwork import ( open_project, close_project, copy_project, - import_inp, export_inp, read_inp, dump_inp, @@ -42,7 +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("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse) async def get_project_info_endpoint( network: str = Query(..., description="管网名称(或项目代码)"), metadata_repo: MetadataRepository = Depends(get_metadata_repository), @@ -55,17 +58,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,10 +67,9 @@ 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="获取服务器上所有可用的供水管网项目名称列表。") +@router.get("/project-codes", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。") async def list_projects_endpoint() -> list[str]: """ 获取项目列表 @@ -87,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="管网名称(或数据库名称)") ): @@ -98,9 +89,10 @@ 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="管网名称(或数据库名称)") + network: str = Query(..., description="管网名称(或数据库名称)"), + _=Depends(require_permission(ENVIRONMENT_MANAGE)), ): """ 创建新项目 @@ -110,9 +102,10 @@ 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="管网名称(或数据库名称)") + network: str = Query(..., description="管网名称(或数据库名称)"), + _=Depends(require_permission(ENVIRONMENT_MANAGE)), ): """ 删除项目 @@ -122,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="管网名称(或数据库名称)") ): @@ -133,7 +126,7 @@ async def is_project_open_endpoint( """ return is_project_open(network) -@router.post("/openproject/", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。") +@router.post("/projects/current", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。") async def open_project_endpoint( network: str = Query(..., description="管网名称(或数据库名称)") ): @@ -167,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="管网名称(或数据库名称)") ): @@ -179,10 +172,11 @@ 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="管网名称(或数据库名称)") + target: str = Query(..., description="管网名称(或数据库名称)"), + _=Depends(require_permission(ENVIRONMENT_MANAGE)), ): """ 复制项目 @@ -193,25 +187,7 @@ 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 等信息。") +@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="版本号 (通常用于增量更新)") @@ -244,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 文件名 (不包含路径)") @@ -258,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="目标文件名") @@ -272,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 @@ -284,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 @@ -300,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 @@ -323,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 @@ -341,27 +317,7 @@ 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 文件。") +@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 @@ -381,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: @@ -415,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 文件名 (不包含路径)") @@ -429,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="目标文件名") @@ -443,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 @@ -455,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 @@ -471,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 @@ -494,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 @@ -512,27 +462,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="文件名"), response: Response = None @@ -552,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 1d9a928..dcf333a 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() @@ -16,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), ): @@ -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( @@ -36,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), ): @@ -52,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), ): @@ -70,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 3b650e4..ff365cc 100644 --- a/app/api/v1/endpoints/schemes.py +++ b/app/api/v1/endpoints/schemes.py @@ -1,10 +1,13 @@ -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() -@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]]: """ 获取方案模式定义 @@ -13,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]: """ 获取单个方案详情 @@ -22,11 +25,36 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称 """ return get_scheme(network, schema_name) -@router.get("/getallschemes/", summary="获取所有方案", description="获取指定网络的所有方案信息") -async def fastapi_get_all_schemes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]: +@router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息") +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/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py new file mode 100644 index 0000000..5ebdd25 --- /dev/null +++ b/app/api/v1/endpoints/sensor_placement.py @@ -0,0 +1,265 @@ +import logging +from typing import Any +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Path, 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 ( + SensorPointResponse, + SensorPlacementExportRequest, + SensorPlacementOptimizeRequest, + SensorPlacementSchemeResponse, + SensorPlacementUpdateRequest, +) +from app.services.sensor_placement import ( + SensorPlacementConflictError, + SensorPlacementNotFoundError, + SensorPlacementValidationError, + build_sensor_placement_workbook, + can_edit_sensor_placement, + get_sensor_placement_candidate, + 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 _can_modify_project(project_context: ProjectContext) -> bool: + return project_context.project_role == "member" + + +def _require_project_write( + project_context: ProjectContext, +) -> None: + if not _can_modify_project(project_context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="当前项目角色为只读,不能修改监测点方案", + ) + + +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, + project_context: ProjectContext, +) -> dict[str, Any]: + try: + scheme = get_sensor_placement_scheme(network, scheme_id) + return { + **scheme, + "can_edit": ( + _can_modify_project(project_context) + and can_edit_sensor_placement(current_user, scheme) + ), + } + except ( + SensorPlacementNotFoundError, + SensorPlacementValidationError, + ) as exc: + 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, + 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) + _require_project_write(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 ( + SensorPlacementConflictError, + 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, + project_context, + ) + + +@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) + _require_project_write(project_context) + scheme = _get_scheme_response( + network, + scheme_id, + current_user, + project_context, + ) + 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, + project_context, + ) + if ( + payload.sensor_location != scheme["sensor_location"] + and not scheme["can_edit"] + ): + raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿") + + try: + workbook = await run_in_threadpool( + 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/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 34b0e3a..b561359 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -1,12 +1,10 @@ -from typing import Any, List, Optional +from typing import Any, List, Literal, Optional from datetime import datetime, timedelta import json -import os -import shutil import threading -from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Body +from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body from fastapi.responses import PlainTextResponse -import app.infra.db.influxdb.api as influxdb_api +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 ( @@ -28,25 +26,33 @@ 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, scheduling_simulation, 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_clock_duration_seconds, + 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): @@ -111,34 +117,29 @@ 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_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", ) - end_datetime = start_datetime + timedelta(minutes=duration) - current_time = start_datetime + 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: - 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) + current_time += hydraulic_step # 必须用这个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: """ 运行项目模拟 @@ -154,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]: """ 运行项目模拟(返回字典) @@ -171,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文件 @@ -184,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: """ 导出模拟输出 @@ -197,27 +198,7 @@ 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="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") +@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。") async def fastapi_burst_analysis( network: str = Query(..., description="管网名称(或数据库名称)"), modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"), @@ -225,6 +206,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: """ 爆管分析(高级版本) @@ -245,34 +227,18 @@ async def fastapi_burst_analysis( burst_size=burst_size, modify_total_duration=modify_total_duration, scheme_name=scheme_name, + username=username, ) 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="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。") +@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格式)"), valves: List[str] = Query(..., description="要关闭的阀门ID列表"), duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"), + scheme_name: str = Query(..., description="阀门关闭方案名称"), ) -> str: """ 阀门关闭分析(高级版本) @@ -281,6 +247,7 @@ async def fastapi_valve_close_analysis( - **start_time**: 阀门关闭开始时间 - **valves**: 要关闭的阀门ID列表 - **duration**: 模拟持续时间(秒,可选,默认900) + - **scheme_name**: 阀门关闭方案名称 支持同时关闭多个阀门进行分析。 """ @@ -289,11 +256,12 @@ 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" -@router.get("/valve_isolation_analysis/", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。") +@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。") async def valve_isolation_endpoint( network: str = Query(..., description="管网名称(或数据库名称)"), accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"), @@ -309,99 +277,154 @@ async def valve_isolation_endpoint( 返回隔离方案,包括: - must_close_valves: 必须关闭的阀门列表 - optional_valves: 可选关闭的阀门列表 - - affected_nodes: 受影响的节点列表 + - affected_nodes: 受影响的节点列表;不可隔离时为空列表 + - affected_node_count: 受影响的节点总数 - 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 -@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="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。") +@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秒"), - scheme_name: str | None = Query(None, description="冲洗方案名称(可选)"), + scheme_name: str = Query(..., description="冲洗方案名称"), + username: str = Depends(get_current_keycloak_username), ) -> str: """ 冲洗分析(高级版本) - **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) - - **scheme_name**: 冲洗方案名称(可选) + - **scheme_name**: 冲洗方案名称 支持多阀联合冲洗操作。 """ - 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, + username=username, ) return result or "success" -@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。") +@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格式)"), 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(可选)"), + username: str = Depends(get_current_keycloak_username), ) -> str: """ 污染物模拟 @@ -411,7 +434,7 @@ async def fastapi_contaminant_simulation( - **source**: 污染源节点ID - **concentration**: 污染浓度(mg/L) - **duration**: 模拟持续时间(秒) - - **scheme_name**: 模拟方案名称(可选) + - **scheme_name**: 模拟方案名称 - **pattern**: 污染源模式ID(可选) 用于评估管网中污染物的传播和影响范围。 @@ -424,27 +447,15 @@ async def fastapi_contaminant_simulation( source=source, concentration=concentration, source_pattern=pattern, + username=username, ) 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="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。") +@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格式)"), - end_time: str = Query(..., description="分析结束时间(ISO 8601格式)"), duration: int = Query(..., description="模拟持续时间(秒)"), ) -> str: """ @@ -452,7 +463,6 @@ async def fastapi_age_analysis( - **network**: 管网名称(或数据库名称) - **start_time**: 分析开始时间 - - **end_time**: 分析结束时间(可选) - **duration**: 模拟持续时间(秒) 分析指定时间段内管网中各节点的水体停留时间。 @@ -466,7 +476,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"), @@ -484,7 +494,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: """ 压力调节(高级版本) @@ -522,19 +532,7 @@ 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请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。") +@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。") async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str: """ 项目管理(高级版本) @@ -563,7 +561,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: """ 排程分析 @@ -589,7 +587,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: """ 日排程分析 @@ -616,64 +614,12 @@ 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.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: - """ - 管网更新(高级版本) - - - **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) -@router.post("/pump_failure/", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。") +@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。") async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str: """ 泵故障管理 @@ -717,7 +663,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="放置方案名称"), @@ -741,7 +687,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: @@ -767,7 +713,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="放置方案名称"), @@ -791,7 +737,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: @@ -817,7 +763,7 @@ async def fastapi_pressure_sensor_placement_kmeans( ) -@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。") +@router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。") async def fastapi_pressure_sensor_placement( network: str = Query(..., description="管网名称(或数据库名称)"), scheme_name: str = Query(..., description="放置方案名称"), @@ -865,7 +811,7 @@ async def fastapi_pressure_sensor_placement( return "success" -@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的日期、开始时间和持续时间,手动运行水力模拟。系统将自动查询管网参数并执行模拟。") +@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。") async def fastapi_run_simulation_manually_by_date( data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"), ) -> dict[str, str]: @@ -874,14 +820,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"]) @@ -908,10 +853,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/api/v1/endpoints/snapshots.py b/app/api/v1/endpoints/snapshots.py index 210f58e..2d6e245 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, @@ -22,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 @@ -31,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="管网名称(或数据库名称)")): """ 撤销操作 @@ -40,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="管网名称(或数据库名称)")): """ 重做操作 @@ -49,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]]: """ 获取快照列表 @@ -58,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: """ 检查快照是否存在 @@ -67,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: """ 检查操作快照是否存在 @@ -76,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: """ 检查当前操作快照是否存在 @@ -85,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"), @@ -98,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: """ 为当前操作创建快照 @@ -107,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: """ 创建快照 @@ -126,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: """ 选择快照 @@ -135,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"), @@ -148,8 +139,12 @@ 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: +@router.post("/with-servers", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None) +async def sync_with_server_endpoint( + network: str = Query(..., description="管网名称(或数据库名称)"), + operation: int = Query(..., description="目标操作ID"), + _=Depends(require_permission(SIMULATION_RUN)), +) -> ChangeSet: """ 与服务器同步 @@ -157,7 +152,7 @@ async def sync_with_server_endpoint(network: str = Query(..., description="管 """ 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: """ 执行批量命令 @@ -170,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 @@ -185,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 @@ -194,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 c0cbf41..7ac740d 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("/timeseries/views/scada-simulations", 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("/timeseries/views/element-simulations", 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("/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="查询开始时间"), @@ -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("/timeseries/scada-cleaning-runs", 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("/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 d6fabf3..705d27a 100644 --- a/app/api/v1/endpoints/timeseries/realtime.py +++ b/app/api/v1/endpoints/timeseries/realtime.py @@ -8,9 +8,12 @@ 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="批量插入实时管道数据", - tags=["时间序列-实时数据"]) + +@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) @@ -30,16 +33,21 @@ async def insert_realtime_links( return {"message": f"Inserted {len(data)} records"} -@router.get("/realtime/links", summary="查询实时管道数据", tags=["时间序列-实时数据"]) +@router.get( + "/timeseries/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: 查询开始时间 @@ -51,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="删除实时管道数据", tags=["时间序列-实时数据"]) +@router.delete( + "/timeseries/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), ): """ @@ -73,11 +85,10 @@ async def delete_realtime_links( return {"message": "Deleted successfully"} -@router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段", - tags=["时间序列-实时数据"]) +@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="更新数据的时间戳"), + time: datetime = Query(..., description=f"要更新记录的时间戳。{TIME_WITH_TZ_DESC}"), field: str = Query(..., description="要更新的字段名称"), value: float = Query(..., description="更新的字段值"), conn: AsyncConnection = Depends(get_timescale_connection), @@ -106,8 +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="批量插入实时节点数据", - tags=["时间序列-实时数据"]) +@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) @@ -127,16 +137,21 @@ async def insert_realtime_nodes( return {"message": f"Inserted {len(data)} records"} -@router.get("/realtime/nodes", summary="查询实时节点数据", tags=["时间序列-实时数据"]) +@router.get( + "/timeseries/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: 查询开始时间 @@ -148,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="删除实时节点数据", tags=["时间序列-实时数据"]) +@router.delete( + "/timeseries/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), ): """ @@ -172,12 +191,11 @@ async def delete_realtime_nodes( -@router.post("/realtime/simulation/store", status_code=201, summary="存储实时模拟结果", - tags=["时间序列-实时数据"]) +@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="管道模拟结果列表"), - result_start_time: str = Query(..., description="模拟结果开始时间"), + result_start_time: str = Query(..., description=f"模拟结果开始时间。{TIME_WITH_TZ_DESC}"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ @@ -199,10 +217,13 @@ async def store_realtime_simulation_result( return {"message": "Simulation results stored successfully"} -@router.get("/realtime/query/by-time-property", summary="按时间和属性查询实时数据", - tags=["时间序列-实时数据"]) +@router.get( + "/timeseries/realtime/records", + 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), @@ -232,12 +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和时间查询实时模拟数据", - tags=["时间序列-实时数据"]) +@router.get( + "/timeseries/realtime/simulation-results", + 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/api/v1/endpoints/timeseries/scada.py b/app/api/v1/endpoints/timeseries/scada.py index a42f87c..3f75b98 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("/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) + 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("/timeseries/scada-readings", 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( + "/timeseries/scada-readings/fields", 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("/timeseries/scada-readings/{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("/timeseries/scada-readings", 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..0c56a75 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("/timeseries/schemes/links/batches", 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("/timeseries/schemes/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("/timeseries/schemes/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("/timeseries/schemes/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("/timeseries/schemes/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("/timeseries/schemes/nodes/batches", 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("/timeseries/schemes/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("/timeseries/schemes/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("/timeseries/schemes/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("/timeseries/schemes/simulation-results", 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( + "/timeseries/schemes/records", 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("/timeseries/schemes/simulation-results", 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/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/endpoints/users.py b/app/api/v1/endpoints/users.py deleted file mode 100644 index 9cd1507..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("/getuserschema/", summary="获取用户模式", description="获取指定网络的用户模式定义") -async def fastapi_get_user_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]: - """ - 获取用户模式定义 - - 返回指定网络的用户模式结构定义 - """ - return get_user_schema(network) - -@router.get("/getuser/", 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("/getallusers/", 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/endpoints/web_search.py b/app/api/v1/endpoints/web_search.py new file mode 100644 index 0000000..29f05e1 --- /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-searches", + 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/rest_router.py b/app/api/v1/rest_router.py new file mode 100644 index 0000000..00ea840 --- /dev/null +++ b/app/api/v1/rest_router.py @@ -0,0 +1,397 @@ +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.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.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 + +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", "PressureSensorPlacement"} +_MODEL_USERNAME_FROM_AUTH = {"PressureSensorPlacement"} + + +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) + 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): + 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: + 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=result.total, + limit=limit or len(result), + offset=offset, + ) + return Page( + items=result[offset : offset + limit], + total=len(result), + limit=limit, + offset=offset, + ) + + parameters = list(signature.parameters.values()) + 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 + + +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: + 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 + 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] + 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 53a6125..f2b73ef 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,112 +1,232 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends + from app.api.v1.endpoints import ( - auth, - project, - simulation, - scada, - extension, - snapshots, - data_query, - users, - schemes, - misc, - risk, - cache, - leakage, + access, + admin_metadata, + agent_auth, + audit, burst_detection, burst_location, - user_management, # 新增:用户管理 - audit, # 新增:审计日志 + cache, + extension, + geocoding, + leakage, meta, -) -from app.api.v1.endpoints.network import ( - general, - junctions, - reservoirs, - tanks, - pipes, - pumps, - valves, - tags, - demands, - geometry, - regions, + misc, + model_import, + project, + project_data, + risk, + scada, + schemes, + sensor_placement, + simulation, + snapshots, + 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, + ENVIRONMENT_MANAGE, + 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 -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(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增 +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)) +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)) + +# 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, + tags=["Metadata Admin"], +) +api_router.include_router(model_import.router, tags=["Model Administration"]) +api_router.include_router(audit.router, 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, tags=["SCADA"]) -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(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( + 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=[environment_manage_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, + tags=["Leakage"], + dependencies=[burst_run_access], +) +api_router.include_router( + burst_detection.router, + tags=["Burst Detection"], + dependencies=[burst_run_access], +) +api_router.include_router( + burst_location.router, + 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/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..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 @@ -8,35 +8,51 @@ 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") + + 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( 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 +61,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( @@ -63,41 +83,18 @@ async def get_current_keycloak_sub( ) from exc -async def get_current_keycloak_username( - token: str | None = Depends(oauth2_optional), -) -> 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") +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 8424429..47742f9 100644 --- a/app/auth/metadata_dependencies.py +++ b/app/auth/metadata_dependencies.py @@ -6,8 +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_sub -from app.core.config import settings +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 @@ -20,10 +22,36 @@ 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 _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) + username = get_keycloak_preferred_username(keycloak_payload) try: user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub) except SQLAlchemyError as exc: @@ -33,12 +61,27 @@ 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( status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" ) + try: + user = await metadata_repo.refresh_user_keycloak_snapshot( + user, + username=username, + 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="Metadata database is unavailable", + ) from exc return user diff --git a/app/auth/permissions.py b/app/auth/permissions.py index 0fb8d1c..380ea5e 100644 --- a/app/auth/permissions.py +++ b/app/auth/permissions.py @@ -1,106 +1,162 @@ -""" -权限控制依赖项和装饰器 +from collections.abc import Awaitable, Callable +from typing import Any -基于角色的访问控制(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 +from fastapi import Depends, HTTPException, Request, status -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 +from app.auth.project_dependencies import ProjectContext, get_project_context -# 预定义的权限检查依赖 -require_admin = require_role(UserRole.ADMIN) -require_operator = require_role(UserRole.OPERATOR) -require_user = require_role(UserRole.USER) +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" -def get_current_admin( - current_user: UserInDB = Depends(require_admin) -) -> UserInDB: - """ - 获取当前管理员用户 - - 等同于 Depends(require_role(UserRole.ADMIN)) - """ - return current_user +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, + } +) -def get_current_operator( - current_user: UserInDB = Depends(require_operator) -) -> UserInDB: - """ - 获取当前操作员用户(或更高权限) - - 等同于 Depends(require_role(UserRole.OPERATOR)) - """ - return current_user +PROJECT_VIEWER_PERMISSIONS = frozenset( + { + WEBGIS_VIEW, + SCADA_VIEW, + SIMULATION_VIEW, + } +) -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 +SYSTEM_ADMIN_PERMISSIONS = frozenset( + { + MODEL_IMPORT, + AUDIT_VIEW, + ENVIRONMENT_MANAGE, + MEMBERSHIP_MANAGE, + } +) -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 +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 6513c93..6a2d387 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" @@ -25,8 +28,11 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True) class ProjectContext: project_id: UUID + project_code: str user_id: UUID project_role: str + system_role: str = "user" + is_superuser: bool = False async def get_metadata_repository( @@ -35,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) @@ -58,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" @@ -80,43 +78,71 @@ async def get_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( project_id=project.id, - user_id=user.id, + project_code=project.code, + 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 @@ -135,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 @@ -176,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/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/core/config.py b/app/core/config.py index 7404bd4..9a521bf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,20 +6,12 @@ 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" - ) - 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 专用 + # 敏感配置加密密钥 (Fernet) + DATABASE_ENCRYPTION_KEY: str = "" # Database Config (PostgreSQL) DB_NAME: str = "tjwater" @@ -57,10 +49,21 @@ 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 = "" + KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900 + + # 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 + + # 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: 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/core/security.py b/app/core/security.py deleted file mode 100644 index 802e837..0000000 --- a/app/core/security.py +++ /dev/null @@ -1,91 +0,0 @@ -from datetime import datetime, timedelta -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 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 = datetime.now() + expires_delta - else: - expire = datetime.now() + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) - - to_encode = { - "exp": expire, - "sub": str(subject), - "type": "access", - "iat": datetime.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 = datetime.now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - - to_encode = { - "exp": expire, - "sub": str(subject), - "type": "refresh", - "iat": datetime.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/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 new file mode 100644 index 0000000..6ebfc25 --- /dev/null +++ b/app/domain/schemas/admin_metadata.py @@ -0,0 +1,134 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +BusinessRole = Literal["admin", "user"] +ProjectRole = Literal["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 diff --git a/app/domain/schemas/metadata.py b/app/domain/schemas/metadata.py index 91dc4c3..f161a5f 100644 --- a/app/domain/schemas/metadata.py +++ b/app/domain/schemas/metadata.py @@ -4,31 +4,22 @@ from uuid import UUID from pydantic import BaseModel -class GeoServerConfigResponse(BaseModel): - gs_base_url: Optional[str] - gs_admin_user: Optional[str] - gs_datastore_name: str - default_extent: Optional[dict] - srid: int - - 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] 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/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py new file mode 100644 index 0000000..a8f503b --- /dev/null +++ b/app/domain/schemas/sensor_placement.py @@ -0,0 +1,92 @@ +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, + max_length=200, + ) + sensor_location: list[str] = Field(..., min_length=1, max_length=200) + + @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, max_length=200) + adjustment_status: dict[str, AdjustmentStatus] = Field( + default_factory=dict, + max_length=200, + ) + + @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 + max_pipe_diameter: float | None = Field( + ..., + description="节点关联管道的最大管径,单位:毫米", + ) + 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/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 eb94fe8..fbb3f02 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/", ] @@ -60,12 +58,23 @@ class AuditMiddleware(BaseHTTPMiddleware): "/meta/projects", "/api/v1/openproject/", "/openproject/", + "/api/v1/audit/session-events", + "/audit/session-events", } + EXCLUDED_PATH_PREFIXES = ( + ) 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"] @@ -73,16 +82,9 @@ class AuditMiddleware(BaseHTTPMiddleware): request_data = None if should_capture_body: try: - # 注意:读取 body 后需要重新设置,避免影响后续处理 body = await request.body() if body: request_data = json.loads(body.decode()) - - # 重新构造请求以供后续使用 - async def receive(): - return {"type": "http.request", "body": body} - - request._receive = receive except Exception as e: logger.warning(f"Failed to read request body for audit: {e}") @@ -90,7 +92,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 @@ -150,6 +152,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: @@ -168,20 +175,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") @@ -196,7 +197,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/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/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/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 9631d7b..b620ba5 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -1,16 +1,15 @@ 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 ( get_database_encryptor, - get_encryptor, is_database_encryption_configured, - is_encryption_configured, ) from app.infra.db.metadb import models @@ -43,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 @@ -75,7 +63,27 @@ class ProjectDetail: gs_workspace: str map_extent: Optional[dict] status: str - 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) class MetadataRepository: @@ -96,6 +104,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,13 +196,76 @@ 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, + creator_user_id: UUID | None = None, + ) -> 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) + 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 + + 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: return None - - geoserver = await self.get_geoserver_config(project.id) - return ProjectDetail( project_id=project.id, name=project.name, @@ -123,7 +274,6 @@ class MetadataRepository: gs_workspace=project.gs_workspace, map_extent=project.map_extent, status=project.status, - geoserver=geoserver ) async def get_membership_role( @@ -137,6 +287,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]: @@ -169,35 +455,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 list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]: stmt = ( select(models.Project, models.UserProjectMembership.project_role) @@ -236,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/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/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/app/infra/db/timescaledb/internal_queries.py b/app/infra/db/timescaledb/internal_queries.py index 3de4db7..5126cf8 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: @@ -49,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, ): @@ -69,6 +71,7 @@ class InternalStorage: link_result_list, result_start_time, num_periods, + result_timestep_seconds, ) break # 成功 except Exception as e: @@ -89,10 +92,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 +134,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: @@ -173,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], @@ -235,17 +263,18 @@ 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 = ( - 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}") @@ -265,9 +294,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), @@ -280,25 +309,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/infra/db/timescaledb/repositories/realtime.py b/app/infra/db/timescaledb/repositories/realtime.py index 06a32de..6b26fa4 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: @@ -102,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() @@ -298,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() @@ -397,24 +399,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 +462,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 +528,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 +572,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/scada.py b/app/infra/db/timescaledb/repositories/scada.py index bc8717f..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, @@ -89,12 +110,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/app/infra/db/timescaledb/repositories/scheme.py b/app/infra/db/timescaledb/repositories/scheme.py index bfa09ca..3903fc0 100644 --- a/app/infra/db/timescaledb/repositories/scheme.py +++ b/app/infra/db/timescaledb/repositories/scheme.py @@ -1,14 +1,26 @@ 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_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 --- @@ -454,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. @@ -466,39 +479,20 @@ 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) - - timestep_parts = globals.hydraulic_timestep.split(":") - timestep = timedelta( - hours=int(timestep_parts[0]), - minutes=int(timestep_parts[1]), - seconds=int(timestep_parts[2]), + simulation_time = parse_utc_time( + result_start_time, field_name="result_start_time" ) + 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, @@ -516,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, @@ -552,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). @@ -564,39 +560,20 @@ 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) - - timestep_parts = globals.hydraulic_timestep.split(":") - timestep = timedelta( - hours=int(timestep_parts[0]), - minutes=int(timestep_parts[1]), - seconds=int(timestep_parts[2]), + simulation_time = parse_utc_time( + result_start_time, field_name="result_start_time" ) + 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, @@ -614,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, @@ -664,21 +642,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 +691,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/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) diff --git a/app/main.py b/app/main.py index fe00e06..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,13 +65,15 @@ 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) +# app.include_router(api_router) # 配置中间件 app.add_middleware(GZipMiddleware, minimum_size=1000) diff --git a/app/native/wndb/__init__.py b/app/native/wndb/__init__.py index 57d230b..35b9c20 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, @@ -456,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 ( @@ -468,6 +470,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/connection.py b/app/native/wndb/connection.py index b42b481..c8d1a67 100644 --- a/app/native/wndb/connection.py +++ b/app/native/wndb/connection.py @@ -1,3 +1,78 @@ +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 _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) + 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 not _is_healthy(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 not _is_healthy(connection): + del g_conn_dict[name] + _close_connection(connection) + 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 c043d03..5009418 100644 --- a/app/native/wndb/database.py +++ b/app/native/wndb/database.py @@ -1,6 +1,7 @@ +from collections.abc import Mapping, Sequence 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' @@ -82,30 +83,45 @@ class DbChangeSet: return DbChangeSet(redo_sql, undo_sql, redo_cs_s, undo_cs_s) -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 +QueryParams = Sequence[Any] | Mapping[str, Any] -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() +def _execute(cur, sql: str, params: QueryParams | None = None): + return cur.execute(sql, params) if params is not None else cur.execute(sql) -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() +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: + _execute(cur, sql, params) + row = cur.fetchone() + if row == None: + raise Exception(sql) + return 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: + _execute(cur, sql, params) + return cur.fetchall() + + +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: + _execute(cur, sql, params) + 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: @@ -136,18 +152,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 +177,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/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..02ee733 100644 --- a/app/native/wndb/s0_base.py +++ b/app/native/wndb/s0_base.py @@ -1,5 +1,6 @@ +from psycopg import sql 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 +48,15 @@ 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( + sql.SQL("select * from {} where id = %s").format( + sql.Identifier(base_type) + ), + (id,), + ) + return cur.fetchone() def is_node(name: str, id: str) -> bool: @@ -125,10 +132,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 +146,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 +194,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,31 +246,36 @@ 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( + "select id from pipes where node1 = %s or node2 = %s", (id, id) + ).fetchall(): + links.append(p['id']) + 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( + "select id from valves where node1 = %s or node2 = %s", (id, id) + ).fetchall(): + links.append(p['id']) + return links 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 d038a96..46f2bf5 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})')" @@ -21,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} @@ -49,10 +55,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: @@ -63,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/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/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/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) 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/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/app/native/wndb/s42_sensor_placement.py b/app/native/wndb/s42_sensor_placement.py index 5ad8b2a..38eb57a 100644 --- a/app/native/wndb/s42_sensor_placement.py +++ b/app/native/wndb/s42_sensor_placement.py @@ -1,7 +1,124 @@ -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( + """ + 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, + 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 + 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), + ) + 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/__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/burst_detection.py b/app/services/burst_detection.py index 59baf32..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 @@ -14,6 +16,16 @@ 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 + + +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( @@ -30,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, @@ -41,7 +55,8 @@ def run_burst_detection( """ 运行爆管侦测服务入口。 - 调用方式二选一: + 调用方式三选一: + - 不传数据时间窗,自动侦测最近完整时刻;可用 `target_time` 回放历史时刻 - 直接传 `observed_pressure_data` - 或传 `scada_start/scada_end` 让后端自动查询 SCADA 压力数据 @@ -73,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 @@ -120,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, @@ -129,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" @@ -140,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(), @@ -241,7 +375,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 +403,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", []), @@ -293,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("爆管侦测结果为空。") @@ -317,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"]), @@ -325,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]]: @@ -333,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): @@ -376,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: @@ -426,6 +761,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..589d122 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 @@ -11,10 +11,12 @@ 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, ) 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"} @@ -39,7 +41,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") @@ -59,6 +61,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, @@ -86,16 +90,43 @@ 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: raise ValueError("模拟方案模式必须提供 simulation_scheme_name。") + normal_start_dt = burst_start_dt + normal_end_dt = burst_end_dt ( burst_pressure_series, burst_pressure_samples, @@ -116,8 +147,8 @@ 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, + end_dt=normal_end_dt, data_type="pressure", series_name="normal_pressure", simulation_source="realtime", @@ -126,6 +157,11 @@ def run_burst_location_by_network( ) 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, @@ -137,21 +173,31 @@ 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, + 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, + end_dt=normal_end_dt, + data_type="pressure", + series_name="normal_pressure", + ) + observed_source = "scada_burst_scada_normal_timerange" + else: + 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="压力数据", ) - observed_source = "scada_burst_realtime_normal_timerange" + ) else: if burst_pressure is None or normal_pressure is None: raise ValueError( @@ -178,6 +224,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。") @@ -198,8 +249,8 @@ 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, + end_dt=normal_end_dt, data_type="flow", series_name="normal_flow", simulation_source="realtime", @@ -208,6 +259,11 @@ def run_burst_location_by_network( ) ) 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, @@ -216,17 +272,26 @@ 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, + end_dt=normal_end_dt, + data_type="flow", + series_name="normal_flow", + ) + ) + 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: @@ -257,6 +322,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] = { @@ -280,10 +346,23 @@ 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": + 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( @@ -301,7 +380,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 +406,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", []), @@ -375,6 +454,64 @@ 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 _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, scheme_type=scheme_type) 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], + 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, @@ -384,13 +521,14 @@ 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 ] 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( @@ -399,6 +537,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): @@ -407,9 +546,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_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) @@ -426,13 +569,14 @@ 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 ] 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, @@ -445,6 +589,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: @@ -453,13 +598,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, @@ -475,6 +631,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 } @@ -555,6 +712,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": @@ -594,14 +752,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 @@ -637,13 +790,35 @@ 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: - 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/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/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/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 diff --git a/app/services/scheme_management.py b/app/services/scheme_management.py index a86a9bd..1cab298 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,53 +8,7 @@ import psycopg from sqlalchemy import create_engine from app.core.config import get_pgconn_string - - -# 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}") +from app.services.time_api import parse_utc_time # 2025/03/23 @@ -89,7 +43,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, ): """ @@ -97,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: """ @@ -112,13 +66,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, ), ) @@ -150,10 +107,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: @@ -162,8 +125,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 @@ -171,6 +164,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/sensor_placement.py b/app/services/sensor_placement.py new file mode 100644 index 0000000..8d4f4f7 --- /dev/null +++ b/app/services/sensor_placement.py @@ -0,0 +1,271 @@ +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, + "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, + "map_y": map_y, + "longitude": float(longitude), + "latitude": float(latitude), + "elevation": float(node["elevation"]), + } + ) + 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], +) -> 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/simulation.py b/app/services/simulation.py index 204c572..d56776c 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, parse_clock_duration_seconds 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): @@ -684,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, @@ -699,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: """ 传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果 @@ -713,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: @@ -755,11 +781,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) @@ -1196,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) @@ -1256,6 +1287,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, @@ -1263,6 +1297,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/app/services/time_api.py b/app/services/time_api.py index 85cc2f4..569626a 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]: @@ -78,12 +89,48 @@ 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 时间段,传进来的日期被认为是北京时间 日期字符串格式: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 +155,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 +163,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/app/services/tjnetwork.py b/app/services/tjnetwork.py index a8447e0..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 ############################################################ @@ -1312,8 +1299,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 @@ -1344,6 +1357,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/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/contracts/manifest.json b/contracts/manifest.json new file mode 100644 index 0000000..782a78b --- /dev/null +++ b/contracts/manifest.json @@ -0,0 +1,9 @@ +{ + "contract_version": "1.0.0", + "contracts": { + "server": { + "file": "server-v1.openapi.json", + "sha256": "df7ae927dcf5ae32c3c1ad9be3245b1b78b984ce1902dd91e6313770860e0d48" + } + } +} diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json new file mode 100644 index 0000000..1c05cfd --- /dev/null +++ b/contracts/server-v1.openapi.json @@ -0,0 +1,51509 @@ +{ + "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" + }, + "PressureSensorPlacementRest": { + "properties": { + "min_diameter": { + "default": 0, + "description": "最小管径限制", + "title": "Min Diameter", + "type": "integer" + }, + "scheme_name": { + "description": "方案名称", + "title": "Scheme Name", + "type": "string" + }, + "sensor_number": { + "description": "传感器数量", + "title": "Sensor Number", + "type": "integer" + } + }, + "required": [ + "scheme_name", + "sensor_number" + ], + "title": "PressureSensorPlacementRest", + "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" + }, + "max_pipe_diameter": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "节点关联管道的最大管径,单位:毫米", + "title": "Max Pipe Diameter" + }, + "node_id": { + "title": "Node Id", + "type": "string" + }, + "project_x": { + "title": "Project X", + "type": "number" + }, + "project_y": { + "title": "Project Y", + "type": "number" + } + }, + "required": [ + "node_id", + "max_pipe_diameter", + "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": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "参与控制的阀门ID列表(可选)", + "title": "Valves" + } + }, + { + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", + "in": "query", + "name": "valves_k", + "required": false, + "schema": { + "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" + } + }, + { + "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/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/PressureSensorPlacementRest", + "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/PressureSensorPlacementRest", + "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-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", + "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/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/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/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: 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/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 new file mode 100644 index 0000000..e0dce29 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/messages/messages_en.properties @@ -0,0 +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 new file mode 100644 index 0000000..3fba4a9 --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/messages/messages_zh_CN.properties @@ -0,0 +1,13 @@ +loginAccountTitle=账号登录 +usernameOrEmail=用户名或邮箱 +doLogIn=登录 +doForgotPassword=忘记密码 +rememberMe=记住我 +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 new file mode 100644 index 0000000..19a185d --- /dev/null +++ b/infra/docker/keycloak/themes/tjwater/login/resources/css/tjwater-login.css @@ -0,0 +1,589 @@ +: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; +} + +.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; + 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/requirements.txt b/requirements.txt index 259c4e2..04460ac 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 +pyclipper==1.4.0 \ No newline at end of file diff --git a/resources/sql/001_create_users_table.sql b/resources/sql/001_create_users_table.sql deleted file mode 100644 index d0eb301..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 DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP 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 5fdc1c1..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 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/003_normalize_timestamp_columns.sql b/resources/sql/003_normalize_timestamp_columns.sql new file mode 100644 index 0000000..c4a13b9 --- /dev/null +++ b/resources/sql/003_normalize_timestamp_columns.sql @@ -0,0 +1,47 @@ +-- ============================================ +-- 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; +END $$; diff --git a/resources/sql/004_metadata_auth_management.sql b/resources/sql/004_metadata_auth_management.sql new file mode 100644 index 0000000..16fc753 --- /dev/null +++ b/resources/sql/004_metadata_auth_management.sql @@ -0,0 +1,53 @@ +-- 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'; + +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); diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql new file mode 100644 index 0000000..bda3627 --- /dev/null +++ b/resources/sql/005_metadata_project_configuration.sql @@ -0,0 +1,68 @@ +-- 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); + +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 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, + 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); 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/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 4dbb7f9..5563403 100644 --- a/resources/sql/create/40.scheme_list.sql +++ b/resources/sql/create/40.scheme_list.sql @@ -7,8 +7,8 @@ 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 varchar(50) not null, scheme_detail JSON -) \ No newline at end of file +) 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/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/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", 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/scripts/main.py b/scripts/main.py index 070895c..902f9ef 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 @@ -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 ############################################################ @@ -3654,40 +3633,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 +3672,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 +3714,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 +3729,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/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/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/scripts/online_Analysis.py b/scripts/online_Analysis.py index bcf4c2e..72b2dba 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, ) @@ -623,7 +627,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 @@ -1141,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: """ @@ -1568,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/scripts/run_server.py b/scripts/run_server.py index 57f2c67..5ea313b 100644 --- a/scripts/run_server.py +++ b/scripts/run_server.py @@ -1,11 +1,12 @@ import asyncio -import sys import os +import sys import uvicorn # 将项目根目录添加到 python 路径 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + if __name__ == "__main__": # Windows 设置事件循环策略 if sys.platform == "win32": diff --git a/scripts/trigger-gitea-pipeline.sh b/scripts/trigger-gitea-pipeline.sh new file mode 100755 index 0000000..3e7453f --- /dev/null +++ b/scripts/trigger-gitea-pipeline.sh @@ -0,0 +1,71 @@ +#!/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" + 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 [[ "$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 +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}'." diff --git a/tests/api/test_access_endpoints.py b/tests/api/test_access_endpoints.py new file mode 100644 index 0000000..ad09ae8 --- /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 new file mode 100644 index 0000000..70161dc --- /dev/null +++ b/tests/api/test_admin_metadata_endpoints.py @@ -0,0 +1,582 @@ +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, + 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 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="user", + 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="user", 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="user", + 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": "user", "is_active": False}, + ) + assert response.role == "user" + 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="user"), + 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()) + current_user = _user(role="admin", is_superuser=True) + + 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=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() + + +@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_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="member", + ) + 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="member"), + project_id=project_id, + user_id=user_id, + current_user=_user(role="admin", is_superuser=True), + metadata_repo=repo, + ) + + assert response.project_role == "member" + repo.update_project_member_role.assert_awaited_once_with( + project_id, user_id, "member" + ) + 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="member"), + 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/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py new file mode 100644 index 0000000..088e263 --- /dev/null +++ b/tests/api/test_agent_auth_endpoints.py @@ -0,0 +1,96 @@ +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, + project_code="fengyang", + user_id=user_id, + project_role="member", + ), + 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), + "network": "fengyang", + "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", + } + + +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..b9eb9cb 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,9 +48,9 @@ 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("/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 new file mode 100644 index 0000000..2cd9a65 --- /dev/null +++ b/tests/api/test_audit_endpoints.py @@ -0,0 +1,97 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +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, "/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 + 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( + "/api/v1/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("/api/v1/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("/api/v1/audit-logs/mine", 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_audit_middleware.py b/tests/api/test_audit_middleware.py new file mode 100644 index 0000000..4a143c7 --- /dev/null +++ b/tests/api/test_audit_middleware.py @@ -0,0 +1,51 @@ +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 +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): + 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_leakage_endpoints.py b/tests/api/test_leakage_endpoints.py index db8ec69..ecd01d2 100644 --- a/tests/api/test_leakage_endpoints.py +++ b/tests/api/test_leakage_endpoints.py @@ -1,13 +1,11 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from types import SimpleNamespace - 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" ) @@ -25,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", @@ -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_meta_endpoints.py b/tests/api/test_meta_endpoints.py new file mode 100644 index 0000000..e8ddd3a --- /dev/null +++ b/tests/api/test_meta_endpoints.py @@ -0,0 +1,87 @@ +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, + ) + + 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", + ) + + repo.get_project_by_id = get_project_by_id + + app = build_test_app(module.router, "/api/v1") + app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace( + project_id=project_id, + project_role="member", + ) + app.dependency_overrides[module.get_metadata_repository] = lambda: repo + client = TestClient(app) + + 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} + + +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/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 new file mode 100644 index 0000000..4fd52de --- /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-imports", + 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-imports", + 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-imports", + 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_openapi_contract.py b/tests/api/test_openapi_contract.py new file mode 100644 index 0000000..33aeb40 --- /dev/null +++ b/tests/api/test_openapi_contract.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi import APIRouter, FastAPI, Query +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.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 + + +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_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 + + 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", {}) + + 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"] + + 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"} + ] + + +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 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") + + 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": 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() + + @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", + } + ] + } diff --git a/tests/api/test_project_endpoints.py b/tests/api/test_project_endpoints.py new file mode 100644 index 0000000..31efb35 --- /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/projects/current", params={"network": "missing"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "Project missing not found" + + +def test_project_info_returns_project_workspace(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", + ) + 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/projects/current", params={"network": "demo"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == "demo" + assert payload["gs_workspace"] == "ws" + assert "geoserver" not in payload + + +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/projects/current", 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/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 + 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..a1d0898 --- /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.post( + "/api/v1/service-area-calculations", + 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/service-area-calculations"]["post"]["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/district-metering-areas", + 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/virtual-district-generation-runs", + 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_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/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py new file mode 100644 index 0000000..73b42ef --- /dev/null +++ b/tests/api/test_sensor_placement_endpoints.py @@ -0,0 +1,429 @@ +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 + + +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", + "max_pipe_diameter": 400.0, + "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", + "max_pipe_diameter": 300.0, + "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, project_role: str = "member"): + self.project_code = project_code + self.project_role = project_role + + 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 + ), + "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, + 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, project_role="member"): + 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", project_role) + ) + 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-optimization-runs", + 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_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( + "/api/v1/sensor-placement-optimization-runs", + 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-optimization-runs", + 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-optimization-runs", + 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-optimization-runs", + json={ + "network": "tjwater", + "scheme_name": "超大方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 201, + "min_diameter": 300, + }, + ) + + 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-optimization-runs", + json={ + "network": "tjwater", + "scheme_name": "只读成员方案", + "sensor_type": "pressure", + "method": "kmeans", + "sensor_count": 2, + "min_diameter": 300, + }, + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "project_role", + ["owner", "admin", "modeler", "dispatcher", "auditor"], +) +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-optimization-runs", + 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): + 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-optimization-runs", + 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( + 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_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( + 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/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py new file mode 100644 index 0000000..a4dab84 --- /dev/null +++ b/tests/api/test_simulation_endpoints.py @@ -0,0 +1,576 @@ +from datetime import datetime, timezone + +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) + 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 + ) + + 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, + }, + ) + install_stub( + 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, + "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( + 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 _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}") + client = TestClient(build_test_app(module.router, "/api/v1")) + + response = client.post("/api/v1/project-runs", 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-analyses", + 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-managements", + 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_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_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 = {} + + 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/simulation-runs", + 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/simulation-runs", + json={ + "name": "demo", + "start_time": "2025-01-02T03:04:05", + "duration": 30, + }, + ) + + 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.post( + "/api/v1/valve-closure-analyses", + 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_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.post( + "/api/v1/burst-analyses", + 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 = {} + + 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_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}, + "valve_control": None, + "drainage_node_ID": "N1", + "flushing_flow": 100.0, + "scheme_name": "flush_case_01", + "username": "alice", + } + + +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 = {} + + 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.post( + "/api/v1/contaminant-simulations", + 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 = _build_authenticated_client(module) + + response = client.post( + "/api/v1/contaminant-simulations", + 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/auth/test_keycloak_dependencies.py b/tests/auth/test_keycloak_dependencies.py new file mode 100644 index 0000000..18ba63d --- /dev/null +++ b/tests/auth/test_keycloak_dependencies.py @@ -0,0 +1,73 @@ +import pytest +from fastapi import HTTPException + +from app.auth import keycloak_dependencies +from app.auth.keycloak_dependencies import ( + _decode_keycloak_token, + 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" + + +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"} diff --git a/tests/auth/test_metadata_dependencies.py b/tests/auth/test_metadata_dependencies.py new file mode 100644 index 0000000..8a99ed5 --- /dev/null +++ b/tests/auth/test_metadata_dependencies.py @@ -0,0 +1,106 @@ +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() + + +@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() 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..a8cd3ac --- /dev/null +++ b/tests/auth/test_rbac_migration.py @@ -0,0 +1,43 @@ +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" + ) + + 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/conftest.py b/tests/conftest.py index b1c7e7b..93a1645 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,178 @@ -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) + monkeypatch.setattr(parent, child_name, module, raising=False) + + 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_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_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, + } diff --git a/tests/unit/test_audit_repository.py b/tests/unit/test_audit_repository.py new file mode 100644 index 0000000..8397270 --- /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="CREATE_PROJECT", + request_method="POST", + request_path="/api/v1/projects", + response_status=200, + ) + ) + + assert result.action == "CREATE_PROJECT" + 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_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] diff --git a/tests/unit/test_burst_location_service.py b/tests/unit/test_burst_location_service.py index abe38a1..c80f813 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 @@ -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 [ @@ -30,9 +37,27 @@ 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) + 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" @@ -52,25 +77,35 @@ 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: {} 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 + 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 @@ -125,16 +160,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}") @@ -159,6 +194,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, scheme_type=None: [ + ( + 1, + "BurstSchemeA", + "burst_analysis", + "testuser", + None, + None, + {"burst_ID": ["Pipe-009", "Pipe-010"]}, + ) + ], + ) result = module.run_burst_location_by_network( network="tjwater", @@ -167,8 +217,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, ) @@ -176,9 +226,11 @@ 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} + 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(11.0) @@ -192,14 +244,20 @@ 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 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-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", + "normal_start": "2025-01-01T00:00:00+00:00", + "normal_end": "2025-01-01T01:00:00+00:00", } @@ -225,12 +283,140 @@ 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))), ) -def test_run_burst_location_monitoring_uses_scada_for_burst_and_realtime_for_normal( +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_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_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 ): module = _load_burst_location_module() @@ -258,10 +444,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]}, ] } @@ -290,12 +480,244 @@ 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))), + 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_defaults_normal_window_to_previous_day( + 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"}, + ) + + 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(fake_scada_query), + ) + 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_burst_scada_normal_timerange" + assert len(scada_calls) == 2 + 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(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( + 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) 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_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"} 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 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 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) 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 new file mode 100644 index 0000000..8023590 --- /dev/null +++ b/tests/unit/test_pressure_cleaning.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from app.algorithms.cleaning import pressure as pressure_cleaning + + +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(): + 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"]) + + 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 + + +@REQUIRES_PRESSURE_SAMPLES +def test_clean_pressure_data_df_km_accepts_single_sensor_wide_frame_with_utc_strings(): + noisy_df = pd.read_csv(NOISY_DATA_PATH) + 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 = 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 new file mode 100644 index 0000000..9559840 --- /dev/null +++ b/tests/unit/test_realtime_repository.py @@ -0,0 +1,67 @@ +import asyncio +from datetime import datetime, timezone + +from app.infra.db.timescaledb.repositories.realtime import 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(): + 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(): + 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), + ) 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 设备" diff --git a/tests/unit/test_scada_repository.py b/tests/unit/test_scada_repository.py new file mode 100644 index 0000000..c8a01e3 --- /dev/null +++ b/tests/unit/test_scada_repository.py @@ -0,0 +1,88 @@ +import asyncio +from datetime import datetime, timezone +import importlib.util +from pathlib import Path + + +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 + + +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) + + asyncio.run( + 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] + + +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) + + asyncio.run( + 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] 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", + ) + == {} + ) diff --git a/tests/unit/test_scheme_simulation_timestep.py b/tests/unit/test_scheme_simulation_timestep.py new file mode 100644 index 0000000..8057b0a --- /dev/null +++ b/tests/unit/test_scheme_simulation_timestep.py @@ -0,0 +1,219 @@ +import inspect +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 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 [ + { + "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 diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py new file mode 100644 index 0000000..e14e06b --- /dev/null +++ b/tests/unit/test_sensor_placement_service.py @@ -0,0 +1,194 @@ +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", + "max_pipe_diameter": 400.0, + "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_candidate_keeps_engineering_coordinates_and_transforms_map_coordinates( + monkeypatch, +): + monkeypatch.setattr( + sensor_placement.wndb, + "get_sensor_placement_nodes", + lambda network, node_ids: [ + { + "node_id": "J1", + "max_pipe_diameter": 400.0, + "project_x": 3038.94, + "project_y": -34446.59, + "map_x": 13525191.530279, + "map_y": 3622984.760237, + "elevation": 4.5, + } + ], + ) + + 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) + + +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 + 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): + monkeypatch.setattr( + sensor_placement, + "_sensor_points", + lambda network, locations: [ + { + "node_id": "J1", + "max_pipe_diameter": 400.0, + "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"]) 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, + ) diff --git a/tests/unit/test_time_api.py b/tests/unit/test_time_api.py new file mode 100644 index 0000000..319c316 --- /dev/null +++ b/tests/unit/test_time_api.py @@ -0,0 +1,71 @@ +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) + + +@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) 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"] 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) diff --git a/tests/unit/test_wndb_connection.py b/tests/unit/test_wndb_connection.py new file mode 100644 index 0000000..b8fc6f7 --- /dev/null +++ b/tests/unit/test_wndb_connection.py @@ -0,0 +1,121 @@ +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, connection): + self.connection = connection + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, 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.connection.rows + + +class _FakeConnection: + 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) + + 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_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"}]) + 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 + 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"] 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,), + ) + ] 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`