Author SHA1 Message Date
jiang 18253f2fe0 docs: 编写客户版交付文档 2026-07-22 11:26:06 +08:00
jiang 02a8686222 Stop baking .env into Docker images 2026-07-21 11:42:17 +08:00
jiang 50d823ca58 fix(db): validate cached project connections 2026-07-21 11:35:18 +08:00
jiang 9f225374de fix(server): remove influxdb remnants 2026-07-17 18:33:38 +08:00
jiang 9b16e4e0a5 fix(simulation): use current user for stored schemes 2026-07-17 16:49:20 +08:00
jiang 810a39a1dc build(server): update customer compile image 2026-07-17 16:38:11 +08:00
jiang e49d21cd1b fix(scada): use project-scoped metadata 2026-07-17 16:28:46 +08:00
jiang 0a2ce81753 fix(leakage): accept display flow units 2026-07-17 11:49:00 +08:00
jiang b8f2f70013 fix(simulation): use report step for schemes 2026-07-16 15:47:26 +08:00
jiang 6f9c94a4dd fix(api): include simulation burst ids 2026-07-16 14:50:22 +08:00
jiang 9f7f5536d7 fix(simulation): use hydraulic timestep 2026-07-16 14:16:06 +08:00
jiang d33619d0d5 fix(burst-location): correct normal data window 2026-07-09 11:51:40 +08:00
jiang 6a16ea44b2 fix(burst-location): tolerate partial SCADA gaps 2026-07-09 10:49:53 +08:00
jiang e862e6c500 ci: unzip health model in image build 2026-07-08 19:02:54 +08:00
jiang 87824f3b3c fix(burst-location): use correct data sources
Simulation mode reads scheme data for both burst and normal observations. Monitoring mode reuses the burst window when no normal window is provided.
2026-07-08 17:51:08 +08:00
jiang 18943314f8 fix(burst-location): normalize simulation ids 2026-07-08 17:17:41 +08:00
jiang c7947a7481 feat(auth)!: migrate customer metadata auth
Remove local auth and user-management endpoints in favor of Keycloak-backed metadata users, project context, admin metadata APIs, and agent auth context.
2026-06-13 15:00:58 +08:00
jiang 4b02118286 refactor(metadata): drop geoserver config refs 2026-06-13 14:58:53 +08:00
jiang dc4e3de85e refactor(api): add kebab-case legacy aliases 2026-06-13 13:07:16 +08:00
jiang bb5d339dbe 删除不再需要的构建和打包工作流文件 2026-06-12 15:28:49 +08:00
jiang 33615cdcfc fix(services): lighten package initialization 2026-06-11 11:43:56 +08:00
jiang d6dda51008 fix(sync): align customer backend behavior 2026-06-11 11:41:24 +08:00
jiang 60c8fc4948 fix(scada): align native device writes 2026-06-11 11:20:25 +08:00
jiang 3de620a2ae fix(db): refresh project connection caches 2026-06-11 11:19:20 +08:00
jiang 8e07d580af fix(region): align service area endpoints 2026-06-11 11:18:09 +08:00
jiang 9be0cd34cf fix(auth): stabilize audit and token timing 2026-06-11 11:17:11 +08:00
jiang f61be3685f fix(metadata): sync project metadata endpoints 2026-06-11 11:15:59 +08:00
jiang 78af7ecfb3 fix(native): sync region dma service area 2026-06-11 10:31:08 +08:00
jiang bf8e4fb040 fix(algorithms): sync pressure and sensor logic 2026-06-11 10:29:52 +08:00
jiang 67ba9c2ac6 fix(simulation): align epanet output handling 2026-06-11 10:29:04 +08:00
jiang 21929b44fc fix(timeseries): align timezone and scada updates 2026-06-11 10:27:51 +08:00
jiang 24169bd277 fix(docker): align customer image build 2026-06-11 10:26:36 +08:00
jiang a787327ca2 feat(api): add web search and geocoding 2026-06-11 10:25:45 +08:00
jiang c676b55b70 fix(api): disable legacy unversioned routes 2026-06-11 10:24:35 +08:00
jiang 322e8156ee docs(delivery): document customer source packaging 2026-06-11 10:24:05 +08:00
jiang 1ec82971ca 强制使用 utf-8 存取 2026-03-16 15:49:49 +08:00
jiang 2d3fd353b4 添加清理 pycache 和编译扩展的功能 2026-03-16 15:06:27 +08:00
jiang 4191f2e508 添加对单个python文件的编译支持 2026-03-14 14:45:53 +08:00
jiang 3fd152e033 更新引用路径 2026-03-14 14:28:59 +08:00
jiang f6361a9eca 移除中文注释,避免 Github Action 工作流出错 2026-03-13 17:35:09 +08:00
jiang 431cf38aaf 优化打包流程,增加编译路径和忽略规则 2026-03-13 17:19:43 +08:00
jiang 220e641c0e 更新构建工作流,删除不必要的安全脚本 2026-03-13 17:14:50 +08:00
jiang ccfc78bb1c 添加构建和打包工作流 2026-03-13 16:53:18 +08:00
jiang f90c5ab6bb 更新封装路径 2026-03-13 16:46:17 +08:00
jiang 67648587c5 删除旧文件 2026-03-13 16:40:18 +08:00
jiang 42cac53319 更改编译代码的文件名 2026-03-13 16:40:02 +08:00
jiang c5f97da5c0 更新metadb引用路径 2026-03-13 16:22:13 +08:00
jiang bf088f691b 重构时序数据库连接逻辑,移除冗余代码 2026-03-13 16:18:11 +08:00
jiang ceea6e7456 元数据库目录结构变更 2026-03-13 16:15:09 +08:00
jiang 8338e11837 移除未使用的区域相关函数导入 2026-03-13 15:23:30 +08:00
jiang 59e44eb1e8 更新API请求体,移除不必要的请求参数 2026-03-13 15:23:02 +08:00
jiang cbad19e3bb 优化API文档,添加参数描述和示例 2026-03-13 15:17:06 +08:00
jiang 41173d61c8 删除网络元素相关的API端点空文件 2026-03-13 11:58:26 +08:00
jiang b58f075344 移除旧的InternalQueries类,更新管道查询逻辑 2026-03-13 11:38:36 +08:00
jiang 90a9bb62c6 重构SCADA信息获取,移除旧的数据库接口 2026-03-13 11:25:48 +08:00
jiang a310522812 删除发版无关的文件 2026-03-13 10:12:08 +08:00
319 changed files with 4278 additions and 3724376 deletions
+19
View File
@@ -0,0 +1,19 @@
.git
.github
.gitea
__pycache__/
.pytest_cache/
.mypy_cache/
.venv/
venv/
build/
dist/
package/
temp/
data/
# db_inp/
inp/
.env
*.pyc
*.dump
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
+20 -8
View File
@@ -1,19 +1,16 @@
# TJWater Server 环境变量配置模板
# 复制此文件为 .env 并填写实际值
# 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,3 +45,18 @@ METADATA_DB_PASSWORD="password"
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256
KEYCLOAK_AUDIENCE="account"
# ============================================
# Bocha Web Search API
# ============================================
BOCHA_API_KEY="sk-your-bocha-api-key"
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
# ============================================
# Tianditu Geocoding API
# ============================================
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
-82
View File
@@ -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.
-128
View File
@@ -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
-1
View File
@@ -7,4 +7,3 @@ build/
*.dump
.vscode/
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
inp/
+73
View File
@@ -0,0 +1,73 @@
# Repository Guidelines
## Project Purpose
This repository is the customer-delivery edition of the TJWater backend. Treat it as a deployable delivery package, not as the primary internal development repository. Changes should be limited to customer-facing fixes, deployment compatibility, configuration templates, packaging, and delivery documentation.
Do not introduce internal-only experiments, debug utilities, local data, or source material that is not required for customer operation.
## Source Encapsulation Requirement
Core backend source code must be encapsulated before delivery. The sensitive implementation areas include business services, native integrations, hydraulic/network algorithms, and EPANET-related logic, especially:
- `app/services`
- `app/native`
- `app/algorithms`
- `app/infra/epanet`
Use the existing Cython packaging flow in `scripts/compile.py` for these areas. Do not ship uncompiled core `.py` files in the final customer package when compiled extension modules are expected. Keep public entry points, configuration loading, route wiring, and minimal package files readable only where required for runtime and operations.
Before removing source files with `--delete-source`, verify the build from a clean working tree or disposable copy. The delete mode is destructive by design.
## Workspace Structure
- `app/main.py` is the FastAPI entry point.
- `app/api` contains HTTP route handlers.
- `app/services` contains core business orchestration and must be protected in delivery builds.
- `app/native`, `app/algorithms`, and `app/infra/epanet` contain specialized computation and integration code that must be protected in delivery builds.
- `infra/` and `Dockerfile` contain deployment assets.
- `scripts/` contains operational and packaging helpers.
- `tests/` contains backend tests.
## Common Commands
Run commands from this repository root:
```bash
conda run -n server python -m pytest tests -q
conda run -n server python scripts/run_server.py
conda run -n server python scripts/compile.py
```
Clean compiled extensions when needed:
```bash
conda run -n server python scripts/compile.py --clean
```
Only use source deletion in a prepared delivery copy:
```bash
conda run -n server python scripts/compile.py --delete-source
```
## Change Policy
Keep changes scoped and conservative. Prefer compatibility patches, configuration adjustments, packaging fixes, and customer deployment hardening. If a change requires substantial business logic updates, make it first in the internal backend repository and then port the reviewed result here.
Do not weaken the encapsulation process to make debugging easier. If debugging requires readable source, do it in the internal repository or a non-delivery branch/copy.
## Testing Guidelines
Run the narrowest useful test command for the affected area. For delivery packaging changes, verify both:
- tests still pass before packaging;
- the packaged/compiled runtime can import and start the FastAPI app.
Do not add tests that depend on untracked customer data, local database dumps, or machine-specific files.
## Security & Delivery Rules
Never commit `.env`, production credentials, customer data, logs, generated caches, `node_modules/`, database dumps, or temporary delivery archives. Use `.env.example` or deployment documentation for required configuration.
Review Docker and deployment files carefully before delivery. Customer packages should contain only the files needed to run, operate, and diagnose the deployed service.
+90
View File
@@ -0,0 +1,90 @@
# TJWater Authentication and Metadata Management
## Ownership
Keycloak owns login identity, credentials, token issuance, and token expiry.
TJWater metadata stores only business snapshots and authorization data:
- `users.keycloak_id` is the stable identity binding.
- `users.username`, `users.email`, and `users.last_login_at` are Keycloak claim caches.
- `users.role`, `users.is_active`, and `users.is_superuser` control TJWater system access.
- `user_project_membership.project_role` controls project access.
The backend does not accept passwords, does not issue local JWTs, and does not
trust frontend-supplied user IDs.
## Login Snapshot Refresh
Every authenticated metadata-user resolution validates the Keycloak access token
and reads `sub`, `preferred_username` or `username`, and `email` claims. The
backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users,
then refreshes `username`, `email`, and `last_login_at`.
This keeps local display data current without changing the identity binding.
There is no Keycloak webhook requirement; second-level user or permission sync is
out of scope unless explicitly requested later.
## Admin APIs
All admin APIs require metadata admin access: `users.is_superuser = true` or
`users.role = 'admin'`.
User and membership management:
- `GET /api/v1/admin/me`
- `POST /api/v1/admin/users/sync`
- `POST /api/v1/admin/users/sync/batch`
- `GET /api/v1/admin/users`
- `GET /api/v1/admin/users/{user_id}`
- `PATCH /api/v1/admin/users/{user_id}`
- `GET /api/v1/admin/projects/{project_id}/members`
- `POST /api/v1/admin/projects/{project_id}/members`
- `PATCH /api/v1/admin/projects/{project_id}/members/{user_id}`
- `DELETE /api/v1/admin/projects/{project_id}/members/{user_id}`
Project configuration:
- `GET /api/v1/admin/projects`
- `POST /api/v1/admin/projects`
- `PATCH /api/v1/admin/projects/{project_id}`
- `GET /api/v1/admin/projects/{project_id}/databases`
- `PUT /api/v1/admin/projects/{project_id}/databases`
- `DELETE /api/v1/admin/projects/{project_id}/databases/{db_role}`
- `POST /api/v1/admin/projects/{project_id}/databases/{db_role}/health`
## 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`
`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.
## Frontend System Management
`/system-admin` is shown only after `GET /api/v1/admin/me` confirms metadata
admin access. The page lets admins maintain metadata users, project members,
projects, project database routing for `biz_data` and `iot_data`, connection
health checks. This replaces direct SQL editing for normal project onboarding.
+80
View File
@@ -0,0 +1,80 @@
# Backend Naming Audit
DOC-003 audit for the customer-delivery `TJWaterServerCustomer` 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:
- Audit: `/api/v1/audit/logs`, `/api/v1/audit/logs/count`
- Metadata: `/api/v1/meta/project`, `/api/v1/meta/projects`, `/api/v1/meta/db/health`
- 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 `{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 `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`
## Internal vs Customer Difference
The customer backend retains local auth/user-management routes under `/api/v1/auth` and `/api/v1/users`; the internal backend has migrated to Keycloak/metadata admin routes. Treat those Customer-only auth routes as delivery compatibility surface, not a source for new internal API naming.
## 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.
+102
View File
@@ -0,0 +1,102 @@
# tjwater-server Customer Image Packaging Notes
This repository is the customer-delivery backend package. Build delivery images from this repository root.
## Image Build
Build the customer backend image:
```bash
docker build -t tjwater-server:latest .
```
The `Dockerfile` already performs source encapsulation in the builder stage:
```bash
python scripts/compile.py
python scripts/compile.py --delete-source
```
The compiled and source-deleted `app/` directory is then copied into the final runner stage. The source deletion happens inside the Docker builder layer only; it does not delete local workspace files.
## Encapsulation Scope
`scripts/compile.py` defaults to compiling these sensitive areas:
- `app/services`
- `app/native/wndb`
- `app/algorithms`
- `app/infra/epanet/epanet.py`
These areas should not contain uncompiled `.py` source files in the delivered image. Public entry points, API route wiring, schemas, configuration, and operational files may remain readable when required for runtime.
## Verification
After building, verify the image tag:
```bash
docker image ls tjwater-server
```
Verify that core source files were removed and compiled extensions exist:
```bash
docker run --rm --entrypoint sh tjwater-server:latest -c "\
printf 'core_py_count='; \
find /app/app/services /app/app/native/wndb /app/app/algorithms /app/app/infra/epanet/epanet.py -name '*.py' 2>/dev/null | wc -l; \
printf 'core_so_count='; \
find /app/app/services /app/app/native/wndb /app/app/algorithms /app/app/infra/epanet -name '*.so' 2>/dev/null | wc -l; \
python -c 'import app.main; print(\"import_app_main=ok\")'"
```
Expected delivery result:
```text
core_py_count=0
core_so_count=<non-zero>
import_app_main=ok
```
Warnings from third-party packages during import are not necessarily build failures. Treat non-zero exit codes, failed imports, or leftover core `.py` files as blockers.
## Export For Windows Delivery
Export the image tarball to the Windows desktop from WSL:
```bash
docker save -o /mnt/c/Users/admin/Desktop/tjwater-server-latest.tar tjwater-server:latest
```
Adjust the Windows username if needed. To find available desktop paths:
```bash
find /mnt/c/Users -maxdepth 2 -type d \( -name Desktop -o -name 桌面 \) 2>/dev/null
```
On the target machine, import the image with:
```bash
docker load -i tjwater-server-latest.tar
```
## Deployment Caution
The development `infra/docker/docker-compose.yml` bind-mounts local source:
```yaml
volumes:
- ../../app:/app/app
- ../../resources:/app/resources
```
Do not use that source mount for customer delivery of the encapsulated image. Mounting `../../app` over `/app/app` replaces the compiled code inside the image with local source files and defeats the encapsulation. For delivery compose files, use the built image directly and mount only required runtime data/config paths.
## Local Safety
Do not run this destructive command in the normal working tree:
```bash
python scripts/compile.py --delete-source
```
Only use `--delete-source` in Docker builder stages or in a disposable delivery copy. The normal delivery image build already handles this safely.
+24 -5
View File
@@ -1,20 +1,39 @@
FROM condaforge/miniforge3:latest
FROM condaforge/miniforge3:latest AS runtime-base
WORKDIR /app
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
RUN mamba install -y python=3.12 pymetis && \
mamba clean -afy
COPY requirements.txt .
RUN pip install uv
RUN pip install --no-cache-dir uv
RUN uv pip install --system --no-cache-dir -r requirements.txt
FROM runtime-base AS builder
RUN mamba install -y c-compiler cxx-compiler && \
mamba clean -afy
COPY app ./app
COPY scripts ./scripts
RUN python scripts/compile.py && \
python scripts/compile.py --delete-source
FROM runtime-base AS runner
# 将代码放入子目录 'app',将数据放入子目录 'db_inp'
# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离
COPY app ./app
COPY db_inp ./db_inp
COPY .env .
COPY --from=builder /app/app ./app
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip
# COPY db_inp ./db_inp
RUN mkdir -p db_inp temp data inp
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
ENV PYTHONPATH=/app
+107
View File
@@ -0,0 +1,107 @@
# TJWaterServerCustomer 客户版后端
`TJWaterServerCustomer` 是 TJWater 客户交付版 Python 后端。该仓库应被视为可部署交付包,只保留客户运行、配置、部署、诊断和交付说明所需内容。
## 技术栈
- 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/ 本地管网数据读写与转换,交付镜像中必须封装
scripts/compile.py Cython 封装脚本
infra/docker/ Docker Compose 编排
tests/ 后端测试
```
## 本地开发
推荐使用已有 conda 环境:
```bash
conda run -n server python -m pytest tests -q
conda run -n server python scripts/run_server.py
```
本地调试不要在正常工作树执行源码删除命令。
## 客户版镜像打包
交付镜像标签通常为:
```bash
docker build -t tjwater-server:latest .
```
`Dockerfile` 会在 builder 阶段执行:
```bash
python scripts/compile.py
python scripts/compile.py --delete-source
```
源码删除只发生在 Docker 构建层内,不会删除本地工作树源码。详细封装、验证和 Windows 导出说明见:
```text
DELIVERY_PACKAGING_NOTES.md
```
## 封装范围
`scripts/compile.py` 默认封装:
- `app/services`
- `app/native/wndb`
- `app/algorithms`
- `app/infra/epanet/epanet.py`
交付镜像中这些核心目录不应残留未编译的 `.py` 源码,应以 `.so` 扩展模块运行。
## 验证命令
构建后检查镜像:
```bash
docker image ls tjwater-server
```
检查核心源码是否已删除、FastAPI 入口是否可导入:
```bash
docker run --rm --entrypoint sh tjwater-server:latest -c "\
printf 'core_py_count='; \
find /app/app/services /app/app/native/wndb /app/app/algorithms /app/app/infra/epanet/epanet.py -name '*.py' 2>/dev/null | wc -l; \
printf 'core_so_count='; \
find /app/app/services /app/app/native/wndb /app/app/algorithms /app/app/infra/epanet -name '*.so' 2>/dev/null | wc -l; \
python -c 'import app.main; print(\"import_app_main=ok\")'"
```
期望结果:
```text
core_py_count=0
core_so_count=<非零>
import_app_main=ok
```
## 部署注意
客户交付时不要把本地 `../../app` 挂载到容器 `/app/app`,否则会覆盖镜像内已封装代码。交付 compose 文件应使用构建好的镜像,只挂载必要的运行数据和配置。
## 安全规则
不要提交 `.env`、生产凭据、客户数据、数据库 dump、日志、生成缓存、临时交付压缩包或本地运行目录。客户版仓库不应加入内部实验、调试工具或非交付源码材料。
+44 -35
View File
@@ -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__})
@@ -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
+8 -6
View File
@@ -122,12 +122,14 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
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,
}
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:
+3 -12
View File
@@ -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
+15 -3
View File
@@ -72,6 +72,7 @@ def burst_analysis(
modify_variable_pump_pattern: dict[str, list] = None,
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
) -> None:
"""
爆管模拟
@@ -86,6 +87,9 @@ def burst_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
"burst_ID": burst_ID,
"burst_size": burst_size,
@@ -211,7 +215,7 @@ def burst_analysis(
name=name,
scheme_name=scheme_name,
scheme_type="burst_analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
@@ -311,6 +315,7 @@ def flushing_analysis(
drainage_node_ID: str = None,
flushing_flow: float = 0,
scheme_name: str = None,
username: str | None = None,
) -> None:
"""
管道冲洗模拟
@@ -323,6 +328,9 @@ def flushing_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing flushing analysis scheme")
scheme_detail: dict = {
"duration": modify_total_duration,
"valve_opening": modify_valve_opening,
@@ -455,7 +463,7 @@ def flushing_analysis(
name=name,
scheme_name=scheme_name,
scheme_type="flushing_analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
@@ -473,6 +481,7 @@ def contaminant_simulation(
concentration: float, # 污染源浓度,单位mg/L
scheme_name: str = None,
source_pattern: str = None, # 污染源时间变化模式名称
username: str | None = None,
) -> None:
"""
污染模拟
@@ -486,6 +495,9 @@ def contaminant_simulation(
:param scheme_name: 方案名称
:return:
"""
if not username:
raise ValueError("username is required when storing contaminant analysis scheme")
scheme_detail: dict = {
"source": source,
"concentration": concentration,
@@ -608,7 +620,7 @@ def contaminant_simulation(
name=name,
scheme_name=scheme_name,
scheme_type="contaminant_analysis",
username="admin",
username=username,
scheme_start_time=modify_pattern_start_time,
scheme_detail=scheme_detail,
)
+695
View File
@@ -0,0 +1,695 @@
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("/me", response_model=MetadataUserResponse)
async def get_metadata_admin_me(
current_user=Depends(get_current_metadata_admin),
) -> MetadataUserResponse:
return MetadataUserResponse.model_validate(current_user)
@router.post("/users/sync", response_model=MetadataUserResponse)
async def sync_metadata_user(
payload: MetadataUserSyncRequest,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> MetadataUserResponse:
try:
return await _upsert_and_audit_metadata_user(
payload,
current_user=current_user,
metadata_repo=metadata_repo,
response_status=status.HTTP_200_OK,
)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User keycloak_id, username, or email conflicts with an existing user",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
@router.post("/users/sync/batch", response_model=List[MetadataUserSyncResult])
async def sync_metadata_users_batch(
payload: MetadataUsersBatchSyncRequest,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[MetadataUserSyncResult]:
results: list[MetadataUserSyncResult] = []
for item in payload.users:
try:
user = await _upsert_and_audit_metadata_user(
item,
current_user=current_user,
metadata_repo=metadata_repo,
response_status=status.HTTP_200_OK,
)
except IntegrityError as exc:
results.append(
MetadataUserSyncResult(
keycloak_id=item.keycloak_id,
success=False,
error="User keycloak_id, username, or email conflicts with an existing user",
)
)
await metadata_repo.session.rollback()
except SQLAlchemyError as exc:
results.append(
MetadataUserSyncResult(
keycloak_id=item.keycloak_id,
success=False,
error=f"Metadata database error: {exc}",
)
)
await metadata_repo.session.rollback()
else:
results.append(
MetadataUserSyncResult(
keycloak_id=item.keycloak_id,
success=True,
user=user,
)
)
return results
@router.get("/users", response_model=List[MetadataUserResponse])
async def list_metadata_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[MetadataUserResponse]:
users = await metadata_repo.list_users(skip=skip, limit=limit)
return [MetadataUserResponse.model_validate(user) for user in users]
@router.get("/projects", response_model=List[AdminProjectResponse])
async def list_admin_projects(
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[AdminProjectResponse]:
projects = await metadata_repo.list_project_records()
return [_project_response(project) for project in projects]
@router.post(
"/projects",
response_model=AdminProjectResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_admin_project(
payload: AdminProjectCreateRequest,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AdminProjectResponse:
try:
project = await metadata_repo.create_project(
name=payload.name,
code=payload.code,
description=payload.description,
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
status=payload.status,
)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project code or workspace conflicts with an existing project",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
await log_audit_event(
action=AuditAction.CREATE,
user_id=current_user.id,
project_id=project.id,
resource_type="project",
resource_id=str(project.id),
request_data=payload.model_dump(mode="json"),
response_status=status.HTTP_201_CREATED,
session=metadata_repo.session,
)
return _project_response(project)
@router.patch(
"/projects/{project_id}",
response_model=AdminProjectResponse,
)
async def update_admin_project(
payload: AdminProjectUpdateRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AdminProjectResponse:
updates = payload.model_dump(mode="json", exclude_unset=True)
try:
project = await metadata_repo.update_project(project_id, updates=updates)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project code or workspace conflicts with an existing project",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
await log_audit_event(
action=AuditAction.UPDATE,
user_id=current_user.id,
project_id=project.id,
resource_type="project",
resource_id=str(project.id),
request_data=updates,
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return _project_response(project)
@router.get(
"/projects/{project_id}/databases",
response_model=List[ProjectDatabaseResponse],
)
async def list_project_databases(
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[ProjectDatabaseResponse]:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
records = await metadata_repo.list_project_databases(project_id)
return [_project_database_response(record) for record in records]
@router.put(
"/projects/{project_id}/databases",
response_model=ProjectDatabaseResponse,
)
async def upsert_project_database(
payload: ProjectDatabaseUpsertRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectDatabaseResponse:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
try:
routing = (
ProjectDbRouting(
project_id=project_id,
db_role=payload.db_role,
db_type=_db_type_for_role(payload.db_role),
dsn=payload.dsn,
pool_min_size=payload.pool_min_size,
pool_max_size=payload.pool_max_size,
)
if payload.dsn
else await metadata_repo.get_project_db_routing(project_id, payload.db_role)
)
if routing is None:
raise ValueError("dsn is required when creating project database config")
await _check_database_connection(routing)
except ValueError as exc:
raise HTTPException(
status_code=_status_for_config_value_error(exc),
detail=str(exc),
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_database_health_error_detail(exc),
) from exc
try:
record = await metadata_repo.upsert_project_database_config(
project_id,
db_role=payload.db_role,
db_type=_db_type_for_role(payload.db_role),
dsn=payload.dsn,
pool_min_size=payload.pool_min_size,
pool_max_size=payload.pool_max_size,
)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project database role conflicts with an existing config",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
await log_audit_event(
action=AuditAction.CONFIG_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_database",
resource_id=payload.db_role,
request_data=_database_audit_payload(payload),
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return _project_database_response(record)
@router.delete(
"/projects/{project_id}/databases/{db_role}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_project_database(
project_id: UUID = Path(...),
db_role: ProjectDbRole = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> None:
removed = await metadata_repo.delete_project_database_config(project_id, db_role)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project database config not found",
)
await log_audit_event(
action=AuditAction.CONFIG_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_database",
resource_id=db_role,
request_data={"deleted": True},
response_status=status.HTTP_204_NO_CONTENT,
session=metadata_repo.session,
)
@router.post(
"/projects/{project_id}/databases/{db_role}/health",
response_model=ProjectDatabaseHealthResponse,
)
async def check_project_database_health(
response: Response,
project_id: UUID = Path(...),
db_role: ProjectDbRole = Path(...),
payload: ProjectDatabaseHealthRequest | None = None,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectDatabaseHealthResponse:
dsn_to_test = payload.dsn if payload and payload.dsn else None
if dsn_to_test:
routing = ProjectDbRouting(
project_id=project_id,
db_role=db_role,
db_type=_db_type_for_role(db_role),
dsn=dsn_to_test,
pool_min_size=1,
pool_max_size=1,
)
else:
try:
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project database routing DSN is invalid: {exc}",
) from exc
if routing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project database config not found",
)
try:
await _check_database_connection(routing)
except Exception as exc: # health endpoint should return diagnostic status
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return ProjectDatabaseHealthResponse(
project_id=project_id,
db_role=db_role,
db_type=routing.db_type,
ok=False,
detail=_database_health_error_detail(exc),
)
return ProjectDatabaseHealthResponse(
project_id=project_id,
db_role=db_role,
db_type=routing.db_type,
ok=True,
detail="连通性测试通过",
)
@router.get("/users/{user_id}", response_model=MetadataUserResponse)
async def get_metadata_user(
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> MetadataUserResponse:
user = await metadata_repo.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return MetadataUserResponse.model_validate(user)
@router.patch("/users/{user_id}", response_model=MetadataUserResponse)
async def update_metadata_user(
payload: MetadataUserUpdateRequest,
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> MetadataUserResponse:
updates = payload.model_dump(mode="json", exclude_unset=True)
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify themselves",
)
user = await metadata_repo.update_user_admin(
user_id,
updates=updates,
)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await log_audit_event(
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_type="metadata_user",
resource_id=str(user.id),
request_data=updates,
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return MetadataUserResponse.model_validate(user)
@router.get(
"/projects/{project_id}/members",
response_model=List[ProjectMemberResponse],
)
async def list_project_members(
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[ProjectMemberResponse]:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
members = await metadata_repo.list_project_members(project_id)
return [ProjectMemberResponse(**member.__dict__) for member in members]
@router.post(
"/projects/{project_id}/members",
response_model=ProjectMemberResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_project_member(
payload: ProjectMemberCreateRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectMemberResponse:
if payload.user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify their own project membership",
)
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
user = await metadata_repo.get_user_by_id(payload.user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
existing = await metadata_repo.get_project_membership(project_id, payload.user_id)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User is already a project member",
)
membership = await metadata_repo.add_project_member(
project_id, payload.user_id, payload.project_role
)
await log_audit_event(
action=AuditAction.PERMISSION_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_member",
resource_id=str(payload.user_id),
request_data=payload.model_dump(mode="json"),
response_status=status.HTTP_201_CREATED,
session=metadata_repo.session,
)
return ProjectMemberResponse(
id=membership.id,
user_id=membership.user_id,
project_id=membership.project_id,
project_role=membership.project_role,
username=user.username,
email=user.email,
is_active=user.is_active,
)
@router.patch(
"/projects/{project_id}/members/{user_id}",
response_model=ProjectMemberResponse,
)
async def update_project_member(
payload: ProjectMemberUpdateRequest,
project_id: UUID = Path(...),
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectMemberResponse:
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify their own project membership",
)
user = await metadata_repo.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
membership = await metadata_repo.update_project_member_role(
project_id, user_id, payload.project_role
)
if membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
)
await log_audit_event(
action=AuditAction.PERMISSION_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_member",
resource_id=str(user_id),
request_data=payload.model_dump(mode="json"),
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return ProjectMemberResponse(
id=membership.id,
user_id=membership.user_id,
project_id=membership.project_id,
project_role=membership.project_role,
username=user.username,
email=user.email,
is_active=user.is_active,
)
@router.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_project_member(
project_id: UUID = Path(...),
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> None:
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify their own project membership",
)
removed = await metadata_repo.remove_project_member(project_id, user_id)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
)
await log_audit_event(
action=AuditAction.PERMISSION_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_member",
resource_id=str(user_id),
response_status=status.HTTP_204_NO_CONTENT,
session=metadata_repo.session,
)
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.auth.keycloak_dependencies import get_current_keycloak_payload
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
)
router = APIRouter()
class AgentAuthContextResponse(BaseModel):
user_id: str
keycloak_sub: str
username: str
role: str
is_superuser: bool
project_id: str
network: str
project_role: str
token_expires_at: str | None = None
@router.get("/agent/auth/context", response_model=AgentAuthContextResponse)
async def get_agent_auth_context(
ctx: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
keycloak_payload: dict = Depends(get_current_keycloak_payload),
) -> AgentAuthContextResponse:
exp = keycloak_payload.get("exp")
token_expires_at = (
datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
if isinstance(exp, int)
else None
)
return AgentAuthContextResponse(
user_id=str(current_user.id),
keycloak_sub=str(current_user.keycloak_id),
username=current_user.username,
role=current_user.role,
is_superuser=current_user.is_superuser,
project_id=str(ctx.project_id),
network=ctx.project_code,
project_role=ctx.project_role,
token_expires_at=token_expires_at,
)
-190
View File
@@ -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,
)
+4 -2
View File
@@ -29,8 +29,10 @@ class BurstLocationRequest(BaseModel):
normal_flow: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="正常时的流量数据")
min_dpressure: float = Field(2.0, description="最小压力差(bar")
basic_pressure: float = Field(10.0, description="基准压力(bar")
scada_burst_start: datetime | None = Field(None, description="SCADA爆管开始时间")
scada_burst_end: datetime | None = Field(None, description="SCADA爆管结束时间")
scada_burst_start: datetime | None = Field(None, description="爆管/模拟方案开始时间")
scada_burst_end: datetime | None = Field(None, description="爆管/模拟方案结束时间")
scada_normal_start: datetime | None = Field(None, description="监测数据正常工况开始时间")
scada_normal_end: datetime | None = Field(None, description="监测数据正常工况结束时间")
use_scada_flow: bool = Field(False, description="是否使用SCADA流量数据")
scheme_name: str | None = Field(None, description="方案名称")
simulation_scheme_name: str | None = Field(None, description="模拟方案名称")
+29
View File
@@ -0,0 +1,29 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.geocoding import (
TiandituGeocodeRequest,
TiandituGeocodingAPIError,
TiandituGeocodingConfigError,
geocode_tianditu,
)
router = APIRouter()
@router.post(
"/tianditu/geocode",
summary="Tianditu Geocoding",
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
)
async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]:
try:
return await geocode_tianditu(request)
except TiandituGeocodingConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except TiandituGeocodingAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+1 -15
View File
@@ -16,7 +16,6 @@ from app.auth.project_dependencies import (
from app.auth.metadata_dependencies import get_current_metadata_user
from app.core.config import settings
from app.domain.schemas.metadata import (
GeoServerConfigResponse,
ProjectMetaResponse,
ProjectSummaryResponse,
)
@@ -34,25 +33,13 @@ async def get_project_metadata(
"""
获取项目元数据
返回当前项目的完整元数据,包括项目基本信息和GeoServer配置
返回当前项目的完整元数据
"""
project = await metadata_repo.get_project_by_id(ctx.project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
geoserver = await metadata_repo.get_geoserver_config(ctx.project_id)
geoserver_payload = (
GeoServerConfigResponse(
gs_base_url=geoserver.gs_base_url,
gs_admin_user=geoserver.gs_admin_user,
gs_datastore_name=geoserver.gs_datastore_name,
default_extent=geoserver.default_extent,
srid=geoserver.srid,
)
if geoserver
else None
)
return ProjectMetaResponse(
project_id=project.id,
name=project.name,
@@ -62,7 +49,6 @@ async def get_project_metadata(
map_extent=project.map_extent,
status=project.status,
project_role=ctx.project_role,
geoserver=geoserver_payload,
)
+2 -1
View File
@@ -28,7 +28,8 @@ async def fastapi_get_json():
)
@router.get("/getallsensorplacements/", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息")
@router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息")
@router.get("/getallsensorplacements/", summary="获取所有传感器位置(旧路径)", description="获取网络中所有传感器的放置位置信息", deprecated=True)
async def fastapi_get_all_sensor_placements(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
"""
获取所有传感器位置
+2 -2
View File
@@ -10,7 +10,7 @@ from app.services.tjnetwork import (
get_network_node_coords,
get_node_coord,
)
from app.auth.dependencies import get_current_user as verify_token
from app.auth.metadata_dependencies import get_current_metadata_user
from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime
import msgpack
@@ -64,7 +64,7 @@ async def fastapi_get_network_in_extent(
@router.get(
"/getnetworkgeometries/",
dependencies=[Depends(verify_token)],
dependencies=[Depends(get_current_metadata_user)],
summary="获取完整网络几何信息",
description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)"
)
+7 -16
View File
@@ -4,7 +4,7 @@ from fastapi.responses import PlainTextResponse
from typing import Any, Dict, List
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.auth.project_dependencies import get_metadata_repository
from app.domain.schemas.metadata import ProjectMetaResponse, GeoServerConfigResponse
from app.domain.schemas.metadata import ProjectMetaResponse
import app.services.project_info as project_info
from app.infra.db.postgresql.database import get_database_instance as get_pg_db
from app.infra.db.timescaledb.database import get_database_instance as get_ts_db
@@ -42,29 +42,20 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere.
router = APIRouter()
lockedPrjs: Dict[str, str] = {}
@router.get("/project_info/", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
@router.get("/project-info", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
@router.get("/project_info/", summary="获取项目信息(旧路径)", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse, deprecated=True)
async def get_project_info_endpoint(
network: str = Query(..., description="管网名称(或项目代码)"),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
"""
获取项目信息
- **network**: 管网名称(或项目代码)
"""
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,
@@ -74,8 +65,7 @@ async def get_project_info_endpoint(
gs_workspace=project_detail.gs_workspace,
map_extent=project_detail.map_extent,
status=project_detail.status,
project_role="viewer", # Default role for public access
geoserver=geoserver_payload
project_role="viewer",
)
@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
@@ -133,7 +123,8 @@ async def is_project_open_endpoint(
"""
return is_project_open(network)
@router.post("/openproject/", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
@router.post("/projects/open", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
@router.post("/openproject/", summary="打开项目(旧路径)", description="将指定项目加载到内存中,并初始化数据库连接池。", deprecated=True)
async def open_project_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)")
):
+2 -5
View File
@@ -1,10 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from psycopg import AsyncConnection
import app.native.wndb as wndb
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.postgresql.scheme import SchemeRepository
from app.auth.project_dependencies import get_project_pg_connection
from app.services import project_info
router = APIRouter()
@@ -26,9 +25,7 @@ async def get_scada_info_with_connection(
返回项目中所有的SCADA设备信息
"""
try:
_ = conn
network_name = project_info.name
scada_data = wndb.get_all_scada_info(network_name) if network_name else []
scada_data = await ScadaInfoRepository.get_scadas(conn)
return {"success": True, "data": scada_data, "count": len(scada_data)}
except Exception as e:
raise HTTPException(
+2 -1
View File
@@ -22,7 +22,8 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称
"""
return get_scheme(network, schema_name)
@router.get("/getallschemes/", summary="获取所有方案", description="获取指定网络的所有方案信息")
@router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息")
@router.get("/getallschemes/", summary="获取所有方案(旧路径)", description="获取指定网络的所有方案信息", deprecated=True)
async def fastapi_get_all_schemes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
"""
获取所有方案列表
+34 -9
View File
@@ -4,8 +4,9 @@ import json
import os
import shutil
import threading
from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Body
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Query, Path, Body
from fastapi.responses import PlainTextResponse
from app.auth.keycloak_dependencies import get_current_keycloak_username
import app.services.simulation as simulation
import app.services.globals as globals
from app.services.tjnetwork import (
@@ -35,7 +36,11 @@ from app.services.simulation_ops import (
daily_scheduling_simulation,
)
from app.services.valve_isolation import analyze_valve_isolation
from app.services.time_api import parse_aware_time, parse_utc_time
from app.services.time_api import (
parse_aware_time,
parse_clock_duration_seconds,
parse_utc_time,
)
from pydantic import BaseModel, Field, field_validator
router = APIRouter()
@@ -118,6 +123,14 @@ def run_simulation_manually_by_date(
network_name: str, start_time: datetime, duration: int
) -> None:
end_datetime = start_time + timedelta(minutes=duration)
time_properties = simulation.get_time(network_name)
hydraulic_step_seconds = parse_clock_duration_seconds(
time_properties["HYDRAULIC TIMESTEP"],
field_name="HYDRAULIC TIMESTEP",
)
if hydraulic_step_seconds <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
current_time = start_time
while current_time < end_datetime:
simulation.run_simulation(
@@ -125,7 +138,7 @@ def run_simulation_manually_by_date(
simulation_type="realtime",
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
)
current_time += timedelta(minutes=15)
current_time += hydraulic_step
# 必须用这个PlainTextResponse,不然每个key都有引号
@@ -188,7 +201,8 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输
# Analysis Endpoints
@router.get("/burst_analysis/", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
@router.get("/burst-analysis", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
@router.get("/burst_analysis/", summary="爆管分析(高级,旧路径)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。", deprecated=True)
async def fastapi_burst_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
@@ -196,6 +210,7 @@ async def fastapi_burst_analysis(
burst_size: list[float] = Query(..., description="对应各爆管点的爆管流量大小列表(L/s)"),
modify_total_duration: int = Query(..., description="模拟总时长(秒)"),
scheme_name: str = Query(..., description="分析方案名称"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
爆管分析(高级版本)
@@ -216,6 +231,7 @@ async def fastapi_burst_analysis(
burst_size=burst_size,
modify_total_duration=modify_total_duration,
scheme_name=scheme_name,
username=username,
)
return "success"
@@ -249,7 +265,8 @@ async def fastapi_valve_close_analysis(
return result or "success"
@router.get("/valve_isolation_analysis/", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
@router.get("/valve-isolation-analysis", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
@router.get("/valve_isolation_analysis/", summary="阀门隔离分析(旧路径)", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。", deprecated=True)
async def valve_isolation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
@@ -289,7 +306,8 @@ async def valve_isolation_endpoint(
return result
@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
@router.get("/flushing-analysis", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级,旧路径)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", deprecated=True)
async def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
@@ -299,6 +317,7 @@ async def fastapi_flushing_analysis(
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
scheme_name: str = Query(..., description="冲洗方案名称"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
冲洗分析(高级版本)
@@ -325,11 +344,13 @@ async def fastapi_flushing_analysis(
drainage_node_ID=drainage_node_ID,
flushing_flow=flush_flow,
scheme_name=scheme_name,
username=username,
)
return result or "success"
@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
@router.get("/contaminant-simulation", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟(旧路径)", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。", deprecated=True)
async def fastapi_contaminant_simulation(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
@@ -338,6 +359,7 @@ async def fastapi_contaminant_simulation(
duration: int = Query(..., description="模拟持续时间(秒)"),
scheme_name: str = Query(..., description="模拟方案名称"),
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
username: str = Depends(get_current_keycloak_username),
) -> str:
"""
污染物模拟
@@ -360,6 +382,7 @@ async def fastapi_contaminant_simulation(
source=source,
concentration=concentration,
source_pattern=pattern,
username=username,
)
return result or "success"
@@ -715,7 +738,8 @@ async def fastapi_pressure_sensor_placement_kmeans(
)
@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。")
@router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。")
@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建(旧路径)", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。", deprecated=True)
async def fastapi_pressure_sensor_placement(
network: str = Query(..., description="管网名称(或数据库名称)"),
scheme_name: str = Query(..., description="放置方案名称"),
@@ -763,7 +787,8 @@ async def fastapi_pressure_sensor_placement(
return "success"
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
@router.post("/simulations/run-by-date", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟(旧路径)", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。", deprecated=True)
async def fastapi_run_simulation_manually_by_date(
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
) -> dict[str, str]:
-215
View File
@@ -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)
+29
View File
@@ -0,0 +1,29 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.web_search import (
BochaSearchAPIError,
BochaSearchConfigError,
WebSearchRequest,
search_bocha_web,
)
router = APIRouter()
@router.post(
"/web-search",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
try:
return await search_bocha_web(request)
except BochaSearchConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except BochaSearchAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+9 -7
View File
@@ -1,12 +1,12 @@
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth,
admin_metadata,
agent_auth,
project,
simulation,
scada,
extension,
snapshots,
# data_query,
users,
schemes,
misc,
@@ -15,9 +15,10 @@ from app.api.v1.endpoints import (
leakage,
burst_detection,
burst_location,
user_management, # 新增:用户管理
audit, # 新增:审计日志
meta,
web_search,
geocoding,
)
from app.api.v1.endpoints.network import (
general,
@@ -52,10 +53,10 @@ from app.api.v1.endpoints.timeseries import (
api_router = APIRouter()
# Core Services
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(
user_management.router, prefix="/users", tags=["User Management"]
) # 新增
admin_metadata.router, prefix="/admin", tags=["Metadata Admin"]
)
api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增
api_router.include_router(meta.router, tags=["Metadata"])
api_router.include_router(project.router, tags=["Project"])
@@ -85,7 +86,6 @@ api_router.include_router(visuals.router, tags=["Visuals"])
# Simulation & Data
api_router.include_router(simulation.router, tags=["Simulation Control"])
# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"])
api_router.include_router(scada.router)
api_router.include_router(snapshots.router, tags=["Snapshots"])
api_router.include_router(users.router, tags=["Users"])
@@ -93,6 +93,8 @@ api_router.include_router(schemes.router, tags=["Schemes"])
api_router.include_router(misc.router, tags=["Misc"])
api_router.include_router(risk.router, tags=["Risk"])
api_router.include_router(cache.router, tags=["Cache"])
api_router.include_router(web_search.router, tags=["Web Search"])
api_router.include_router(geocoding.router, tags=["Geocoding"])
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
api_router.include_router(
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
-100
View File
@@ -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
+26 -43
View File
@@ -8,35 +8,41 @@ from jose import JWTError, jwt
from app.core.config import settings
oauth2_optional = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login", auto_error=False
tokenUrl="keycloak", auto_error=False
)
# logger = logging.getLogger(__name__)
async def get_current_keycloak_sub(
def _decode_keycloak_token(token: str) -> dict:
if not settings.KEYCLOAK_PUBLIC_KEY:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Keycloak public key is not configured",
)
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
return jwt.decode(
token,
key,
algorithms=[settings.KEYCLOAK_ALGORITHM],
audience=settings.KEYCLOAK_AUDIENCE or None,
)
async def get_current_keycloak_payload(
token: str | None = Depends(oauth2_optional),
) -> UUID:
) -> dict:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if settings.KEYCLOAK_PUBLIC_KEY:
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
algorithms = [settings.KEYCLOAK_ALGORITHM]
else:
key = settings.SECRET_KEY
algorithms = [settings.ALGORITHM]
try:
payload = jwt.decode(
token,
key,
algorithms=algorithms,
audience=settings.KEYCLOAK_AUDIENCE or None,
)
return _decode_keycloak_token(token)
except JWTError as exc:
# logger.warning("Keycloak token validation failed: %s", exc)
raise HTTPException(
@@ -45,6 +51,10 @@ async def get_current_keycloak_sub(
headers={"WWW-Authenticate": "Bearer"},
) from exc
async def get_current_keycloak_sub(
payload: dict = Depends(get_current_keycloak_payload),
) -> UUID:
sub = payload.get("sub")
if not sub:
raise HTTPException(
@@ -64,35 +74,8 @@ async def get_current_keycloak_sub(
async def get_current_keycloak_username(
token: str | None = Depends(oauth2_optional),
payload: dict = Depends(get_current_keycloak_payload),
) -> str:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if settings.KEYCLOAK_PUBLIC_KEY:
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
algorithms = [settings.KEYCLOAK_ALGORITHM]
else:
key = settings.SECRET_KEY
algorithms = [settings.ALGORITHM]
try:
payload = jwt.decode(
token,
key,
algorithms=algorithms,
audience=settings.KEYCLOAK_AUDIENCE or None,
)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
) from exc
username = payload.get("preferred_username") or payload.get("username")
if not username:
raise HTTPException(
+47 -3
View File
@@ -6,8 +6,7 @@ from fastapi import Depends, HTTPException, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.keycloak_dependencies import get_current_keycloak_sub
from app.core.config import settings
from app.auth.keycloak_dependencies import get_current_keycloak_payload
from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
@@ -20,10 +19,40 @@ async def get_metadata_repository(
return MetadataRepository(session)
def _keycloak_sub_from_payload(payload: dict) -> UUID:
sub = payload.get("sub")
if not sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing subject claim",
headers={"WWW-Authenticate": "Bearer"},
)
try:
return UUID(str(sub))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid subject claim",
headers={"WWW-Authenticate": "Bearer"},
) from exc
def _username_from_payload(payload: dict) -> str | None:
username = payload.get("preferred_username") or payload.get("username")
return str(username) if username else None
def _email_from_payload(payload: dict) -> str | None:
email = payload.get("email")
return str(email) if email else None
async def get_current_metadata_user(
keycloak_sub: UUID = Depends(get_current_keycloak_sub),
keycloak_payload: dict = Depends(get_current_keycloak_payload),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
keycloak_sub = _keycloak_sub_from_payload(keycloak_payload)
try:
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
except SQLAlchemyError as exc:
@@ -39,6 +68,21 @@ async def get_current_metadata_user(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
try:
user = await metadata_repo.refresh_user_keycloak_snapshot(
user,
username=_username_from_payload(keycloak_payload),
email=_email_from_payload(keycloak_payload),
)
except SQLAlchemyError as exc:
logger.error(
"Metadata DB error while refreshing current user snapshot",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
return user
-106
View File
@@ -1,106 +0,0 @@
"""
权限控制依赖项和装饰器
基于角色的访问控制(RBAC
"""
from typing import Callable
from fastapi import Depends, HTTPException, status
from app.domain.models.role import UserRole
from app.domain.schemas.user import UserInDB
from app.auth.dependencies import get_current_active_user
def require_role(required_role: UserRole):
"""
要求特定角色或更高权限
用法:
@router.get("/admin-only")
async def admin_endpoint(user: UserInDB = Depends(require_role(UserRole.ADMIN))):
...
Args:
required_role: 需要的最低角色
Returns:
依赖函数
"""
async def role_checker(
current_user: UserInDB = Depends(get_current_active_user)
) -> UserInDB:
user_role = UserRole(current_user.role)
if not user_role.has_permission(required_role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required role: {required_role.value}, "
f"Your role: {user_role.value}"
)
return current_user
return role_checker
# 预定义的权限检查依赖
require_admin = require_role(UserRole.ADMIN)
require_operator = require_role(UserRole.OPERATOR)
require_user = require_role(UserRole.USER)
def get_current_admin(
current_user: UserInDB = Depends(require_admin)
) -> UserInDB:
"""
获取当前管理员用户
等同于 Depends(require_role(UserRole.ADMIN))
"""
return current_user
def get_current_operator(
current_user: UserInDB = Depends(require_operator)
) -> UserInDB:
"""
获取当前操作员用户(或更高权限)
等同于 Depends(require_role(UserRole.OPERATOR))
"""
return current_user
def check_resource_owner(user_id: int, current_user: UserInDB) -> bool:
"""
检查是否是资源拥有者或管理员
Args:
user_id: 资源拥有者ID
current_user: 当前用户
Returns:
是否有权限
"""
# 管理员可以访问所有资源
if UserRole(current_user.role).has_permission(UserRole.ADMIN):
return True
# 检查是否是资源拥有者
return current_user.id == user_id
def require_owner_or_admin(user_id: int):
"""
要求是资源拥有者或管理员
Args:
user_id: 资源拥有者ID
Returns:
依赖函数
"""
async def owner_or_admin_checker(
current_user: UserInDB = Depends(get_current_active_user)
) -> UserInDB:
if not check_resource_owner(user_id, current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this resource"
)
return current_user
return owner_or_admin_checker
+2
View File
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProjectContext:
project_id: UUID
project_code: str
user_id: UUID
project_role: str
@@ -85,6 +86,7 @@ async def get_project_context(
return ProjectContext(
project_id=project.id,
project_code=project.code,
user_id=user.id,
project_role=membership_role,
)
+13 -17
View File
@@ -11,17 +11,8 @@ class Settings(BaseSettings):
NETWORK_NAME: str = "default_network"
# JWT 配置
SECRET_KEY: str = (
"your-secret-key-here-change-in-production-use-openssl-rand-hex-32"
)
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# 数据加密密钥 (使用 Fernet)
ENCRYPTION_KEY: str = "" # 必须从环境变量设置
DATABASE_ENCRYPTION_KEY: str = "" # project_databases.dsn_encrypted 专用
# 敏感配置加密密钥 (Fernet)
DATABASE_ENCRYPTION_KEY: str = ""
# Database Config (PostgreSQL)
DB_NAME: str = "tjwater"
@@ -36,11 +27,6 @@ class Settings(BaseSettings):
TIMESCALEDB_DB_PORT: str = "5433"
TIMESCALEDB_DB_USER: str = "postgres"
TIMESCALEDB_DB_PASSWORD: str = "password"
# InfluxDB
INFLUXDB_URL: str = "http://localhost:8086"
INFLUXDB_TOKEN: str = "token"
INFLUXDB_ORG: str = "org"
INFLUXDB_BUCKET: str = "bucket"
# Metadata Database Config (PostgreSQL)
METADATA_DB_NAME: str = "system_hub"
@@ -59,11 +45,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 = ""
# 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:
db_password = quote_plus(self.DB_PASSWORD)
+3 -7
View File
@@ -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(
-95
View File
@@ -1,95 +0,0 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, Union, Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
创建 JWT Access Token
Args:
subject: 用户标识(通常是用户名或用户ID)
expires_delta: 过期时间增量
Returns:
JWT token 字符串
"""
if expires_delta:
expire = _utc_now() + expires_delta
else:
expire = _utc_now() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
"exp": expire,
"sub": str(subject),
"type": "access",
"iat": _utc_now(),
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def create_refresh_token(subject: Union[str, Any]) -> str:
"""
创建 JWT Refresh Token(长期有效)
Args:
subject: 用户标识
Returns:
JWT refresh token 字符串
"""
expire = _utc_now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
"exp": expire,
"sub": str(subject),
"type": "refresh",
"iat": _utc_now(),
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
验证密码
Args:
plain_password: 明文密码
hashed_password: 密码哈希
Returns:
是否匹配
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
生成密码哈希
Args:
password: 明文密码
Returns:
bcrypt 哈希字符串
"""
return pwd_context.hash(password)
+3 -1
View File
@@ -1,7 +1,9 @@
"""
This module is reserved for future implementation of advanced cryptographic operations.
Current basic encryption (Fernet) and password hashing are implemented in `app.core.encryption` and `app.core.security`.
Current Fernet encryption helpers are implemented in `app.core.encryption`.
Login credentials are owned by Keycloak; this backend does not hash or store
local passwords.
Future expansion may include:
- Asymmetric encryption (RSA/ECC) for secure communication
- Key management and rotation services
-36
View File
@@ -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]
+134
View File
@@ -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", "operator", "viewer"]
ProjectRole = Literal["owner", "admin", "member", "viewer"]
ProjectStatus = Literal["active", "inactive", "archived"]
ProjectDbRole = Literal["biz_data", "iot_data"]
class MetadataUserSyncRequest(BaseModel):
keycloak_id: UUID
username: str = Field(..., min_length=1, max_length=50)
email: str = Field(..., min_length=1, max_length=100)
role: BusinessRole = "user"
is_active: bool = True
class MetadataUsersBatchSyncRequest(BaseModel):
users: list[MetadataUserSyncRequest] = Field(..., min_length=1, max_length=500)
class MetadataUserUpdateRequest(BaseModel):
role: BusinessRole | None = None
is_active: bool | None = None
class MetadataUserResponse(BaseModel):
id: UUID
keycloak_id: UUID
username: str
email: str
role: str
is_active: bool
is_superuser: bool
created_at: datetime
updated_at: datetime
last_login_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class MetadataUserSyncResult(BaseModel):
keycloak_id: UUID
user: MetadataUserResponse | None = None
success: bool
error: str | None = None
class ProjectMemberCreateRequest(BaseModel):
user_id: UUID
project_role: ProjectRole = "viewer"
class ProjectMemberUpdateRequest(BaseModel):
project_role: ProjectRole
class ProjectMemberResponse(BaseModel):
id: UUID
user_id: UUID
project_id: UUID
project_role: str
username: str
email: str
is_active: bool
class AdminProjectCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
code: str = Field(..., min_length=1, max_length=50)
description: str | None = None
gs_workspace: str = Field(..., min_length=1, max_length=100)
map_extent: dict | None = None
status: ProjectStatus = "active"
class AdminProjectUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
code: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
gs_workspace: str | None = Field(default=None, min_length=1, max_length=100)
map_extent: dict | None = None
status: ProjectStatus | None = None
class AdminProjectResponse(BaseModel):
project_id: UUID
name: str
code: str
description: str | None = None
gs_workspace: str
map_extent: dict | None = None
status: str
created_at: datetime
updated_at: datetime
class ProjectDatabaseUpsertRequest(BaseModel):
db_role: ProjectDbRole
dsn: str | None = Field(default=None, min_length=1)
pool_min_size: int = Field(default=2, ge=1)
pool_max_size: int = Field(default=10, ge=1)
@model_validator(mode="after")
def validate_pool_bounds(self):
if self.pool_max_size < self.pool_min_size:
raise ValueError("pool_max_size must be greater than or equal to pool_min_size")
return self
class ProjectDatabaseResponse(BaseModel):
id: UUID
project_id: UUID
db_role: str
db_type: str
pool_min_size: int
pool_max_size: int
has_dsn: bool
class ProjectDatabaseHealthRequest(BaseModel):
dsn: str | None = Field(default=None, min_length=1)
class ProjectDatabaseHealthResponse(BaseModel):
project_id: UUID
db_role: str
db_type: str
ok: bool
detail: str
-9
View File
@@ -4,14 +4,6 @@ from uuid import UUID
from pydantic import BaseModel
class GeoServerConfigResponse(BaseModel):
gs_base_url: Optional[str] = None
gs_admin_user: Optional[str] = None
gs_datastore_name: str
default_extent: Optional[dict] = None
srid: int
class ProjectMetaResponse(BaseModel):
project_id: UUID
name: str
@@ -21,7 +13,6 @@ class ProjectMetaResponse(BaseModel):
map_extent: Optional[dict] = None
status: str
project_role: str
geoserver: Optional[GeoServerConfigResponse] = None
class ProjectSummaryResponse(BaseModel):
-68
View File
@@ -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")
+6 -14
View File
@@ -33,8 +33,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
# 需要审计的路径前缀
AUDIT_PATHS = [
# "/api/v1/auth/",
# "/api/v1/users/",
# "/api/v1/projects/",
# "/api/v1/networks/",
]
@@ -193,20 +191,14 @@ class AuditMiddleware(BaseHTTPMiddleware):
return None
sub = None
try:
key = (
settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
if settings.KEYCLOAK_PUBLIC_KEY
else settings.SECRET_KEY
)
algorithms = (
[settings.KEYCLOAK_ALGORITHM]
if settings.KEYCLOAK_PUBLIC_KEY
else [settings.ALGORITHM]
)
if not settings.KEYCLOAK_PUBLIC_KEY:
return None
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
payload = jwt.decode(
token,
key,
algorithms=algorithms,
algorithms=[settings.KEYCLOAK_ALGORITHM],
audience=settings.KEYCLOAK_AUDIENCE or None,
)
sub = payload.get("sub")
@@ -221,7 +213,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
keycloak_id = UUID(sub)
user = await repo.get_user_by_keycloak_id(keycloak_id)
except ValueError:
user = await repo.get_user_by_username(sub)
return None
if user and user.is_active:
return user.id
return None
+2 -2
View File
@@ -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,
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
# 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名称
-33
View File
@@ -1,33 +0,0 @@
from influxdb_client import InfluxDBClient, Point, WriteOptions
from influxdb_client.client.query_api import QueryApi
import influxdb_info
# 配置 InfluxDB 连接
url = influxdb_info.url
token = influxdb_info.token
org = influxdb_info.org
bucket = "SCADA_data"
# 创建 InfluxDB 客户端
client = InfluxDBClient(url=url, token=token, org=org)
# 创建查询 API 对象
query_api = client.query_api()
# 构建查询语句
query = f'''
from(bucket: "{bucket}")
|> range(start: -1h)
'''
# 执行查询
result = query_api.query(query)
print(result)
# 处理查询结果
for table in result:
for record in table.records:
print(f"Time: {record.get_time()}, Value: {record.get_value()}, Measurement: {record.get_measurement()}, Field: {record.get_field()}")
# 关闭客户端连接
client.close()
-20
View File
@@ -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"
@@ -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,66 @@ class MetadataRepository:
)
return result.scalar_one_or_none()
async def list_project_records(self) -> List[models.Project]:
result = await self.session.execute(
select(models.Project).order_by(models.Project.name)
)
return list(result.scalars().all())
async def create_project(
self,
*,
name: str,
code: str,
description: str | None,
gs_workspace: str,
map_extent: dict | None,
status: str,
) -> models.Project:
project = models.Project(
id=uuid4(),
name=name,
code=code,
description=description,
gs_workspace=gs_workspace,
map_extent=map_extent,
status=status,
created_at=_utcnow(),
updated_at=_utcnow(),
)
self.session.add(project)
await self.session.commit()
await self.session.refresh(project)
return project
async def update_project(
self,
project_id: UUID,
*,
updates: dict,
) -> Optional[models.Project]:
project = await self.get_project_by_id(project_id)
if project is None:
return None
for field in (
"name",
"code",
"description",
"gs_workspace",
"map_extent",
"status",
):
if field in updates:
setattr(project, field, updates[field])
project.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(project)
return project
async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]:
project = await self.get_project_by_code(code)
if not project:
return None
geoserver = await self.get_geoserver_config(project.id)
return ProjectDetail(
project_id=project.id,
name=project.name,
@@ -123,7 +264,6 @@ class MetadataRepository:
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
geoserver=geoserver
)
async def get_membership_role(
@@ -137,6 +277,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 +445,6 @@ class MetadataRepository:
pool_max_size=record.pool_max_size,
)
async def get_geoserver_config(
self, project_id: UUID
) -> Optional[ProjectGeoServerInfo]:
result = await self.session.execute(
select(models.ProjectGeoServerConfig).where(
models.ProjectGeoServerConfig.project_id == project_id
)
)
record = result.scalar_one_or_none()
if not record:
return None
if record.gs_admin_password_encrypted:
if is_encryption_configured():
encryptor = get_encryptor()
password = encryptor.decrypt(record.gs_admin_password_encrypted)
else:
password = record.gs_admin_password_encrypted
else:
password = None
return ProjectGeoServerInfo(
project_id=record.project_id,
gs_base_url=record.gs_base_url,
gs_admin_user=record.gs_admin_user,
gs_admin_password=password,
gs_datastore_name=record.gs_datastore_name,
default_extent=record.default_extent,
srid=record.srid,
)
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
stmt = (
select(models.Project, models.UserProjectMembership.project_role)
@@ -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
+51
View File
@@ -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
]
+154 -154
View File
@@ -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(
+19 -9
View File
@@ -50,6 +50,7 @@ class InternalStorage:
link_result_list: List[dict],
result_start_time: str,
num_periods: int = 1,
result_timestep_seconds: int | None = None,
db_name: str = None,
max_retries: int = 3,
):
@@ -70,6 +71,7 @@ class InternalStorage:
link_result_list,
result_start_time,
num_periods,
result_timestep_seconds,
)
break # 成功
except Exception as e:
@@ -229,7 +231,14 @@ class InternalQueries:
scheme_type: str | None = None,
scheme_name: str | None = None,
) -> dict[str, list[dict]]:
if not element_ids:
normalized_element_ids = list(
dict.fromkeys(
normalized
for normalized in (str(element_id).strip() for element_id in element_ids)
if normalized
)
)
if not normalized_element_ids:
return {}
start_dt = parse_utc_time(start_time, field_name="start_time")
@@ -253,9 +262,9 @@ class InternalQueries:
with conn.cursor(row_factory=dict_row) as cur:
if schema_name == "scheme":
query = sql.SQL(
"SELECT id, time, {} FROM {}.{} "
"SELECT btrim(id::text) AS id, time, {} FROM {}.{} "
"WHERE scheme_type = %s AND scheme_name = %s "
"AND time >= %s AND time <= %s AND id = ANY(%s)"
"AND time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)"
).format(
sql.Identifier(field),
sql.Identifier(schema_name),
@@ -268,25 +277,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:
+31 -21
View File
@@ -3,10 +3,24 @@ from datetime import datetime, timedelta
from collections import defaultdict
from psycopg import AsyncConnection, Connection, sql
import app.services.globals as globals
from app.services.time_api import parse_utc_time
from app.services.time_api import parse_clock_duration_seconds, parse_utc_time
class SchemeRepository:
@staticmethod
def _get_result_timestep(result_timestep_seconds: int | None) -> timedelta:
if result_timestep_seconds is not None:
if result_timestep_seconds <= 0:
raise ValueError("result_timestep_seconds must be greater than 0.")
return timedelta(seconds=result_timestep_seconds)
timestep_seconds = parse_clock_duration_seconds(
globals.hydraulic_timestep,
field_name="HYDRAULIC TIMESTEP",
)
if timestep_seconds <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
return timedelta(seconds=timestep_seconds)
# --- Link Simulation ---
@@ -452,6 +466,7 @@ class SchemeRepository:
link_result_list: List[Dict[str, any]],
result_start_time: str,
num_periods: int = 1,
result_timestep_seconds: int | None = None,
):
"""
Store scheme simulation results to TimescaleDB.
@@ -468,20 +483,16 @@ class SchemeRepository:
result_start_time, field_name="result_start_time"
)
timestep_parts = globals.hydraulic_timestep.split(":")
timestep = timedelta(
hours=int(timestep_parts[0]),
minutes=int(timestep_parts[1]),
seconds=int(timestep_parts[2]),
)
timestep = SchemeRepository._get_result_timestep(result_timestep_seconds)
# Prepare node data for batch insert
node_data = []
for node_result in node_result_list:
node_id = node_result.get("node")
for period_index in range(num_periods):
result_rows = node_result.get("result", [])
for period_index in range(min(num_periods, len(result_rows))):
current_time = simulation_time + (timestep * period_index)
data = node_result.get("result", [])[period_index]
data = result_rows[period_index]
node_data.append(
{
"time": current_time,
@@ -499,9 +510,10 @@ class SchemeRepository:
link_data = []
for link_result in link_result_list:
link_id = link_result.get("link")
for period_index in range(num_periods):
result_rows = link_result.get("result", [])
for period_index in range(min(num_periods, len(result_rows))):
current_time = simulation_time + (timestep * period_index)
data = link_result.get("result", [])[period_index]
data = result_rows[period_index]
link_data.append(
{
"time": current_time,
@@ -535,6 +547,7 @@ class SchemeRepository:
link_result_list: List[Dict[str, any]],
result_start_time: str,
num_periods: int = 1,
result_timestep_seconds: int | None = None,
):
"""
Store scheme simulation results to TimescaleDB (sync version).
@@ -551,20 +564,16 @@ class SchemeRepository:
result_start_time, field_name="result_start_time"
)
timestep_parts = globals.hydraulic_timestep.split(":")
timestep = timedelta(
hours=int(timestep_parts[0]),
minutes=int(timestep_parts[1]),
seconds=int(timestep_parts[2]),
)
timestep = SchemeRepository._get_result_timestep(result_timestep_seconds)
# Prepare node data for batch insert
node_data = []
for node_result in node_result_list:
node_id = node_result.get("node")
for period_index in range(num_periods):
result_rows = node_result.get("result", [])
for period_index in range(min(num_periods, len(result_rows))):
current_time = simulation_time + (timestep * period_index)
data = node_result.get("result", [])[period_index]
data = result_rows[period_index]
node_data.append(
{
"time": current_time,
@@ -582,9 +591,10 @@ class SchemeRepository:
link_data = []
for link_result in link_result_list:
link_id = link_result.get("link")
for period_index in range(num_periods):
result_rows = link_result.get("result", [])
for period_index in range(min(num_periods, len(result_rows))):
current_time = simulation_time + (timestep * period_index)
data = link_result.get("result", [])[period_index]
data = result_rows[period_index]
link_data.append(
{
"time": current_time,
+76 -1
View File
@@ -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] = {}
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)
+19 -15
View File
@@ -1,6 +1,6 @@
from typing import Any
from psycopg.rows import dict_row, Row
from .connection import g_conn_dict as conn
from .connection import project_connection
API_ADD = 'add'
API_UPDATE = 'update'
@@ -83,29 +83,33 @@ class DbChangeSet:
def read(name: str, sql: str) -> Row:
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(sql)
row = cur.fetchone()
if row == None:
raise Exception(sql)
return row
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
row = cur.fetchone()
if row == None:
raise Exception(sql)
return row
def read_all(name: str, sql: str) -> list[Row]:
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(sql)
return cur.fetchall()
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
return cur.fetchall()
def try_read(name: str, sql: str) -> Row | None:
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(sql)
return cur.fetchone()
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
return cur.fetchone()
def write(name: str, sql: str) -> None:
with conn[name].cursor() as cur:
cur.execute(sql)
with project_connection(name) as conn:
with conn.cursor() as cur:
cur.execute(sql)
def get_current_operation(name: str) -> int:
+9 -12
View File
@@ -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)
+51 -43
View File
@@ -1,5 +1,5 @@
from psycopg.rows import dict_row, Row
from .connection import g_conn_dict as conn
from .connection import project_connection
from .database import read
from typing import Any
@@ -47,9 +47,10 @@ ELEMENT_TYPES : dict[str, int] = {
}
def _get_from(name: str, id: str, base_type: str) -> Row | None:
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select * from {base_type} where id = '{id}'")
return cur.fetchone()
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select * from {base_type} where id = '{id}'")
return cur.fetchone()
def is_node(name: str, id: str) -> bool:
@@ -125,10 +126,11 @@ def is_region(name: str, id: str) -> bool:
def _get_all(name: str, base_type: str) -> list[str]:
ids : list[str] = []
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from {base_type} order by id")
for record in cur:
ids.append(record['id'])
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from {base_type} order by id")
for record in cur:
ids.append(record['id'])
return ids
@@ -138,29 +140,32 @@ def get_nodes(name: str) -> list[str]:
# DingZQ
def _get_nodes_by_type(name: str, type: str) -> list[str]:
ids : list[str] = []
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from {_NODE} where type = '{type}' order by id")
for record in cur:
ids.append(record['id'])
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from {_NODE} where type = '{type}' order by id")
for record in cur:
ids.append(record['id'])
return ids
# DingZQ
def get_nodes_id_and_type(name: str) -> dict[str, str]:
nodes_id_and_type: dict[str, str] = {}
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id, type from {_NODE} order by id")
for record in cur:
nodes_id_and_type[record['id']] = record['type']
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id, type from {_NODE} order by id")
for record in cur:
nodes_id_and_type[record['id']] = record['type']
return nodes_id_and_type
# DingZQ 2024-12-31
def get_major_nodes(name: str, diameter: int) -> list[str]:
major_nodes_set = set()
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select node1, node2 from pipes where diameter > {diameter}")
for record in cur:
major_nodes_set.add(record['node1'])
major_nodes_set.add(record['node2'])
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select node1, node2 from pipes where diameter > {diameter}")
for record in cur:
major_nodes_set.add(record['node1'])
major_nodes_set.add(record['node2'])
return list(major_nodes_set)
@@ -183,29 +188,32 @@ def get_links(name: str) -> list[str]:
# DingZQ
def _get_links_by_type(name: str, type: str) -> list[str]:
ids : list[str] = []
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from {_LINK} where type = '{type}' order by id")
for record in cur:
ids.append(record['id'])
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from {_LINK} where type = '{type}' order by id")
for record in cur:
ids.append(record['id'])
return ids
# DingZQ
def get_links_id_and_type(name: str) -> dict[str, str]:
links_id_and_type: dict[str, str] = {}
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id, type from {_LINK} order by id")
for record in cur:
links_id_and_type[record['id']] = record['type']
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id, type from {_LINK} order by id")
for record in cur:
links_id_and_type[record['id']] = record['type']
return links_id_and_type
# DingZQ 2024-12-31
# 获取直径大于800的管道
def get_major_pipes(name: str, diameter: int) -> list[str]:
major_pipe_ids: list[str] = []
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from pipes where diameter > {diameter} order by id")
for record in cur:
major_pipe_ids.append(record['id'])
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from pipes where diameter > {diameter} order by id")
for record in cur:
major_pipe_ids.append(record['id'])
return major_pipe_ids
# DingZQ
@@ -232,15 +240,16 @@ def get_regions(name: str) -> list[str]:
return _get_all(name, _REGION)
def get_node_links(name: str, id: str) -> list[str]:
with conn[name].cursor(row_factory=dict_row) as cur:
links: list[str] = []
for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall():
links.append(p['id'])
for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall():
links.append(p['id'])
for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall():
links.append(p['id'])
return links
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
links: list[str] = []
for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall():
links.append(p['id'])
for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall():
links.append(p['id'])
for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall():
links.append(p['id'])
return links
def get_link_nodes(name: str, id: str) -> list[str]:
@@ -259,4 +268,3 @@ def get_region_type(name: str, id: str)->str:
return type
+7 -4
View File
@@ -1,5 +1,7 @@
from .database import *
from .connection import project_connection
from .s0_base import get_link_nodes
from psycopg.rows import dict_row
def sql_update_coord(node: str, x: float, y: float) -> str:
coord = f"st_geomfromtext('point({x} {y})')"
@@ -49,10 +51,11 @@ def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -
node_ids = set([s.split(':')[0] for s in get_nodes_in_extent(name, x1, y1, x2, y2)])
all_link_ids = []
with conn[name].cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from pipes")
for record in cur:
all_link_ids.append(record['id'])
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(f"select id from pipes")
for record in cur:
all_link_ids.append(record['id'])
links = []
for link_id in all_link_ids:
+35 -30
View File
@@ -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
return pipe_risk_probability_geometries
+4 -35
View File
@@ -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`.
"""
+223 -47
View File
@@ -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,6 +11,7 @@ from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.services.scheme_management import (
query_burst_location_scheme_detail,
query_burst_location_schemes,
query_scheme_list,
scheme_name_exists,
store_scheme_info,
)
@@ -40,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")
@@ -60,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,
@@ -87,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,
@@ -117,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",
@@ -127,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,
@@ -138,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(
@@ -179,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。")
@@ -199,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",
@@ -209,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,
@@ -217,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:
@@ -258,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] = {
@@ -281,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(
@@ -376,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) 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,
@@ -385,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(
@@ -400,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):
@@ -408,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)
@@ -427,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,
@@ -446,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:
@@ -454,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,
@@ -476,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
}
@@ -556,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":
@@ -595,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
@@ -638,7 +790,31 @@ def _get_sensor_nodes(network: str, data_type: str) -> list[str]:
def _dedupe_ids(ids: list[str] | None) -> list[str]:
if ids is None:
return []
return list(dict.fromkeys([str(item) for item in ids if item]))
return list(
dict.fromkeys(
normalized
for normalized in (_normalize_identifier(item) for item in ids)
if normalized
)
)
def _normalize_identifier(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _normalize_timeseries_by_id(
data: dict[Any, list[dict[str, Any]]] | None,
) -> dict[str, list[dict[str, Any]]]:
normalized_data: dict[str, list[dict[str, Any]]] = {}
for raw_id, records in (data or {}).items():
normalized_id = _normalize_identifier(raw_id)
if not normalized_id:
continue
normalized_data.setdefault(normalized_id, []).extend(records or [])
return normalized_data
def _to_datetime(value: datetime | str) -> datetime:
+76
View File
@@ -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
+2 -2
View File
@@ -23,8 +23,8 @@ non_realtime_region_patterns = {} # 基于source_outflow_region进行区分
realtime_region_pipe_flow_and_demand_id = {} # 基于source_outflow_region搜索该分区中的实时pipe_flow和demand的api_query_id,后续用region的流量 - 实时流量计的流量
realtime_region_pipe_flow_and_demand_patterns = {} # 基于source_outflow_region搜索该分区中的实时pipe_flow和demand的associated_pattern,后续用region的流量 - 实时流量计的流量
# ---------------------------------------------------------
# influxdb_api.py中的全局变量
# 全局变量,用于存储不同类型的realtime api_query_id
# 历史数据访问相关全局变量
# 全局变量,用于存储不同类型的 realtime api_query_id
reservoir_liquid_level_realtime_ids = []
tank_liquid_level_realtime_ids = []
fixed_pump_realtime_ids = []
+12 -7
View File
@@ -28,13 +28,12 @@ import pytz
import requests
import time
from typing import Optional, Tuple
import app.infra.db.influxdb.api as influxdb_api
import typing
import psycopg
import logging
import app.services.globals as globals
import app.services.project_info as project_info
from app.services.time_api import parse_beijing_time
from app.services.time_api import parse_beijing_time, parse_clock_duration_seconds
from app.core.config import get_pgconn_string
from app.infra.db.timescaledb.internal_queries import (
InternalQueries as TimescaleInternalQueries,
@@ -757,11 +756,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)
@@ -1258,6 +1259,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,
@@ -1265,6 +1269,7 @@ def run_simulation(
link_result,
modify_pattern_start_time,
num_periods_result,
result_timestep_seconds,
db_name=db_name,
)
endtime = time.time()
+35
View File
@@ -89,6 +89,41 @@ def to_time_range(dt: datetime, delta: float) -> tuple[datetime, datetime]:
return (start_time, end_time)
def parse_clock_duration_seconds(clock: str, field_name: str = "duration") -> int:
"""
Parse EPANET-style clock durations into seconds.
Accepted formats include H:MM, HH:MM, H:MM:SS, and HH:MM:SS.
"""
if not isinstance(clock, str):
raise ValueError(f"{field_name} must be a string clock duration.")
parts = clock.strip().split(":")
if len(parts) not in (2, 3):
raise ValueError(
f"{field_name} must use H:MM or H:MM:SS format, got {clock!r}."
)
try:
values = [int(part) for part in parts]
except ValueError as exc:
raise ValueError(
f"{field_name} must contain numeric clock parts, got {clock!r}."
) from exc
if any(value < 0 for value in values):
raise ValueError(f"{field_name} must not contain negative values.")
hours, minutes = values[0], values[1]
seconds = values[2] if len(values) == 3 else 0
if minutes >= 60 or seconds >= 60:
raise ValueError(
f"{field_name} minutes and seconds must be less than 60, got {clock!r}."
)
return hours * 3600 + minutes * 60 + seconds
def parse_beijing_date_range(query_date: str) -> tuple[datetime, datetime]:
'''
将一个日期字符串转换成 start/end 时间段传进来的日期被认为是北京时间
+93
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
dist/
build/
__pycache__/
-79
View File
@@ -1,79 +0,0 @@
# TJWater CLI
独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。
## 构建可执行产物
```bash
cd TJWaterServerBinary/cli
python -m pip install -r requirements.txt
python -m pip install -r requirements-build.txt
chmod +x build.sh
./build.sh
```
构建完成后,直接使用编译产物:
```bash
./dist/tjwater-cli/tjwater-cli help
```
这个可执行文件可以直接参与管道:
```bash
./dist/tjwater-cli/tjwater-cli help | jq
```
当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。
如果需要在开发时直接走源码入口,也可以显式使用 Python:
```bash
python -m tjwater_cli help
```
## 部署到 agent 服务器
最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行:
```bash
./tjwater-cli/tjwater-cli help
```
如果希望打包传输:
```bash
cd TJWaterServerBinary/cli
tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli
```
如果希望放到 PATH 中:
```bash
ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli
tjwater-cli help | jq
```
## 运行与构建依赖
```bash
cd TJWaterServerBinary/cli
python -m pip install -r requirements.txt
python -m pip install -r requirements-build.txt
```
`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。
## 认证上下文
CLI 通过 `--auth-context` 读取 JSON 文件。常用字段:
```json
{
"server": "http://backend-host:8000",
"access_token": "...",
"project_id": "...",
"network": "...",
"username": "..."
}
```
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -n "${PYTHON:-}" ]; then
PYTHON_BIN="$PYTHON"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="python"
else
PYTHON_BIN="python3"
fi
cd "$ROOT"
"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec
BIN_PATH="$ROOT/dist/"
if [ ! -x "$BIN_PATH" ]; then
echo "build succeeded but executable was not created: $BIN_PATH" >&2
exit 1
fi
"$BIN_PATH" help >/dev/null
echo "built executable: $BIN_PATH"
-5
View File
@@ -1,5 +0,0 @@
from tjwater_cli.main import console_entry
if __name__ == "__main__":
console_entry()
-14
View File
@@ -1,14 +0,0 @@
{
"include": [
"tjwater_cli",
"tests"
],
"executionEnvironments": [
{
"root": ".",
"extraPaths": [
"."
]
}
]
}
-1
View File
@@ -1 +0,0 @@
pyinstaller>=6.11,<7
-3
View File
@@ -1,3 +0,0 @@
click>=8.1,<9
requests>=2.31,<3
typer>=0.12,<1
-6
View File
@@ -1,6 +0,0 @@
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
-550
View File
@@ -1,550 +0,0 @@
import json
from pathlib import Path
from typer.testing import CliRunner
from tjwater_cli import common, core
from tjwater_cli.main import app, main
runner = CliRunner()
class DummyResponse:
def __init__(self, *, status_code=200, json_data=None, text="", headers=None, content=None):
self.status_code = status_code
self._json_data = json_data
self.text = text
self.headers = headers or {"content-type": "application/json"}
self.content = content if content is not None else text.encode("utf-8")
@property
def ok(self):
return 200 <= self.status_code < 300
def json(self):
if self._json_data is None:
raise ValueError("no json")
return self._json_data
def test_load_auth_context_supports_aliases(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_PROJECT_ID", "p1")
monkeypatch.setenv("TJWATER_USER_ID", "u1")
monkeypatch.setenv("TJWATER_USERNAME", "tester")
monkeypatch.setenv("TJWATER_NETWORK", "net1")
auth = core.load_auth_context(auth_stdin=False)
assert auth.server == "http://server"
assert auth.access_token == "abc"
assert auth.project_id == "p1"
assert auth.user_id == "u1"
assert auth.username == "tester"
assert auth.network == "net1"
def test_build_runtime_context_uses_default_server(monkeypatch):
monkeypatch.delenv("TJWATER_SERVER", raising=False)
monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False)
monkeypatch.delenv("TJWATER_USER_ID", raising=False)
monkeypatch.delenv("TJWATER_USERNAME", raising=False)
monkeypatch.delenv("TJWATER_NETWORK", raising=False)
monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False)
runtime = core.build_runtime_context(
server=None,
scheme=None,
timeout=core.DEFAULT_TIMEOUT,
request_id="req-1",
)
assert runtime.server == core.DEFAULT_SERVER
def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
observed_runtime_ids: list[int] = []
def fake_request_json(ctx, **kwargs):
observed_runtime_ids.append(id(ctx))
assert ctx.auth.access_token == "token-1"
assert kwargs["params"] == {"network": "tjwater", "node": "11"}
return {"node": "11"}, 5
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(
app,
["--auth-stdin", "network", "get-node-properties", "--node", "11"],
input=json.dumps(
{
"server": "http://server",
"access_token": "token-1",
"project_id": "project-1",
"network": "tjwater",
}
),
)
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"node": "11"}
assert len(observed_runtime_ids) == 1
def test_network_get_all_junction_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["params"] = kwargs["params"]
return [{"id": "J1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-junction-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "J1"}]
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}}
def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["params"] = kwargs["params"]
return [{"id": "P1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pipe-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "P1"}]
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}}
def test_help_outputs_json_lists_commands():
result = runner.invoke(app, ["help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["schema_version"] == "tjwater-cli/v1"
assert any(command["command"] == "analysis" for command in payload["commands"])
assert all(command["command"] != "project" for command in payload["commands"])
assert payload["menu_level"] == 1
assert all(command["command"] != "project list" for command in payload["commands"])
def test_help_option_json_is_removed():
result = runner.invoke(app, ["help", "--json"])
assert result.exit_code == 2
assert "No such option: --json" in result.output
def test_simulation_help_lists_subcommands():
result = runner.invoke(app, ["simulation", "help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["summary"] == "模拟运行与调度相关命令。"
commands = {command["command"]: command for command in payload["commands"]}
assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行"
assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
assert "tjwater-cli" in commands["simulation run"]["example"]
assert "simulation run" in commands["simulation run"]["example"]
def test_nested_group_help_lists_examples():
result = runner.invoke(app, ["analysis", "leakage", "help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["summary"] == "漏损分析相关命令。"
commands = {command["command"]: command for command in payload["commands"]}
assert commands["analysis leakage identify"]["summary"] == "执行漏损识别"
assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"
def test_analysis_help_uses_group_summaries_for_nested_groups():
result = runner.invoke(app, ["analysis", "help"])
payload = json.loads(result.stdout)
commands = {command["command"]: command for command in payload["commands"]}
assert result.exit_code == 0
assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。"
assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。"
assert "analysis burst-location" not in commands
assert "analysis risk" not in commands
assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01"
assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01"
def test_bare_analysis_uses_typer_help_with_descriptions():
result = runner.invoke(app, ["analysis"])
assert result.exit_code == 2
assert "分析计算与诊断相关命令。" in result.stdout
assert "burst 执行爆管分析" in result.stdout
assert "valve" in result.stdout
assert "leakage 漏损分析相关命令。" in result.stdout
assert "burst-location" not in result.stdout
assert "risk" not in result.stdout
def test_leaf_help_outputs_json():
result = runner.invoke(app, ["help", "simulation", "run"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["command"] == "simulation run"
assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询"
assert payload["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
assert len(payload["examples"]) == 1
assert "simulation run" in payload["examples"][0]
def test_root_help_flag_uses_typer_style_with_examples():
result = runner.invoke(app, ["--help"], prog_name="tjwater-cli")
assert result.exit_code == 0
assert "Usage: tjwater-cli" in result.stdout
assert "Examples:" in result.stdout
assert "tjwater-cli help simulation run" in result.stdout
def test_leaf_help_flag_includes_usage_and_example():
result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli")
assert result.exit_code == 0
assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout
assert "Usage example:" in result.stdout
assert "--start-time <START_TIME>" in result.stdout
assert "--duration" in result.stdout
assert "Examples:" in result.stdout
assert "tjwater-cli simulation run" in result.stdout
assert "START_TIME" in result.stdout
assert "DURATION" in result.stdout
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
burst_path = tmp_path / "burst.json"
burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8")
def fake_request(**kwargs):
return DummyResponse(text="success", headers={"content-type": "text/plain"})
monkeypatch.setattr(core.requests, "request", fake_request)
result = runner.invoke(
app,
[
"analysis",
"burst",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--duration",
"30",
"--burst-file",
str(burst_path),
"--scheme",
"burst_case_01",
],
)
assert result.exit_code == 0
assert '"summary": "爆管分析执行成功"' in result.stdout
assert "tjwater-cli data scheme get --name burst_case_01" in result.stdout
assert "tjwater-cli data scheme list" in result.stdout
def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
captured.update(kwargs)
return DummyResponse(text="success", headers={"content-type": "text/plain"})
monkeypatch.setattr(core.requests, "request", fake_request)
result = runner.invoke(
app,
[
"analysis",
"contaminant",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--duration",
"900",
"--source-node",
"N1",
"--concentration",
"10.0",
"--scheme",
"contam_case_01",
],
)
assert result.exit_code == 0
assert captured["params"] == {
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"source": "N1",
"concentration": 10.0,
"duration": 900,
"scheme_name": "contam_case_01",
}
def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
valve_path = tmp_path / "valve.json"
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
def fake_request(**kwargs):
captured.update(kwargs)
return DummyResponse(text="success", headers={"content-type": "text/plain"})
monkeypatch.setattr(core.requests, "request", fake_request)
result = runner.invoke(
app,
[
"analysis",
"flushing",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--valve-setting-file",
str(valve_path),
"--drainage-node",
"N1",
"--flow",
"100.0",
"--duration",
"900",
"--scheme",
"flush_case_01",
],
)
assert result.exit_code == 0
assert captured["params"] == {
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": ["V1"],
"valves_k": [0.5],
"drainage_node_ID": "N1",
"flush_flow": 100.0,
"duration": 900,
"scheme_name": "flush_case_01",
}
def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
captured.update(kwargs)
return DummyResponse(text="success", headers={"content-type": "text/plain"})
monkeypatch.setattr(core.requests, "request", fake_request)
result = runner.invoke(
app,
[
"analysis",
"valve",
"--mode",
"close",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--valve",
"V1",
"--duration",
"900",
"--scheme",
"valve_case_01",
],
)
assert result.exit_code == 0
assert captured["params"] == {
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": ["V1"],
"duration": 900,
"scheme_name": "valve_case_01",
}
def test_analysis_contaminant_requires_scheme(monkeypatch, capsys):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
exit_code = main(
[
"analysis",
"contaminant",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--duration",
"900",
"--source-node",
"N1",
"--concentration",
"10.0",
],
)
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"code": "SCHEME_REQUIRED"' in stdout
def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
valve_path = tmp_path / "valve.json"
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
exit_code = main(
[
"analysis",
"flushing",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--valve-setting-file",
str(valve_path),
"--drainage-node",
"N1",
"--flow",
"100.0",
],
)
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"code": "SCHEME_REQUIRED"' in stdout
def test_analysis_valve_close_requires_scheme(monkeypatch, capsys):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
exit_code = main(
[
"analysis",
"valve",
"--mode",
"close",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--valve",
"V1",
"--duration",
"900",
],
)
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"code": "SCHEME_REQUIRED"' in stdout
def test_main_missing_option_error_includes_usage_and_next_step(capsys):
exit_code = main(["simulation", "run"])
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"summary": "缺少参数"' in stdout
assert '"code": "MISSING_PARAMETER"' in stdout
assert '"usage": "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"' in stdout
assert '"tjwater-cli help simulation run"' in stdout
def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
exit_code = main(["analysis"])
stdout = capsys.readouterr().out
assert exit_code == 0
assert "Usage: tjwater-cli analysis" in stdout
assert "分析计算与诊断相关命令。" in stdout
assert '"ok": false' not in stdout
def test_simulation_run_translates_rfc3339(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
captured.update(kwargs)
return DummyResponse(json_data={"status": "success", "message": "Simulation started"})
monkeypatch.setattr(core.requests, "request", fake_request)
result = runner.invoke(
app,
[
"simulation",
"run",
"--start-time",
"2025-01-02T03:04:05+08:00",
"--duration",
"30",
],
)
assert result.exit_code == 0
assert captured["json"] == {
"name": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"duration": 30,
}
assert "tjwater-cli data timeseries realtime links" in result.stdout
assert "tjwater-cli data timeseries realtime nodes" in result.stdout
def test_removed_project_command_returns_not_found(capsys):
exit_code = main(["project", "list"])
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout
-45
View File
@@ -1,45 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
datas = collect_data_files("certifi")
a = Analysis(
["entrypoint.py"],
pathex=["."],
binaries=[],
datas=datas,
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="tjwater-cli",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="tjwater-cli",
)
-3
View File
@@ -1,3 +0,0 @@
from .main import app, main
__all__ = ["app", "main"]
-5
View File
@@ -1,5 +0,0 @@
from .main import console_entry
if __name__ == "__main__":
console_entry()
-82
View File
@@ -1,82 +0,0 @@
from __future__ import annotations
import typer
from .formatters import TJWaterGroup
app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup)
network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
app.add_typer(network_app, name="network")
app.add_typer(component_app, name="component")
component_app.add_typer(component_option_app, name="option")
app.add_typer(simulation_app, name="simulation")
app.add_typer(analysis_app, name="analysis")
analysis_app.add_typer(analysis_sensor_placement_app, name="sensor-placement")
analysis_app.add_typer(analysis_leakage_app, name="leakage")
analysis_leakage_app.add_typer(analysis_leakage_schemes_app, name="schemes")
analysis_app.add_typer(analysis_burst_detection_app, name="burst-detection")
analysis_burst_detection_app.add_typer(analysis_burst_detection_schemes_app, name="schemes")
analysis_app.add_typer(analysis_burst_location_app, name="burst-location")
analysis_burst_location_app.add_typer(analysis_burst_location_schemes_app, name="schemes")
analysis_app.add_typer(analysis_risk_app, name="risk")
app.add_typer(data_app, name="data")
data_app.add_typer(data_timeseries_app, name="timeseries")
data_timeseries_app.add_typer(data_timeseries_realtime_app, name="realtime")
data_timeseries_app.add_typer(data_timeseries_scheme_app, name="scheme")
data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada")
data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite")
data_app.add_typer(data_scada_app, name="scada")
data_app.add_typer(data_scheme_app, name="scheme")
data_app.add_typer(data_extension_app, name="extension")
data_app.add_typer(data_misc_app, name="misc")
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(network_app, ("network",)),
(component_app, ("component",)),
(component_option_app, ("component", "option")),
(simulation_app, ("simulation",)),
(analysis_app, ("analysis",)),
(analysis_sensor_placement_app, ("analysis", "sensor-placement")),
(analysis_leakage_app, ("analysis", "leakage")),
(analysis_leakage_schemes_app, ("analysis", "leakage", "schemes")),
(analysis_burst_detection_app, ("analysis", "burst-detection")),
(analysis_burst_detection_schemes_app, ("analysis", "burst-detection", "schemes")),
(analysis_burst_location_app, ("analysis", "burst-location")),
(analysis_burst_location_schemes_app, ("analysis", "burst-location", "schemes")),
(analysis_risk_app, ("analysis", "risk")),
(data_app, ("data",)),
(data_timeseries_app, ("data", "timeseries")),
(data_timeseries_realtime_app, ("data", "timeseries", "realtime")),
(data_timeseries_scheme_app, ("data", "timeseries", "scheme")),
(data_timeseries_scada_app, ("data", "timeseries", "scada")),
(data_timeseries_composite_app, ("data", "timeseries", "composite")),
(data_scada_app, ("data", "scada")),
(data_scheme_app, ("data", "scheme")),
(data_extension_app, ("data", "extension")),
(data_misc_app, ("data", "misc")),
]
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
-528
View File
@@ -1,528 +0,0 @@
from __future__ import annotations
from datetime import timedelta
from pathlib import Path
from typing import Annotated
import typer
from .apps import (
analysis_app,
analysis_burst_detection_app,
analysis_burst_detection_schemes_app,
analysis_burst_location_app,
analysis_burst_location_schemes_app,
analysis_leakage_app,
analysis_leakage_schemes_app,
analysis_risk_app,
analysis_sensor_placement_app,
simulation_app,
)
from .common import emit_api, runtime_context
from .core import (
CLIError,
emit_success,
parse_burst_file,
parse_optional_dataset_file,
parse_time_with_timezone,
parse_valve_setting_file,
request_json,
require_network,
require_username,
resolve_scheme,
)
@simulation_app.command("run")
def simulation_run(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
duration: Annotated[int, typer.Option("--duration", help="持续分钟数")],
) -> None:
runtime = runtime_context(ctx)
network = require_network(runtime)
parsed = parse_time_with_timezone(start_time, option_name="--start-time")
end_time = (parsed + timedelta(minutes=duration)).isoformat()
body = {
"name": network,
"start_time": parsed.replace(microsecond=0).isoformat(),
"duration": duration,
}
emit_api(
ctx,
summary="触发模拟成功",
method="POST",
path="/runsimulationmanuallybydate/",
json_body=body,
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
f"tjwater-cli data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
],
)
@analysis_app.command("burst")
def analysis_burst(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
burst_file: Annotated[Path, typer.Option("--burst-file", help="爆管输入 JSON 文件")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
ids, sizes = parse_burst_file(burst_file)
scheme_name = resolve_scheme(runtime, scheme, required=True)
params = {
"network": require_network(runtime),
"modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"burst_ID": ids,
"burst_size": sizes,
"modify_total_duration": duration,
"scheme_name": scheme_name,
}
emit_api(
ctx,
summary="爆管分析执行成功",
method="GET",
path="/burst_analysis/",
params=params,
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater-cli data scheme get --name {scheme_name}",
"tjwater-cli data scheme list",
],
)
@analysis_app.command("valve")
def analysis_valve(
ctx: typer.Context,
mode: Annotated[str, typer.Option("--mode", help="close|isolation")],
start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None,
valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None,
element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None,
disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None,
duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None,
scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
network = require_network(runtime)
if mode == "close":
if not start_time or not valve:
raise CLIError(
"CLI 参数错误",
code="INVALID_VALVE_CLOSE_ARGS",
message="close mode requires --start-time and at least one --valve",
exit_code=2,
)
params = {
"network": network,
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"valves": valve,
"duration": duration or 900,
"scheme_name": resolve_scheme(runtime, scheme, required=True),
}
emit_api(
ctx,
summary="阀门关闭分析执行成功",
method="GET",
path="/valve_close_analysis/",
params=params,
require_auth=True,
require_network_ctx=True,
)
return
if mode == "isolation":
if not element:
raise CLIError(
"CLI 参数错误",
code="INVALID_VALVE_ISOLATION_ARGS",
message="isolation mode requires at least one --element",
exit_code=2,
)
params = {"network": network, "accident_element": element}
if disabled_valve:
params["disabled_valves"] = disabled_valve
emit_api(
ctx,
summary="阀门隔离分析执行成功",
method="GET",
path="/valve_isolation_analysis/",
params=params,
require_auth=True,
require_network_ctx=True,
)
return
raise CLIError(
"CLI 参数错误",
code="INVALID_MODE",
message="--mode must be close or isolation",
exit_code=2,
)
@analysis_app.command("flushing")
def analysis_flushing(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
valve_setting_file: Annotated[Path, typer.Option("--valve-setting-file", help="阀门开度 JSON 文件")],
drainage_node: Annotated[str, typer.Option("--drainage-node", help="排污节点")],
flow: Annotated[float, typer.Option("--flow", help="冲洗流量")],
duration: Annotated[int | None, typer.Option("--duration", help="持续秒数")] = None,
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
valves, openings = parse_valve_setting_file(valve_setting_file)
params = {
"network": require_network(runtime),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"valves": valves,
"valves_k": openings,
"drainage_node_ID": drainage_node,
"flush_flow": flow,
"duration": duration or 900,
"scheme_name": resolve_scheme(runtime, scheme, required=True),
}
emit_api(
ctx,
summary="冲洗分析执行成功",
method="GET",
path="/flushing_analysis/",
params=params,
require_auth=True,
require_network_ctx=True,
)
@analysis_app.command("age")
def analysis_age(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="水龄分析执行成功",
method="GET",
path="/age_analysis/",
params={
"network": require_network(runtime),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"duration": duration,
},
require_auth=True,
require_network_ctx=True,
)
@analysis_app.command("contaminant")
def analysis_contaminant(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
duration: Annotated[int, typer.Option("--duration", help="持续秒数")],
source_node: Annotated[str, typer.Option("--source-node", help="污染源节点")],
concentration: Annotated[float, typer.Option("--concentration", help="浓度")],
pattern: Annotated[str | None, typer.Option("--pattern", help="模式 ID")] = None,
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
params = {
"network": require_network(runtime),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"source": source_node,
"concentration": concentration,
"duration": duration,
"scheme_name": resolve_scheme(runtime, scheme, required=True),
}
if pattern:
params["pattern"] = pattern
emit_api(
ctx,
summary="污染物模拟执行成功",
method="GET",
path="/contaminant_simulation/",
params=params,
require_auth=True,
require_network_ctx=True,
)
@analysis_sensor_placement_app.command("kmeans")
def analysis_sensor_placement_kmeans(
ctx: typer.Context,
count: Annotated[int, typer.Option("--count", help="传感器数量")],
min_diameter: Annotated[int, typer.Option("--min-diameter", help="最小管径")] = 0,
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
body = {
"name": require_network(runtime),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"sensor_number": count,
"min_diameter": min_diameter,
"username": require_username(runtime),
}
emit_api(
ctx,
summary="传感器选址执行成功",
method="POST",
path="/pressure_sensor_placement_kmeans/",
json_body=body,
require_auth=True,
require_network_ctx=True,
require_username_ctx=True,
)
@analysis_leakage_app.command("identify")
def analysis_leakage_identify(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
body = {
"network": require_network(runtime),
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
}
emit_api(
ctx,
summary="漏损识别执行成功",
method="POST",
path="/leakage/identify/",
json_body=body,
require_auth=True,
require_network_ctx=True,
)
@analysis_leakage_schemes_app.command("list")
def analysis_leakage_schemes_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取漏损方案列表成功",
method="GET",
path="/leakage/schemes/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@analysis_leakage_schemes_app.command("get")
def analysis_leakage_schemes_get(
ctx: typer.Context,
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取漏损方案详情成功",
method="GET",
path=f"/leakage/schemes/{scheme_name}",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@analysis_burst_detection_app.command("detect")
def analysis_burst_detection_detect(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
) -> None:
runtime = runtime_context(ctx)
body = {
"network": require_network(runtime),
"scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
}
emit_api(
ctx,
summary="爆管检测执行成功",
method="POST",
path="/burst-detection/detect/",
json_body=body,
require_auth=True,
require_network_ctx=True,
)
@analysis_burst_detection_schemes_app.command("list")
def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管检测方案列表成功",
method="GET",
path="/burst-detection/schemes/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@analysis_burst_detection_schemes_app.command("get")
def analysis_burst_detection_schemes_get(
ctx: typer.Context,
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管检测方案详情成功",
method="GET",
path=f"/burst-detection/schemes/{scheme_name}",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@analysis_burst_location_app.command("locate")
def analysis_burst_location_locate(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")],
burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring",
pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None,
flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None,
pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None,
flow_file: Annotated[Path | None, typer.Option("--flow-file", help="包含 burst_flow/normal_flow 的 JSON 文件")] = None,
use_scada_flow: Annotated[bool, typer.Option("--use-scada-flow", help="启用 SCADA 流量")] = False,
) -> None:
runtime = runtime_context(ctx)
pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {}
flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {}
body = {
"network": require_network(runtime),
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"data_source": data_source,
"scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
"burst_leakage": burst_leakage,
"use_scada_flow": use_scada_flow,
}
if pressure_scada_id:
body["pressure_scada_ids"] = pressure_scada_id
if flow_scada_id:
body["flow_scada_ids"] = flow_scada_id
if isinstance(pressure_payload, dict):
body.update({key: value for key, value in pressure_payload.items() if key in {"burst_pressure", "normal_pressure"}})
if isinstance(flow_payload, dict):
body.update({key: value for key, value in flow_payload.items() if key in {"burst_flow", "normal_flow"}})
emit_api(
ctx,
summary="爆管定位执行成功",
method="POST",
path="/burst-location/locate/",
json_body=body,
require_auth=True,
require_network_ctx=True,
)
@analysis_burst_location_schemes_app.command("list")
def analysis_burst_location_schemes_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管定位方案列表成功",
method="GET",
path="/burst-location/schemes/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@analysis_burst_location_schemes_app.command("get")
def analysis_burst_location_schemes_get(
ctx: typer.Context,
scheme_name: Annotated[str, typer.Argument(help="方案名称")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管定位方案详情成功",
method="GET",
path=f"/burst-location/schemes/{scheme_name}",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@analysis_risk_app.command("pipe-now")
def analysis_risk_pipe_now(
ctx: typer.Context,
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取当前管道风险成功",
method="GET",
path="/getpiperiskprobabilitynow/",
params={"network": require_network(runtime), "pipe_id": pipe},
require_auth=True,
require_network_ctx=True,
)
@analysis_risk_app.command("pipe-history")
def analysis_risk_pipe_history(
ctx: typer.Context,
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取历史管道风险成功",
method="GET",
path="/getpiperiskprobability/",
params={"network": require_network(runtime), "pipe_id": pipe},
require_auth=True,
require_network_ctx=True,
)
@analysis_risk_app.command("network")
def analysis_risk_network(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
network = require_network(runtime)
probabilities, duration_prob = request_json(
runtime,
method="GET",
path="/getnetworkpiperiskprobabilitynow/",
params={"network": network},
require_auth=True,
require_network_ctx=True,
)
geometries, duration_geo = request_json(
runtime,
method="GET",
path="/getpiperiskprobabilitygeometries/",
params={"network": network},
require_auth=True,
require_network_ctx=True,
)
emit_success(
summary="读取全网风险成功",
data={"probabilities": probabilities, "geometries": geometries},
ctx=runtime,
duration_ms=duration_prob + duration_geo,
)
-573
View File
@@ -1,573 +0,0 @@
from __future__ import annotations
from typing import Annotated
import typer
from .apps import (
data_extension_app,
data_misc_app,
data_scada_app,
data_scheme_app,
data_timeseries_composite_app,
data_timeseries_realtime_app,
data_timeseries_scada_app,
data_timeseries_scheme_app,
)
from .common import emit_api, runtime_context
from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme
def _scheme_type_option(scheme_type: str | None) -> str:
return scheme_type or "simulation"
@data_timeseries_realtime_app.command("links")
def data_realtime_links(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
) -> None:
emit_api(
ctx,
summary="读取实时管道数据成功",
method="GET",
path="/realtime/links",
params={
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
},
require_auth=True,
require_project=True,
)
@data_timeseries_realtime_app.command("nodes")
def data_realtime_nodes(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
) -> None:
emit_api(
ctx,
summary="读取实时节点数据成功",
method="GET",
path="/realtime/nodes",
params={
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
},
require_auth=True,
require_project=True,
)
@data_timeseries_realtime_app.command("simulation-by-id-time")
def data_realtime_simulation_by_id_time(
ctx: typer.Context,
id: Annotated[str, typer.Option("--id", help="元素 ID")],
type: Annotated[str, typer.Option("--type", help="pipe|junction")],
time: Annotated[str, typer.Option("--time", help="查询时间")],
) -> None:
emit_api(
ctx,
summary="读取实时模拟数据成功",
method="GET",
path="/realtime/query/by-id-time",
params={
"id": id,
"type": type,
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
},
require_auth=True,
require_project=True,
)
@data_timeseries_realtime_app.command("simulation-by-time-property")
def data_realtime_simulation_by_time_property(
ctx: typer.Context,
type: Annotated[str, typer.Option("--type", help="pipe|junction")],
time: Annotated[str, typer.Option("--time", help="查询时间")],
property: Annotated[str, typer.Option("--property", help="属性名")],
) -> None:
emit_api(
ctx,
summary="读取实时属性聚合数据成功",
method="GET",
path="/realtime/query/by-time-property",
params={
"type": type,
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
"property": property,
},
require_auth=True,
require_project=True,
)
@data_timeseries_scheme_app.command("links")
def data_scheme_links(
ctx: typer.Context,
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取方案管道数据成功",
method="GET",
path="/scheme/links",
params={
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"scheme_type": _scheme_type_option(scheme_type),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
},
require_auth=True,
require_project=True,
)
@data_timeseries_scheme_app.command("node-field")
def data_scheme_node_field(
ctx: typer.Context,
node: Annotated[str, typer.Option("--node", help="节点 ID")],
field: Annotated[str, typer.Option("--field", help="字段名")],
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取方案节点字段成功",
method="GET",
path=f"/scheme/nodes/{node}/field",
params={
"field": field,
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"scheme_type": _scheme_type_option(scheme_type),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
},
require_auth=True,
require_project=True,
)
@data_timeseries_scheme_app.command("simulation")
def data_scheme_simulation(
ctx: typer.Context,
query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")],
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None,
time: Annotated[str, typer.Option("--time", help="查询时间")] = "",
type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe",
property: Annotated[str | None, typer.Option("--property", help="属性名")] = None,
) -> None:
runtime = runtime_context(ctx)
params = {
"scheme_name": resolve_scheme(runtime, scheme, required=True),
"scheme_type": _scheme_type_option(scheme_type),
"query_time": parse_time_with_timezone(time, option_name="--time").isoformat(),
"type": type,
}
if query == "by-id-time":
if not id:
raise CLIError(
"CLI 参数错误",
code="ID_REQUIRED",
message="--id is required for --query by-id-time",
exit_code=2,
)
params["id"] = id
emit_api(
ctx,
summary="读取方案单点模拟数据成功",
method="GET",
path="/scheme/query/by-id-time",
params=params,
require_auth=True,
require_project=True,
)
return
if query == "by-scheme-time-property":
if not property:
raise CLIError(
"CLI 参数错误",
code="PROPERTY_REQUIRED",
message="--property is required for --query by-scheme-time-property",
exit_code=2,
)
params["property"] = property
emit_api(
ctx,
summary="读取方案属性聚合数据成功",
method="GET",
path="/scheme/query/by-scheme-time-property",
params=params,
require_auth=True,
require_project=True,
)
return
raise CLIError(
"CLI 参数错误",
code="INVALID_QUERY",
message="--query must be by-id-time or by-scheme-time-property",
exit_code=2,
)
@data_timeseries_scada_app.command("query")
def data_scada_query(
ctx: typer.Context,
device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")],
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
field: Annotated[str | None, typer.Option("--field", help="字段名")] = None,
) -> None:
path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range"
params = {
"device_ids": ",".join(device_id),
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
}
if field:
params["field"] = field
emit_api(
ctx,
summary="读取 SCADA 时序成功",
method="GET",
path=path,
params=params,
require_auth=True,
require_project=True,
)
@data_timeseries_composite_app.callback(invoke_without_command=True)
def data_timeseries_composite(
ctx: typer.Context,
kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None,
feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None,
start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None,
end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None,
pipe: Annotated[str | None, typer.Option("--pipe", help="pipeline-health 用管道 ID")] = None,
scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None,
scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None,
use_cleaned: Annotated[bool, typer.Option("--use-cleaned", help="element-scada 使用清洗值")] = False,
) -> None:
_ = pipe
if ctx.invoked_subcommand is not None:
return
if not kind or not start_time or not end_time:
raise CLIError(
"CLI 参数错误",
code="INVALID_COMPOSITE_ARGS",
message="composite query requires --kind, --start-time, and --end-time",
exit_code=2,
)
runtime = runtime_context(ctx)
params = {
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
"end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
}
if kind == "scada-simulation":
if not feature:
raise CLIError(
"CLI 参数错误",
code="FEATURE_REQUIRED",
message="--feature is required for scada-simulation",
exit_code=2,
)
params["device_ids"] = ",".join(feature)
scheme_name = resolve_scheme(runtime, scheme)
if scheme_name:
params["scheme_name"] = scheme_name
params["scheme_type"] = _scheme_type_option(scheme_type)
emit_api(
ctx,
summary="读取复合 SCADA-模拟数据成功",
method="GET",
path="/composite/scada-simulation",
params=params,
require_auth=True,
require_project=True,
)
return
if kind == "element-simulation":
if not feature:
raise CLIError(
"CLI 参数错误",
code="FEATURE_REQUIRED",
message="--feature is required for element-simulation",
exit_code=2,
)
params["feature_infos"] = ",".join(feature)
scheme_name = resolve_scheme(runtime, scheme)
if scheme_name:
params["scheme_name"] = scheme_name
params["scheme_type"] = _scheme_type_option(scheme_type)
emit_api(
ctx,
summary="读取复合元素模拟数据成功",
method="GET",
path="/composite/element-simulation",
params=params,
require_auth=True,
require_project=True,
)
return
if kind == "element-scada":
if not feature or len(feature) != 1:
raise CLIError(
"CLI 参数错误",
code="FEATURE_REQUIRED",
message="element-scada requires exactly one --feature as element_id",
exit_code=2,
)
params["element_id"] = feature[0]
params["use_cleaned"] = use_cleaned
emit_api(
ctx,
summary="读取元素关联 SCADA 数据成功",
method="GET",
path="/composite/element-scada",
params=params,
require_auth=True,
require_project=True,
)
return
raise CLIError(
"CLI 参数错误",
code="INVALID_KIND",
message="--kind must be scada-simulation, element-simulation, or element-scada",
exit_code=2,
)
@data_timeseries_composite_app.command("pipeline-health")
def data_composite_pipeline_health(
ctx: typer.Context,
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
start_time: Annotated[str, typer.Option("--start-time", help="开始时间")],
end_time: Annotated[str, typer.Option("--end-time", help="结束时间")],
) -> None:
_ = pipe, start_time
emit_api(
ctx,
summary="读取管道健康预测成功",
method="GET",
path="/composite/pipeline-health-prediction",
params={
"network_name": require_network(runtime_context(ctx)),
"query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
},
require_auth=True,
require_project=True,
require_network_ctx=True,
)
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
mapping = {
("device", "schema"): ("/getscadadeviceschema/", {}),
("device", "get"): ("/getscadadevice/", {"id_param": "id"}),
("device", "list"): ("/getallscadadevices/", {}),
("device-data", "schema"): ("/getscadadevicedataschema/", {}),
("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}),
("element", "schema"): ("/getscadaelementschema/", {}),
("element", "get"): ("/getscadaelement/", {"id_param": "id"}),
("element", "list"): ("/getscadaelements/", {}),
("info", "schema"): ("/getscadainfoschema/", {}),
("info", "get"): ("/getscadainfo/", {"id_param": "id"}),
("info", "list"): ("/getallscadainfo/", {}),
}
result = mapping.get((kind, action))
if result is None:
raise CLIError(
"CLI 参数错误",
code="INVALID_SCADA_KIND",
message=f"unsupported scada {action} kind: {kind}",
exit_code=2,
)
return result
@data_scada_app.command("schema")
def data_scada_schema(
ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")],
) -> None:
runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind, "schema")
emit_api(
ctx,
summary="读取 SCADA schema 成功",
method="GET",
path=path,
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_scada_app.command("get")
def data_scada_get(
ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")],
id: Annotated[str, typer.Option("--id", help="记录 ID")],
) -> None:
runtime = runtime_context(ctx)
path, meta = _scada_mapping(kind, "get")
params = {"network": require_network(runtime), meta["id_param"]: id}
emit_api(
ctx,
summary="读取 SCADA 数据成功",
method="GET",
path=path,
params=params,
require_auth=True,
require_network_ctx=True,
)
@data_scada_app.command("list")
def data_scada_list(
ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="device|element|info")],
) -> None:
runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind, "list")
emit_api(
ctx,
summary="读取 SCADA 列表成功",
method="GET",
path=path,
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_scheme_app.command("schema")
def data_scheme_schema(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取方案 schema 成功",
method="GET",
path="/getschemeschema/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_scheme_app.command("get")
def data_scheme_get(
ctx: typer.Context,
name: Annotated[str, typer.Option("--name", help="方案名称")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取方案成功",
method="GET",
path="/getscheme/",
params={"network": require_network(runtime), "schema_name": name},
require_auth=True,
require_network_ctx=True,
)
@data_scheme_app.command("list")
def data_scheme_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取方案列表成功",
method="GET",
path="/getallschemes/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("keys")
def data_extension_keys(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据键成功",
method="GET",
path="/getallextensiondatakeys/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("get")
def data_extension_get(
ctx: typer.Context,
key: Annotated[str, typer.Option("--key", help="扩展键")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据成功",
method="GET",
path="/getextensiondata/",
params={"network": require_network(runtime), "key": key},
require_auth=True,
require_network_ctx=True,
)
@data_extension_app.command("list")
def data_extension_list(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取扩展数据列表成功",
method="GET",
path="/getallextensiondata/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_misc_app.command("sensor-placements")
def data_misc_sensor_placements(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取传感器位置成功",
method="GET",
path="/getallsensorplacements/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_misc_app.command("burst-location-results")
def data_misc_burst_location_results(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取爆管定位结果成功",
method="GET",
path="/getallburstlocateresults/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
-144
View File
@@ -1,144 +0,0 @@
from __future__ import annotations
from typing import Annotated
import typer
from .apps import component_option_app, network_app
from .common import emit_api, runtime_context
from .core import CLIError, require_network
@network_app.command("get-node-properties")
def network_get_node_properties(
ctx: typer.Context,
node: Annotated[str, typer.Option("--node", help="节点 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取节点属性成功",
method="GET",
path="/getnodeproperties/",
params={"network": require_network(runtime), "node": node},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-link-properties")
def network_get_link_properties(
ctx: typer.Context,
link: Annotated[str, typer.Option("--link", help="管线 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取管线属性成功",
method="GET",
path="/getlinkproperties/",
params={"network": require_network(runtime), "link": link},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-junction-properties")
def network_get_all_junction_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部节点属性成功",
method="GET",
path="/getalljunctionproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pipe-properties")
def network_get_all_pipe_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部管道属性成功",
method="GET",
path="/getallpipeproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@component_option_app.command("schema")
def component_option_schema(
ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")],
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
) -> None:
runtime = runtime_context(ctx)
path = _component_option_path(kind, schema=True)
params = {"network": require_network(runtime)}
if kind == "pump-energy" and pump:
params["pump"] = pump
emit_api(
ctx,
summary="读取选项 schema 成功",
method="GET",
path=path,
params=params,
require_auth=True,
require_network_ctx=True,
)
@component_option_app.command("get")
def component_option_get(
ctx: typer.Context,
kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")],
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
) -> None:
runtime = runtime_context(ctx)
path = _component_option_path(kind, schema=False)
params = {"network": require_network(runtime)}
if kind == "pump-energy":
if not pump:
raise CLIError(
"CLI 参数错误",
code="PUMP_REQUIRED",
message="--pump is required when --kind pump-energy",
exit_code=2,
)
params["pump"] = pump
emit_api(
ctx,
summary="读取选项属性成功",
method="GET",
path=path,
params=params,
require_auth=True,
require_network_ctx=True,
)
def _component_option_path(kind: str, *, schema: bool) -> str:
routes = {
("time", True): "/gettimeschema",
("time", False): "/gettimeproperties/",
("energy", True): "/getenergyschema/",
("energy", False): "/getenergyproperties/",
("pump-energy", True): "/getpumpenergyschema/",
("pump-energy", False): "/getpumpenergyproperties//",
("network", True): "/getoptionschema/",
("network", False): "/getoptionproperties/",
}
path = routes.get((kind, schema))
if path is None:
raise CLIError(
"CLI 参数错误",
code="INVALID_KIND",
message="--kind must be one of time, energy, pump-energy, network",
exit_code=2,
)
return path
-63
View File
@@ -1,63 +0,0 @@
from __future__ import annotations
from typing import Any
import typer
from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_json
def runtime_context(ctx: typer.Context):
obj = ctx.obj
if not isinstance(obj, dict):
obj = {}
ctx.obj = obj
cached_runtime = obj.get("_runtime_context")
if cached_runtime is not None:
return cached_runtime
runtime = build_runtime_context(
server=obj.get("server"),
auth_stdin=obj.get("auth_stdin", False),
scheme=obj.get("scheme"),
timeout=obj.get("timeout", DEFAULT_TIMEOUT),
request_id=obj.get("request_id"),
)
obj["_runtime_context"] = runtime
return runtime
def emit_api(
ctx: typer.Context,
*,
summary: str,
method: str,
path: str,
params: dict[str, Any] | None = None,
json_body: Any = None,
require_auth: bool = True,
require_project: bool = False,
require_network_ctx: bool = False,
require_username_ctx: bool = False,
next_commands: list[str] | None = None,
) -> None:
runtime = runtime_context(ctx)
data, duration_ms = request_json(
runtime,
method=method,
path=path,
params=params,
json_body=json_body,
require_auth=require_auth,
require_project=require_project,
require_network_ctx=require_network_ctx,
require_username_ctx=require_username_ctx,
)
emit_success(
summary=summary,
data=data,
ctx=runtime,
duration_ms=duration_ms,
next_commands=next_commands,
)
-629
View File
@@ -1,629 +0,0 @@
from __future__ import annotations
import json
import os
import sys
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping
import requests
import typer
SCHEMA_VERSION = "tjwater-cli/v1"
CLI_NAME = "tjwater-cli"
DEFAULT_TIMEOUT = 60
DEFAULT_SERVER = "http://192.168.1.114:8000"
class CLIError(Exception):
def __init__(
self,
summary: str,
*,
code: str,
message: str,
exit_code: int,
retryable: bool = False,
next_commands: list[str] | None = None,
data: Any = None,
) -> None:
super().__init__(message)
self.summary = summary
self.code = code
self.message = message
self.exit_code = exit_code
self.retryable = retryable
self.next_commands = next_commands or []
self.data = data
@dataclass(frozen=True)
class AuthContext:
server: str | None = None
access_token: str | None = None
project_id: str | None = None
user_id: str | None = None
username: str | None = None
network: str | None = None
headers: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class RuntimeContext:
server: str | None
auth: AuthContext
scheme: str | None
timeout: int
request_id: str
@dataclass(frozen=True)
class CommandOptionDoc:
name: str
description: str
required: bool = False
repeated: bool = False
default: Any = None
@dataclass(frozen=True)
class CommandDoc:
path: tuple[str, ...]
summary: str
description: str
options: tuple[CommandOptionDoc, ...] = ()
examples: tuple[str, ...] = ()
next_commands: tuple[str, ...] = ()
output: str = "标准 JSON 输出"
def _pick(mapping: Mapping[str, Any], *keys: str) -> Any:
for key in keys:
value = mapping.get(key)
if value not in (None, ""):
return value
return None
def load_auth_context(auth_stdin: bool = False) -> AuthContext:
if auth_stdin:
raw = json.loads(sys.stdin.read())
else:
extra_headers = os.getenv("TJWATER_EXTRA_HEADERS")
raw = {
"server": os.getenv("TJWATER_SERVER"),
"access_token": os.getenv("TJWATER_ACCESS_TOKEN"),
"project_id": os.getenv("TJWATER_PROJECT_ID"),
"user_id": os.getenv("TJWATER_USER_ID"),
"username": os.getenv("TJWATER_USERNAME"),
"network": os.getenv("TJWATER_NETWORK"),
"headers": json.loads(extra_headers) if extra_headers else {},
}
headers = raw.get("headers") or {}
if not isinstance(headers, dict):
raise CLIError(
"认证失败",
code="AUTH_CONTEXT_INVALID",
message="auth context headers must be a JSON object",
exit_code=3,
)
return AuthContext(
server=_pick(raw, "server", "base_url"),
access_token=_pick(raw, "access_token", "token", "accessToken"),
project_id=_pick(raw, "project_id", "projectId", "x_project_id"),
user_id=_pick(raw, "user_id", "userId", "x_user_id"),
username=_pick(raw, "username", "preferred_username"),
network=_pick(raw, "network", "project_code", "projectCode", "project"),
headers={str(key): str(value) for key, value in headers.items()},
)
def build_runtime_context(
*,
server: str | None,
auth_stdin: bool = False,
scheme: str | None,
timeout: int,
request_id: str | None,
) -> RuntimeContext:
auth = load_auth_context(auth_stdin=auth_stdin)
resolved_request_id = request_id or str(uuid.uuid4())
return RuntimeContext(
server=server or auth.server or DEFAULT_SERVER,
auth=auth,
scheme=scheme,
timeout=timeout,
request_id=resolved_request_id,
)
def require_server(ctx: RuntimeContext) -> str:
if ctx.server:
return ctx.server.rstrip("/")
raise CLIError(
"认证失败",
code="SERVER_REQUIRED",
message="missing server URL; use --server or include server in auth context",
exit_code=3,
)
def require_access_token(ctx: RuntimeContext) -> str:
if ctx.auth.access_token:
return ctx.auth.access_token
raise CLIError(
"认证失败",
code="UNAUTHENTICATED",
message="missing access token for agent context",
exit_code=3,
next_commands=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"],
)
def require_project_id(ctx: RuntimeContext) -> str:
if ctx.auth.project_id:
return ctx.auth.project_id
raise CLIError(
"认证失败",
code="PROJECT_CONTEXT_REQUIRED",
message="missing project_id for agent context",
exit_code=3,
next_commands=["add project_id to auth context"],
)
def require_network(ctx: RuntimeContext) -> str:
if ctx.auth.network:
return ctx.auth.network
raise CLIError(
"认证失败",
code="NETWORK_CONTEXT_REQUIRED",
message="missing network in auth context for legacy network-based endpoints",
exit_code=3,
next_commands=["add network to auth context"],
)
def require_username(ctx: RuntimeContext) -> str:
if ctx.auth.username:
return ctx.auth.username
raise CLIError(
"认证失败",
code="USERNAME_CONTEXT_REQUIRED",
message="missing username in auth context",
exit_code=3,
next_commands=["add username to auth context"],
)
def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None:
scheme = explicit_scheme or ctx.scheme
if required and not scheme:
raise CLIError(
"CLI 参数错误",
code="SCHEME_REQUIRED",
message="missing scheme; use --scheme",
exit_code=2,
)
return scheme
def parse_time_with_timezone(value: str, *, option_name: str) -> datetime:
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise CLIError(
"CLI 参数错误",
code="INVALID_TIME",
message=f"{option_name} must be a valid ISO 8601 / RFC 3339 timestamp",
exit_code=2,
) from exc
if parsed.tzinfo is None:
raise CLIError(
"CLI 参数错误",
code="TIMEZONE_REQUIRED",
message=f"{option_name} must include an explicit timezone offset",
exit_code=2,
)
return parsed
def read_json_input(path: Path, *, label: str) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise CLIError(
"CLI 参数错误",
code="INPUT_NOT_FOUND",
message=f"{label} file not found: {path}",
exit_code=2,
) from exc
except json.JSONDecodeError as exc:
raise CLIError(
"CLI 参数错误",
code="INPUT_INVALID_JSON",
message=f"{label} file must be valid JSON: {path}",
exit_code=2,
) from exc
def parse_burst_file(path: Path) -> tuple[list[str], list[float]]:
raw = read_json_input(path, label="burst")
if isinstance(raw, dict) and "bursts" in raw:
raw = raw["bursts"]
if isinstance(raw, dict) and "burst_ID" in raw and "burst_size" in raw:
ids = [str(item) for item in raw["burst_ID"]]
sizes = [float(item) for item in raw["burst_size"]]
if len(ids) != len(sizes):
raise CLIError(
"CLI 参数错误",
code="BURST_FILE_INVALID",
message="burst file burst_ID and burst_size must have the same length",
exit_code=2,
)
return ids, sizes
if isinstance(raw, list):
ids: list[str] = []
sizes: list[float] = []
for item in raw:
if not isinstance(item, dict) or "id" not in item or "size" not in item:
raise CLIError(
"CLI 参数错误",
code="BURST_FILE_INVALID",
message="burst file items must contain id and size",
exit_code=2,
)
ids.append(str(item["id"]))
sizes.append(float(item["size"]))
return ids, sizes
raise CLIError(
"CLI 参数错误",
code="BURST_FILE_INVALID",
message="burst file must be a JSON array or object with burst_ID/burst_size",
exit_code=2,
)
def parse_valve_setting_file(path: Path) -> tuple[list[str], list[float]]:
raw = read_json_input(path, label="valve-setting")
if isinstance(raw, dict) and "valves" in raw and "valves_k" in raw:
valves = [str(item) for item in raw["valves"]]
openings = [float(item) for item in raw["valves_k"]]
if len(valves) != len(openings):
raise CLIError(
"CLI 参数错误",
code="VALVE_SETTING_INVALID",
message="valves and valves_k must have the same length",
exit_code=2,
)
return valves, openings
if isinstance(raw, list):
valves: list[str] = []
openings: list[float] = []
for item in raw:
if not isinstance(item, dict) or "valve" not in item or "opening" not in item:
raise CLIError(
"CLI 参数错误",
code="VALVE_SETTING_INVALID",
message="valve-setting items must contain valve and opening",
exit_code=2,
)
valves.append(str(item["valve"]))
openings.append(float(item["opening"]))
return valves, openings
raise CLIError(
"CLI 参数错误",
code="VALVE_SETTING_INVALID",
message="valve-setting file must be a JSON array or object with valves/valves_k",
exit_code=2,
)
def parse_optional_dataset_file(path: Path | None, *, label: str) -> Any:
if path is None:
return None
return read_json_input(path, label=label)
def build_headers(
ctx: RuntimeContext,
*,
require_auth: bool,
require_project: bool,
) -> dict[str, str]:
headers = {
"Accept": "application/json, text/plain, */*",
"X-Request-Id": ctx.request_id,
}
headers.update(ctx.auth.headers)
if require_auth:
headers["Authorization"] = f"Bearer {require_access_token(ctx)}"
elif ctx.auth.access_token:
headers["Authorization"] = f"Bearer {ctx.auth.access_token}"
if require_project:
headers["X-Project-Id"] = require_project_id(ctx)
elif ctx.auth.project_id:
headers["X-Project-Id"] = ctx.auth.project_id
if ctx.auth.user_id:
headers["X-User-Id"] = ctx.auth.user_id
return headers
def _extract_error_message(response: requests.Response) -> str:
try:
payload = response.json()
except ValueError:
text = response.text.strip()
return text or f"http {response.status_code}"
if isinstance(payload, dict):
detail = payload.get("detail")
if isinstance(detail, str):
return detail
if isinstance(detail, list):
return "; ".join(json.dumps(item, ensure_ascii=False) for item in detail)
message = payload.get("message")
if isinstance(message, str):
return message
return json.dumps(payload, ensure_ascii=False)
def map_http_status_to_exit_code(status_code: int) -> int:
if status_code in (400, 422):
return 2
if status_code == 401:
return 3
if status_code == 403:
return 4
if status_code == 404:
return 5
if status_code in (409, 412):
return 6
return 7
def _parse_response_body(response: requests.Response) -> Any:
if response.status_code == 204 or not response.content:
return {}
content_type = response.headers.get("content-type", "").lower()
if "application/json" in content_type:
payload = response.json()
if isinstance(payload, dict) and payload.get("status") == "error":
raise CLIError(
"服务端错误",
code="SERVER_ERROR",
message=str(payload.get("message") or "server returned error status"),
exit_code=7,
data=payload,
)
return payload
text = response.text
if text:
return {"report": text}
return {}
def request_json(
ctx: RuntimeContext,
*,
method: str,
path: str,
params: dict[str, Any] | None = None,
json_body: Any = None,
require_auth: bool = True,
require_project: bool = False,
require_network_ctx: bool = False,
require_username_ctx: bool = False,
) -> tuple[Any, int]:
require_server(ctx)
if require_network_ctx:
require_network(ctx)
if require_username_ctx:
require_username(ctx)
url = f"{require_server(ctx)}/api/v1{path}"
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
started = time.monotonic()
try:
response = requests.request(
method=method.upper(),
url=url,
params=params,
json=json_body,
headers=headers,
timeout=ctx.timeout,
)
except requests.Timeout as exc:
raise CLIError(
"请求超时",
code="REQUEST_TIMEOUT",
message=f"request timed out after {ctx.timeout} seconds",
exit_code=7,
retryable=True,
) from exc
except requests.RequestException as exc:
raise CLIError(
"连接失败",
code="REQUEST_FAILED",
message=str(exc),
exit_code=7,
retryable=True,
) from exc
duration_ms = int((time.monotonic() - started) * 1000)
if not response.ok:
raise CLIError(
"请求失败",
code=f"HTTP_{response.status_code}",
message=_extract_error_message(response),
exit_code=map_http_status_to_exit_code(response.status_code),
retryable=response.status_code >= 500,
)
return _parse_response_body(response), duration_ms
def request_bytes(
ctx: RuntimeContext,
*,
method: str,
path: str,
params: dict[str, Any] | None = None,
require_auth: bool = True,
require_project: bool = False,
require_network_ctx: bool = False,
) -> tuple[bytes, int]:
require_server(ctx)
if require_network_ctx:
require_network(ctx)
url = f"{require_server(ctx)}/api/v1{path}"
headers = build_headers(ctx, require_auth=require_auth, require_project=require_project)
started = time.monotonic()
try:
response = requests.request(
method=method.upper(),
url=url,
params=params,
headers=headers,
timeout=ctx.timeout,
)
except requests.Timeout as exc:
raise CLIError(
"请求超时",
code="REQUEST_TIMEOUT",
message=f"request timed out after {ctx.timeout} seconds",
exit_code=7,
retryable=True,
) from exc
except requests.RequestException as exc:
raise CLIError(
"连接失败",
code="REQUEST_FAILED",
message=str(exc),
exit_code=7,
retryable=True,
) from exc
duration_ms = int((time.monotonic() - started) * 1000)
if not response.ok:
raise CLIError(
"请求失败",
code=f"HTTP_{response.status_code}",
message=_extract_error_message(response),
exit_code=map_http_status_to_exit_code(response.status_code),
retryable=response.status_code >= 500,
)
return response.content, duration_ms
def build_success_payload(
*,
summary: str,
data: Any,
server: str | None,
request_id: str,
duration_ms: int,
next_commands: list[str] | None = None,
) -> dict[str, Any]:
return {
"ok": True,
"schema_version": SCHEMA_VERSION,
"summary": summary,
"data": data,
"metadata": {
"request_id": request_id,
"server": server,
"duration_ms": duration_ms,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
},
"next_commands": next_commands or [],
}
def build_failure_payload(
*,
summary: str,
code: str,
message: str,
retryable: bool,
server: str | None,
request_id: str | None,
next_commands: list[str] | None = None,
data: Any = None,
) -> dict[str, Any]:
return {
"ok": False,
"schema_version": SCHEMA_VERSION,
"summary": summary,
"error": {
"code": code,
"message": message,
"retryable": retryable,
},
"data": data,
"metadata": {
"request_id": request_id,
"server": server,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
},
"next_commands": next_commands or [],
}
def emit_success(
*,
summary: str,
data: Any,
ctx: RuntimeContext,
duration_ms: int,
next_commands: list[str] | None = None,
) -> None:
typer.echo(
json.dumps(
build_success_payload(
summary=summary,
data=data,
server=ctx.server,
request_id=ctx.request_id,
duration_ms=duration_ms,
next_commands=next_commands,
),
ensure_ascii=False,
)
)
def emit_failure(
*,
summary: str,
code: str,
message: str,
exit_code: int,
retryable: bool,
server: str | None,
request_id: str | None,
next_commands: list[str] | None = None,
data: Any = None,
) -> int:
typer.echo(
json.dumps(
build_failure_payload(
summary=summary,
code=code,
message=message,
retryable=retryable,
server=server,
request_id=request_id,
next_commands=next_commands,
data=data,
),
ensure_ascii=False,
)
)
return exit_code
-15
View File
@@ -1,15 +0,0 @@
from __future__ import annotations
import click
import typer.core
class TJWaterGroup(typer.core.TyperGroup):
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
super().format_help(ctx, formatter)
from .helping import build_group_help_appendix
appendix = build_group_help_appendix(ctx)
if appendix:
formatter.write_paragraph()
formatter.write_text(appendix)
-415
View File
@@ -1,415 +0,0 @@
from __future__ import annotations
import json
from typing import Annotated, Any
import click
import typer
from .apps import GROUP_HELP_APPS, TOP_LEVEL_COMMANDS, app
from .core import CLIError
from .registry import (
get_command_doc,
get_group_summary,
has_subcommands,
is_hidden_path,
list_capabilities,
list_subcommands,
)
def _click_root_command() -> click.Command:
# Must stay lazy: the click tree is only complete after command modules import.
return typer.main.get_command(app)
def _normalize_command_path(tokens: list[str]) -> tuple[str, ...]:
while tokens and tokens[0] not in TOP_LEVEL_COMMANDS:
tokens = tokens[1:]
return tuple(tokens)
def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]:
if click_ctx is None:
return ()
return _normalize_command_path(click_ctx.command_path.split())
def _build_click_context(path: tuple[str, ...]) -> click.Context | None:
root = _click_root_command()
ctx: click.Context = click.Context(root, info_name="tjwater-cli")
command: click.Command = root
for token in path:
if not isinstance(command, click.Group):
return None
next_command = command.commands.get(token)
if next_command is None:
return None
ctx = click.Context(next_command, info_name=token, parent=ctx)
command = next_command
return ctx
def build_usage(path: tuple[str, ...]) -> str | None:
ctx = _build_click_context(path)
if ctx is None:
return None
parts = ["tjwater-cli", *path]
for parameter in ctx.command.params:
if not isinstance(parameter, click.Option):
continue
if "--help" in parameter.opts:
continue
option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
if parameter.is_flag:
parts.append(f"--{option_name}" if parameter.required else f"[--{option_name}]")
continue
placeholder = option_name.upper().replace("-", "_")
if parameter.required:
parts.extend([f"--{option_name}", f"<{placeholder}>"])
else:
parts.append(f"[--{option_name} <{placeholder}>]")
return " ".join(parts)
def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]:
ctx = _build_click_context(path)
if ctx is None:
return []
options: list[dict[str, Any]] = []
for parameter in ctx.command.params:
if not isinstance(parameter, click.Option):
continue
if "--help" in parameter.opts:
continue
cli_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
options.append(
{
"name": cli_name,
"description": parameter.help or "",
"required": parameter.required,
"repeated": parameter.multiple,
"default": parameter.default,
}
)
return options
def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
path_specific_samples: dict[tuple[tuple[str, ...], str], str] = {
(("component", "option", "schema"), "kind"): "time",
(("component", "option", "get"), "kind"): "time",
(("data", "timeseries", "composite"), "kind"): "scada-simulation",
(("data", "scada", "schema"), "kind"): "device",
(("data", "scada", "get"), "kind"): "device",
(("data", "scada", "list"), "kind"): "device",
}
if (path, option_name) in path_specific_samples:
return path_specific_samples[(path, option_name)]
if option_name == "start-time":
return "2025-01-02T03:04:05+08:00"
if option_name == "end-time":
return "2025-01-02T04:04:05+08:00"
if option_name == "date":
return "2025-01-02"
if option_name == "duration":
return "30"
if option_name == "kind":
return "time"
if option_name == "mode":
return "close"
if option_name == "scheme":
return "baseline"
if option_name == "output":
return "./demo.inp" if "export-inp" in path else "./output.json"
if option_name == "pump":
return "PUMP-1"
if option_name == "node":
return "J1"
if option_name == "source-node":
return "J1"
if option_name == "drainage-node":
return "J2"
if option_name in {"link", "pipe", "pipe-id", "element-id", "element"}:
return "P1"
if option_name == "flow":
return "120.5"
if option_name == "concentration":
return "0.8"
if option_name == "device-id":
return "SCADA-001"
if option_name == "burst-file":
return "./burst.json"
if option_name == "valve-setting-file":
return "./valves.json"
if option_name.endswith("-file"):
return "./input.json"
if option_name.endswith("-id"):
return "demo-id"
return "demo"
def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None = None) -> str:
ctx = _build_click_context(path)
required_option_names: list[str] = []
if ctx is not None:
required_option_names = [
next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
for parameter in ctx.command.params
if isinstance(parameter, click.Option) and "--help" not in parameter.opts and parameter.required
]
if existing_examples:
for example in existing_examples:
has_required_options = all(f"--{option_name}" in example for option_name in required_option_names)
if has_required_options:
return example
parts = ["tjwater-cli", *path]
if ctx is None:
return " ".join(parts)
for parameter in ctx.command.params:
if not isinstance(parameter, click.Option):
continue
if "--help" in parameter.opts or not parameter.required:
continue
option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "")
parts.extend([f"--{option_name}", _sample_option_value(path, option_name)])
return " ".join(parts)
def _enrich_leaf_payload(payload: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]:
enriched = dict(payload)
enriched["usage"] = build_usage(path) or payload.get("usage")
click_options = _click_option_docs(path)
if click_options:
enriched["options"] = click_options
enriched["examples"] = payload.get("examples") or []
if not enriched["examples"] or all("<" in example and ">" in example for example in enriched["examples"]):
enriched["examples"] = [_build_example(path, existing_examples=enriched["examples"])]
return enriched
def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
enriched = dict(payload)
commands: list[dict[str, Any]] = []
for command in payload.get("commands", []):
command_item = dict(command)
path = tuple(command_item["command"].split())
doc = get_command_doc(path)
if doc is None and has_subcommands(path):
command_item["usage"] = f"tjwater-cli {' '.join(path)} help"
command_item["example"] = f"tjwater-cli {' '.join(path)} help"
else:
existing_examples = [] if doc is None else list(doc.get("examples", []))
command_item["usage"] = build_usage(path) or command_item.get("usage")
command_item["example"] = _build_example(path, existing_examples=existing_examples)
commands.append(command_item)
enriched["commands"] = commands
return enriched
def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, bool]:
if not path:
return list_capabilities(), True
payload = get_command_doc(path)
if payload is not None:
return _enrich_leaf_payload(payload, path), False
if has_subcommands(path):
return _enrich_index_payload(list_subcommands(path, get_group_summary(path))), True
return None, False
def emit_help_payload(payload: dict[str, Any]) -> None:
typer.echo(json.dumps(payload, ensure_ascii=False))
def merge_next_commands(*groups: list[str] | None) -> list[str]:
merged: list[str] = []
seen: set[str] = set()
for group in groups:
for command in group or []:
if command in seen:
continue
seen.add(command)
merged.append(command)
return merged
def merge_error_data(primary: Any, secondary: Any) -> Any:
if primary is None:
return secondary
if secondary is None:
return primary
if isinstance(primary, dict) and isinstance(secondary, dict):
return {**secondary, **primary}
return primary
def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str]]:
command_path = context_command_path(click_ctx)
usage = build_usage(command_path) if command_path else None
if command_path:
if command_path[-1] == "help":
group_path = command_path[:-1]
if group_path:
return (
{
"command_group": " ".join(group_path),
"usage": f"tjwater-cli {' '.join(group_path)} help",
"examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
},
merge_next_commands(
[f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
["tjwater-cli help"],
),
)
payload, is_index = resolve_help_payload(command_path)
if payload is not None and not is_index:
return (
{
"command": payload["command"],
"usage": payload.get("usage") or usage,
"examples": payload.get("examples", []),
},
merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]),
)
if payload is not None and is_index:
return (
{
"command_group": " ".join(command_path),
"usage": f"tjwater-cli {' '.join(command_path)} help",
"examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
},
merge_next_commands(
[f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
["tjwater-cli help"],
),
)
return ({"usage": usage} if usage else None, ["tjwater-cli help"])
def classify_click_error(exc: click.ClickException) -> tuple[str, str]:
if isinstance(exc, click.NoSuchOption):
return "未知选项", "UNKNOWN_OPTION"
if isinstance(exc, click.MissingParameter):
return "缺少参数", "MISSING_PARAMETER"
if isinstance(exc, click.BadParameter):
return "参数无效", "INVALID_PARAMETER"
message = exc.format_message()
if "No such command" in message:
return "未找到命令", "COMMAND_NOT_FOUND"
return "CLI 参数错误", "USAGE_ERROR"
def _build_root_help_epilog() -> str:
return "\n".join(
[
"\b",
"Examples:",
" tjwater-cli help",
" tjwater-cli help simulation run",
" tjwater-cli simulation run --help",
]
)
def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
lines = ["\b"]
description = payload.get("description")
usage = payload.get("usage")
examples = payload.get("examples", [])
next_commands = payload.get("next_commands", [])
if description:
lines.extend([f"Description: {description}", ""])
if usage:
lines.extend([f"Usage example: {usage}", ""])
if examples:
lines.append("Examples:")
lines.extend(f" {example}" for example in examples)
lines.append("")
if next_commands:
lines.append("Next steps:")
lines.extend(f" {command}" for command in next_commands)
lines.append("")
lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"])
return "\n".join(lines)
def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"]
for command in payload.get("commands", [])[:2]:
example = command.get("example")
if example:
lines.append(f" {example}")
return "\n".join(lines)
def build_group_help_appendix(click_ctx: click.Context | None) -> str | None:
path = context_command_path(click_ctx)
if not path:
return _build_root_help_epilog()
payload, is_index = resolve_help_payload(path)
if payload is None or not is_index:
return None
return _build_group_help_epilog(path, payload)
def make_group_help_handler(path_prefix: tuple[str, ...]):
def group_help() -> None:
payload, is_index = resolve_help_payload(path_prefix)
if payload is None:
raise CLIError(
"未找到命令",
code="COMMAND_NOT_FOUND",
message=f"unknown command path: {' '.join(path_prefix)}",
exit_code=2,
next_commands=["tjwater-cli help"],
)
emit_help_payload(payload)
group_help.__name__ = f"{'_'.join(path_prefix)}_help"
return group_help
def register_group_help_commands() -> None:
for group_app, path_prefix in GROUP_HELP_APPS:
group_app.command("help")(make_group_help_handler(path_prefix))
def apply_typer_help_metadata() -> None:
app.help = "\n".join(
[
"TJWater agent CLI",
"",
"Examples:",
" tjwater-cli help",
" tjwater-cli help simulation run",
" tjwater-cli simulation run --help",
]
)
app.short_help = "TJWater agent CLI"
for group_app, path_prefix in GROUP_HELP_APPS:
for command_info in group_app.registered_commands:
command_path = (*path_prefix, command_info.name)
if command_info.name == "help":
command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。"
command_info.short_help = command_info.help
command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"])
command_info.hidden = False
continue
payload = get_command_doc(command_path)
command_info.help = None if payload is None else str(payload.get("summary", ""))
command_info.short_help = command_info.help
command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload)
command_info.hidden = is_hidden_path(command_path)
for group_info in group_app.registered_groups:
group_path = (*path_prefix, group_info.name)
summary = get_group_summary(group_path)
group_info.help = summary
group_info.short_help = summary
group_info.hidden = is_hidden_path(group_path)
for group_info in app.registered_groups:
group_path = (group_info.name,)
summary = get_group_summary(group_path)
group_info.help = summary
group_info.short_help = summary
group_info.hidden = is_hidden_path(group_path)
-112
View File
@@ -1,112 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Annotated
import click
import typer
from click.exceptions import NoArgsIsHelpError
from . import commands_analysis, commands_data, commands_readonly # noqa: F401
from .apps import app
from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure
from .helping import (
apply_typer_help_metadata,
build_error_guidance,
classify_click_error,
emit_help_payload,
merge_error_data,
merge_next_commands,
register_group_help_commands,
resolve_help_payload,
)
@app.callback()
def root_callback(
ctx: typer.Context,
server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None,
auth_stdin: Annotated[bool, typer.Option("--auth-stdin", help="从标准输入读取认证上下文 JSON")] = False,
scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None,
timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT,
request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None,
) -> None:
ctx.obj = {
"server": server,
"auth_stdin": auth_stdin,
"scheme": scheme,
"timeout": timeout,
"request_id": request_id,
}
register_group_help_commands()
@app.command("help", context_settings={"allow_extra_args": True})
def help_command(ctx: typer.Context) -> None:
command_path = list(ctx.args)
payload, is_index = resolve_help_payload(tuple(command_path))
if payload is None:
emit_failure(
summary="未找到命令",
code="COMMAND_NOT_FOUND",
message=f"unknown command path: {' '.join(command_path)}",
exit_code=2,
retryable=False,
server=None,
request_id=None,
data={
"usage": "tjwater-cli help <command-path>",
"examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"],
},
next_commands=["tjwater-cli help", "tjwater-cli help simulation"],
)
raise typer.Exit(code=2)
emit_help_payload(payload)
# Must run at import time because tests call runner.invoke(app, ...) directly.
apply_typer_help_metadata()
def main(argv: list[str] | None = None) -> int:
try:
app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False)
return 0
except CLIError as exc:
click_ctx = click.get_current_context(silent=True)
error_data, next_commands = build_error_guidance(click_ctx)
return emit_failure(
summary=exc.summary,
code=exc.code,
message=exc.message,
exit_code=exc.exit_code,
retryable=exc.retryable,
server=None,
request_id=None,
next_commands=merge_next_commands(exc.next_commands, next_commands),
data=merge_error_data(exc.data, error_data),
)
except NoArgsIsHelpError:
return 0
except click.ClickException as exc:
click_ctx = click.get_current_context(silent=True) or exc.ctx
error_data, next_commands = build_error_guidance(click_ctx)
summary, code = classify_click_error(exc)
return emit_failure(
summary=summary,
code=code,
message=exc.format_message(),
exit_code=2,
retryable=False,
server=None,
request_id=None,
next_commands=next_commands,
data=error_data,
)
def console_entry() -> None:
raise SystemExit(main())
-632
View File
@@ -1,632 +0,0 @@
from __future__ import annotations
from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION
GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("network",): "管网节点、管线等基础属性查询命令。",
("component",): "组件选项与配置读取命令。",
("component", "option"): "组件选项查询命令。",
("simulation",): "模拟运行与调度相关命令。",
("analysis",): "分析计算与诊断相关命令。",
("analysis", "leakage"): "漏损分析相关命令。",
("analysis", "leakage", "schemes"): "漏损方案查询命令。",
("analysis", "burst-detection"): "爆管检测相关命令。",
("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。",
("analysis", "burst-location"): "爆管定位相关命令。",
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
("analysis", "risk"): "风险分析相关命令。",
("analysis", "sensor-placement"): "传感器选址相关命令。",
("data",): "时序、SCADA、方案和扩展数据查询命令。",
("data", "timeseries"): "时序数据查询命令。",
("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
("data", "timeseries", "scheme"): "方案时序查询命令。",
("data", "timeseries", "scada"): "SCADA 时序查询命令。",
("data", "timeseries", "composite"): "复合时序查询命令。",
("data", "scada"): "SCADA 元数据查询命令。",
("data", "scheme"): "方案数据查询命令。",
("data", "extension"): "扩展数据查询命令。",
("data", "misc"): "其他结果数据查询命令。",
}
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
("analysis", "burst-location"),
("analysis", "risk"),
)
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("network", "get-node-properties"): CommandDoc(
path=("network", "get-node-properties"),
summary="读取节点属性",
description="调用 /getnodeproperties/。",
options=(CommandOptionDoc("node", "节点 ID", required=True),),
examples=("tjwater-cli network get-node-properties --node J1",),
),
("network", "get-link-properties"): CommandDoc(
path=("network", "get-link-properties"),
summary="读取管线属性",
description="调用 /getlinkproperties/。",
options=(CommandOptionDoc("link", "管线 ID", required=True),),
examples=("tjwater-cli network get-link-properties --link P1",),
),
("network", "get-all-junction-properties"): CommandDoc(
path=("network", "get-all-junction-properties"),
summary="读取全部节点属性",
description="调用 /getalljunctionproperties/。",
examples=("tjwater-cli network get-all-junction-properties",),
),
("network", "get-all-pipe-properties"): CommandDoc(
path=("network", "get-all-pipe-properties"),
summary="读取全部管道属性",
description="调用 /getallpipeproperties/。",
examples=("tjwater-cli network get-all-pipe-properties",),
),
("component", "option", "schema"): CommandDoc(
path=("component", "option", "schema"),
summary="读取选项 schema",
description="kind 支持 time、energy、pump-energy、network。",
options=(
CommandOptionDoc("kind", "选项类型", required=True),
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
),
examples=(
"tjwater-cli component option schema --kind time",
"tjwater-cli component option schema --kind energy",
"tjwater-cli component option schema --kind pump-energy --pump PUMP1",
"tjwater-cli component option schema --kind network",
),
),
("component", "option", "get"): CommandDoc(
path=("component", "option", "get"),
summary="读取选项属性",
description="kind 支持 time、energy、pump-energy、network。",
options=(
CommandOptionDoc("kind", "选项类型", required=True),
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
),
examples=(
"tjwater-cli component option get --kind time",
"tjwater-cli component option get --kind energy",
"tjwater-cli component option get --kind pump-energy --pump PUMP1",
"tjwater-cli component option get --kind network",
),
),
("simulation", "run"): CommandDoc(
path=("simulation", "run"),
summary="触发指定绝对时间的模拟运行",
description="把显式带时区的 RFC3339 start-time 直接传给 /runsimulationmanuallybydate/;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续分钟数", required=True),
),
examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",),
next_commands=(
"tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
"tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
),
output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询",
),
("analysis", "burst"): CommandDoc(
path=("analysis", "burst"),
summary="执行爆管分析",
description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续秒数", required=True),
CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True),
CommandOptionDoc("scheme", "方案名称"),
),
examples=(
"tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01",
"tjwater-cli data scheme get --name burst_case_01",
"tjwater-cli data scheme list",
),
),
("analysis", "valve"): CommandDoc(
path=("analysis", "valve"),
summary="阀门工况分析。",
description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。",
options=(
CommandOptionDoc(name="mode", description="阀门操作模式:'close''isolation'", required=True),
CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"),
CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True),
CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True),
CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True),
CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"),
CommandOptionDoc(name="scheme", description="close 模式方案名称"),
),
examples=(
"tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01",
"tjwater-cli analysis valve --mode isolation --element E1 --element E2",
"tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3",
),
),
("analysis", "flushing"): CommandDoc(
path=("analysis", "flushing"),
summary="执行冲洗分析",
description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True),
CommandOptionDoc("drainage-node", "排污节点 ID", required=True),
CommandOptionDoc("flow", "冲洗流量", required=True),
CommandOptionDoc("duration", "持续秒数,默认 900"),
CommandOptionDoc("scheme", "方案名称", required=True),
),
examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",),
),
("analysis", "age"): CommandDoc(
path=("analysis", "age"),
summary="执行水龄分析",
description="调用 /age_analysis/。duration 单位为秒。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续秒数", required=True),
),
examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",),
),
("analysis", "contaminant"): CommandDoc(
path=("analysis", "contaminant"),
summary="执行污染物模拟",
description="调用 /contaminant_simulation/。duration 单位为秒。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("duration", "持续秒数", required=True),
CommandOptionDoc("source-node", "污染源节点 ID", required=True),
CommandOptionDoc("concentration", "浓度值", required=True),
CommandOptionDoc("pattern", "模式 ID"),
CommandOptionDoc("scheme", "方案名称", required=True),
),
examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",),
),
("analysis", "sensor-placement", "kmeans"): CommandDoc(
path=("analysis", "sensor-placement", "kmeans"),
summary="执行 KMeans 传感器选址",
description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。",
options=(
CommandOptionDoc("count", "传感器数量", required=True),
CommandOptionDoc("min-diameter", "最小管径,默认 0"),
CommandOptionDoc("scheme", "方案名称"),
),
examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",),
),
("analysis", "leakage", "identify"): CommandDoc(
path=("analysis", "leakage", "identify"),
summary="执行漏损识别",
description="把 CLI 时间映射到 scada_start / scada_end。",
options=(
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
CommandOptionDoc("scheme", "方案名称"),
),
examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",),
),
("analysis", "leakage", "schemes", "list"): CommandDoc(
path=("analysis", "leakage", "schemes", "list"),
summary="列出漏损方案",
description="调用 /leakage/schemes/。",
examples=("tjwater-cli analysis leakage schemes list",),
),
("analysis", "leakage", "schemes", "get"): CommandDoc(
path=("analysis", "leakage", "schemes", "get"),
summary="读取漏损方案详情",
description="调用 /leakage/schemes/{scheme_name}",
examples=("tjwater-cli analysis leakage schemes get my_scheme",),
),
("analysis", "burst-detection", "detect"): CommandDoc(
path=("analysis", "burst-detection", "detect"),
summary="执行爆管检测",
description="调用 /burst-detection/detect/。",
options=(
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
CommandOptionDoc("scheme", "方案名称"),
),
examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",),
),
("analysis", "burst-detection", "schemes", "list"): CommandDoc(
path=("analysis", "burst-detection", "schemes", "list"),
summary="列出爆管检测方案",
description="调用 /burst-detection/schemes/。",
examples=("tjwater-cli analysis burst-detection schemes list",),
),
("analysis", "burst-detection", "schemes", "get"): CommandDoc(
path=("analysis", "burst-detection", "schemes", "get"),
summary="读取爆管检测方案详情",
description="调用 /burst-detection/schemes/{scheme_name}",
examples=("tjwater-cli analysis burst-detection schemes get my_scheme",),
),
("analysis", "burst-location", "locate"): CommandDoc(
path=("analysis", "burst-location", "locate"),
summary="执行爆管定位",
description="调用 /burst-location/locate/;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。",
options=(
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
CommandOptionDoc("burst-leakage", "爆管漏水量", required=True),
CommandOptionDoc("scheme", "方案名称"),
CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"),
CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True),
CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True),
CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"),
CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"),
CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"),
),
examples=(
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01",
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json",
),
),
("analysis", "burst-location", "schemes", "list"): CommandDoc(
path=("analysis", "burst-location", "schemes", "list"),
summary="列出爆管定位方案",
description="调用 /burst-location/schemes/。",
examples=("tjwater-cli analysis burst-location schemes list",),
),
("analysis", "burst-location", "schemes", "get"): CommandDoc(
path=("analysis", "burst-location", "schemes", "get"),
summary="读取爆管定位方案详情",
description="调用 /burst-location/schemes/{scheme_name}",
examples=("tjwater-cli analysis burst-location schemes get my_scheme",),
),
("analysis", "risk", "pipe-now"): CommandDoc(
path=("analysis", "risk", "pipe-now"),
summary="读取单条管道当前风险",
description="调用 /getpiperiskprobabilitynow/。",
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
examples=("tjwater-cli analysis risk pipe-now --pipe P1",),
),
("analysis", "risk", "pipe-history"): CommandDoc(
path=("analysis", "risk", "pipe-history"),
summary="读取单条管道历史风险",
description="调用 /getpiperiskprobability/。",
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
examples=("tjwater-cli analysis risk pipe-history --pipe P1",),
),
("analysis", "risk", "network"): CommandDoc(
path=("analysis", "risk", "network"),
summary="读取全网风险",
description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。",
examples=("tjwater-cli analysis risk network",),
),
("data", "timeseries", "realtime", "links"): CommandDoc(
path=("data", "timeseries", "realtime", "links"),
summary="查询实时管道时序",
description="调用 /realtime/links。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
),
examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
),
("data", "timeseries", "realtime", "nodes"): CommandDoc(
path=("data", "timeseries", "realtime", "nodes"),
summary="查询实时节点时序",
description="调用 /realtime/nodes。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
),
examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
),
("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc(
path=("data", "timeseries", "realtime", "simulation-by-id-time"),
summary="按元素和时间查询实时模拟结果",
description="调用 /realtime/query/by-id-time。",
options=(
CommandOptionDoc("id", "元素 ID", required=True),
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True),
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
),
examples=(
"tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00",
"tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00",
),
),
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
summary="按时间和属性查询实时模拟结果",
description="调用 /realtime/query/by-time-property。",
options=(
CommandOptionDoc("type", "元素类型:pipe 或 junction", required=True),
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
CommandOptionDoc("property", "属性名", required=True),
),
examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",),
),
("data", "timeseries", "scheme", "links"): CommandDoc(
path=("data", "timeseries", "scheme", "links"),
summary="查询方案管道时序",
description="调用 /scheme/links。",
options=(
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
CommandOptionDoc("scheme", "方案名称"),
CommandOptionDoc("scheme-type", "方案类型"),
),
examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
),
("data", "timeseries", "scheme", "node-field"): CommandDoc(
path=("data", "timeseries", "scheme", "node-field"),
summary="查询方案节点字段时序",
description="调用 /scheme/nodes/{node_id}/field。",
options=(
CommandOptionDoc("node", "节点 ID", required=True),
CommandOptionDoc("field", "字段名", required=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
CommandOptionDoc("scheme", "方案名称"),
CommandOptionDoc("scheme-type", "方案类型"),
),
examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
),
("data", "timeseries", "scheme", "simulation"): CommandDoc(
path=("data", "timeseries", "scheme", "simulation"),
summary="查询方案模拟数据",
description="支持 by-id-time 与 by-scheme-time-property 两种查询。",
options=(
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
CommandOptionDoc("scheme", "方案名称"),
CommandOptionDoc("scheme-type", "方案类型"),
CommandOptionDoc("id", "元素 IDby-id-time 时必需)"),
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
CommandOptionDoc("type", "元素类型:pipe 或 junction"),
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需)"),
),
examples=(
"tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme",
"tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme",
),
),
("data", "timeseries", "scada", "query"): CommandDoc(
path=("data", "timeseries", "scada", "query"),
summary="查询 SCADA 时序",
description="device-id 会被转换成后端逗号分隔参数。",
options=(
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
CommandOptionDoc("field", "字段名"),
),
examples=(
"tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",
"tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field flow",
),
),
("data", "timeseries", "composite"): CommandDoc(
path=("data", "timeseries", "composite"),
summary="执行复合时序查询",
description="kind 支持 scada-simulation、element-simulation、element-scada。",
options=(
CommandOptionDoc("kind", "复合查询类型", required=True),
CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_idelement-simulation 为 element_id:propertyelement-scada 为 element_id", repeated=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
CommandOptionDoc("scheme", "方案名称"),
CommandOptionDoc("scheme-type", "方案类型"),
CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"),
),
examples=(
"tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
"tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
"tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned",
),
),
("data", "timeseries", "composite", "pipeline-health"): CommandDoc(
path=("data", "timeseries", "composite", "pipeline-health"),
summary="查询管道健康预测",
description="调用 /composite/pipeline-health-prediction。",
options=(
CommandOptionDoc("pipe", "管道 ID", required=True),
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
),
examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
),
("data", "scada", "schema"): CommandDoc(
path=("data", "scada", "schema"),
summary="读取 SCADA schema",
description="kind 支持 device、device-data、element、info。",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=(
"tjwater-cli data scada schema --kind device",
"tjwater-cli data scada schema --kind device-data",
"tjwater-cli data scada schema --kind element",
"tjwater-cli data scada schema --kind info",
),
),
("data", "scada", "get"): CommandDoc(
path=("data", "scada", "get"),
summary="读取单条 SCADA 元数据",
description="kind 支持 device、device-data、element、info。",
options=(
CommandOptionDoc("kind", "SCADA 数据类型", required=True),
CommandOptionDoc("id", "记录 ID", required=True),
),
examples=(
"tjwater-cli data scada get --kind device --id D1",
"tjwater-cli data scada get --kind element --id E1",
),
),
("data", "scada", "list"): CommandDoc(
path=("data", "scada", "list"),
summary="列出 SCADA 元数据",
description="kind 支持 device、element、infodevice-data 当前后端无 list 接口。",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=(
"tjwater-cli data scada list --kind device",
"tjwater-cli data scada list --kind element",
"tjwater-cli data scada list --kind info",
),
),
("data", "scheme", "schema"): CommandDoc(
path=("data", "scheme", "schema"),
summary="读取方案 schema",
description="调用 /getschemeschema/。",
examples=("tjwater-cli data scheme schema",),
),
("data", "scheme", "get"): CommandDoc(
path=("data", "scheme", "get"),
summary="读取单条方案",
description="调用 /getscheme/。",
options=(CommandOptionDoc("name", "方案名称", required=True),),
examples=("tjwater-cli data scheme get --name my_scheme",),
),
("data", "scheme", "list"): CommandDoc(
path=("data", "scheme", "list"),
summary="列出方案",
description="调用 /getallschemes/。",
examples=("tjwater-cli data scheme list",),
),
("data", "extension", "keys"): CommandDoc(
path=("data", "extension", "keys"),
summary="列出扩展数据键",
description="调用 /getallextensiondatakeys/。",
examples=("tjwater-cli data extension keys",),
),
("data", "extension", "get"): CommandDoc(
path=("data", "extension", "get"),
summary="读取扩展数据",
description="调用 /getextensiondata/。",
options=(CommandOptionDoc("key", "扩展键", required=True),),
examples=("tjwater-cli data extension get --key my_key",),
),
("data", "extension", "list"): CommandDoc(
path=("data", "extension", "list"),
summary="列出扩展数据",
description="调用 /getallextensiondata/。",
examples=("tjwater-cli data extension list",),
),
("data", "misc", "sensor-placements"): CommandDoc(
path=("data", "misc", "sensor-placements"),
summary="列出传感器布置结果",
description="调用 /getallsensorplacements/。",
examples=("tjwater-cli data misc sensor-placements",),
),
("data", "misc", "burst-location-results"): CommandDoc(
path=("data", "misc", "burst-location-results"),
summary="列出爆管定位结果",
description="调用 /getallburstlocateresults/。",
examples=("tjwater-cli data misc burst-location-results",),
),
}
def _build_examples(doc: CommandDoc) -> list[str]:
return list(doc.examples) if doc.examples else [_build_usage(doc)]
def _is_hidden_path(path: tuple[str, ...]) -> bool:
return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES)
def is_hidden_path(path: tuple[str, ...]) -> bool:
return _is_hidden_path(path)
def has_subcommands(path_prefix: tuple[str, ...]) -> bool:
return any(
not _is_hidden_path(doc.path)
and doc.path[: len(path_prefix)] == path_prefix
and len(doc.path) > len(path_prefix)
for doc in COMMAND_DOCS.values()
)
def get_group_summary(path_prefix: tuple[str, ...]) -> str:
return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令")
def list_capabilities() -> dict[str, object]:
seen: set[tuple[str, ...]] = set()
commands: list[dict[str, str]] = []
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
if _is_hidden_path(doc.path):
continue
prefix = doc.path[:1]
if prefix in seen:
continue
seen.add(prefix)
commands.append(
{
"command": " ".join(prefix),
"summary": get_group_summary(prefix),
}
)
return {
"ok": True,
"schema_version": SCHEMA_VERSION,
"summary": "可用一级菜单",
"menu_level": 1,
"commands": commands,
}
def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None:
if _is_hidden_path(path):
return None
doc = COMMAND_DOCS.get(path)
if doc is None:
return None
return {
"ok": True,
"schema_version": SCHEMA_VERSION,
"summary": doc.summary,
"command": " ".join(doc.path),
"description": doc.description,
"usage": _build_usage(doc),
"options": [
{
"name": option.name,
"description": option.description,
"required": option.required,
"repeated": option.repeated,
"default": option.default,
}
for option in doc.options
],
"examples": _build_examples(doc),
"next_commands": list(doc.next_commands),
"output": doc.output,
}
def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]:
seen: set[str] = set()
commands: list[dict[str, str]] = []
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
if _is_hidden_path(doc.path):
continue
if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix):
continue
subcommand = doc.path[len(path_prefix)]
if subcommand in seen:
continue
seen.add(subcommand)
current_path = (*path_prefix, subcommand)
is_group = has_subcommands(current_path)
usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
commands.append(
{
"command": " ".join(current_path),
"summary": get_group_summary(current_path) if is_group else doc.summary,
"usage": usage,
"example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0],
}
)
return {
"ok": True,
"schema_version": SCHEMA_VERSION,
"summary": summary or get_group_summary(path_prefix),
"commands": commands,
}
def _build_usage(doc: CommandDoc) -> str:
parts = ["tjwater-cli", *doc.path]
for option in doc.options:
placeholder = option.name.upper().replace("-", "_")
if option.required:
parts.extend([f"--{option.name}", f"<{placeholder}>"])
else:
parts.append(f"[--{option.name} <{placeholder}>]")
return " ".join(parts)
-436
View File
@@ -1,436 +0,0 @@
# Agent CLI 接口范围确认
本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。
## 结论
首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。
```text
tjwater-cli network
tjwater-cli component
tjwater-cli simulation
tjwater-cli analysis
tjwater-cli data
tjwater-cli help
```
首批默认不暴露:
- 会修改 network 的接口:`add*``set*``delete*``generate*`
- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制
- 数据写入/清理接口:insert、update、delete、clean、clear、batch store
- 用户管理接口:创建、更新、删除、激活、停用
- 快照回滚和批量命令执行接口:undo、redo、pick、batch
## 设计原则
- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。
- 首批只暴露 `schema``list``get``exists`、只读计算和分析类能力。
- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。
- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。
- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`
- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。
- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。
- 高频命令可以提供 alias,但文档和 skill 只写规范命令。
## 分级约束
| 顶层命令 | 二级范围 | 说明 |
|---|---|---|
| `network` | `get-node-properties``get-link-properties` | 管网节点/管线属性查询,只读 |
| `component` | `option` | EPANET 选项设置,只读 |
| `simulation` | `run` | 模拟运行 |
| `analysis` | `burst``valve``flushing``age``contaminant``sensor-placement``leakage``burst-detection``burst-location``risk` | 任务级分析 |
| `data` | `timeseries``scada``scheme``extension``misc` | 数据查询 |
| `help` | `COMMAND` | Agent 能力发现和命令说明 |
命令深度建议:
- 常规命令不超过 3 层:`tjwater-cli component option get`
- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links`
- `risk` 归入 `analysis risk`
- `scada``scheme``extension` 归入 `data`
## 全局上下文与通用参数
首批 CLI 建议统一支持以下全局参数:
```text
--server URL
--auth-context PATH
--scheme SCHEME
--timeout SEC
--request-id ID
```
参数含义:
| 参数 | 含义 | 作用域 | 说明 |
|---|---|---|---|
| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 |
| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 |
| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 |
| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 |
| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 |
约束:
- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。
- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。
- `--server``--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。
- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。
参数表达建议:
- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。
- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。
- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE``NODE:VALUE``VALVE:OPENING` 这类 shell 内联 DSL。
- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。
## 首批 CLI 范围
### Network
来源:
```text
app/api/v1/endpoints/network/*.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 |
| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 |
| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 |
| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 |
暂不暴露:
```text
add*
set*
delete*
generate*
POST /generatedistrictmeteringarea/
POST /generatesubdistrictmeteringarea/
POST /generateservicearea/
POST /generatevirtualdistrict/
```
备注:`GET /settitle/` 语义是修改标题,首批不暴露。
### Component
来源:
```text
app/api/v1/endpoints/components/*.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema |
| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 |
| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema |
| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 |
| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema |
| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 |
| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema |
| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 |
暂不暴露:
```text
POST /addcurve/
POST /setcurveproperties/
POST /deletecurve/
POST /addpattern/
POST /setpatternproperties/
POST /deletepattern/
POST /settimeproperties/
POST /setenergyproperties/
GET /setpumpenergyproperties//
POST /setoptionproperties/
POST /setcontrolproperties/
POST /setruleproperties/
POST /setqualityproperties/
POST /setemitterproperties/
POST /setsource/
POST /addsource/
POST /deletesource/
POST /setreaction/
POST /setpipereaction/
POST /settankreaction/
POST /setmixing/
POST /addmixing/
POST /deletemixing/
POST /setvertexproperties/
POST /addvertex/
POST /deletevertex/
POST /setlabelproperties/
POST /addlabel/
POST /deletelabel/
POST /setbackdropproperties/
```
备注:
- `options` 当前实际只读接口分为 4 组:`time``energy``pump-energy``network`
- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`
- 后端现有路径 `GET /getpumpenergyproperties//``GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。
### Simulation / Analysis / Risk
来源:
```text
app/api/v1/endpoints/simulation.py
app/api/v1/endpoints/leakage.py
app/api/v1/endpoints/burst_detection.py
app/api/v1/endpoints/burst_location.py
app/api/v1/endpoints/risk.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 |
| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` |
| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/``GET /valve_isolation_analysis/` | 阀门分析;close 模式需要 `--scheme``--valve` 可重复 |
| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` |
| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 |
| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant_simulation/` | 污染物模拟 |
| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 |
| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 |
| `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/``GET /leakage/schemes/{scheme_name}` | 漏损方案查询 |
| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 |
| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/``GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 |
| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 |
| `tjwater-cli analysis burst-location schemes list\|get` | `GET /burst-location/schemes/``GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 |
| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 |
| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 |
| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/``GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 |
暂缓或暂不暴露:
```text
POST /network_project/
GET /runproject/
POST /network_update/
POST /project_management/
POST /sensorplacementscheme/create
POST /pump_failure/
POST /pressure_regulation/
POST /scheduling_analysis/
POST /daily_scheduling_analysis/
```
执行模型:
- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。
- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。
- 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。
- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。
- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`
- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。
### Data
来源:
```text
app/api/v1/endpoints/timeseries/*.py
app/api/v1/endpoints/scada.py
app/api/v1/endpoints/schemes.py
app/api/v1/endpoints/extension.py
app/api/v1/endpoints/misc.py
app/api/v1/endpoints/project_data.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 |
| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 |
| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 |
| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 |
| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links``GET /scheme/links/{link_id}/field` | 方案管道数据 |
| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 |
| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 |
| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range``GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 |
| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` |
| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py``GET` 查询接口 | `SCADA` 元数据 |
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py``GET` 接口 | 当前 project 方案查询 |
| `tjwater-cli data extension keys\|get\|list` | `extension.py``GET` 查询接口 | 当前 project 扩展数据查询 |
| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 |
| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 |
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links``nodes``simulation-by-id-time``simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
- `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
暂不暴露:
```text
POST /realtime/*/batch
DELETE /realtime/*
PATCH /realtime/*
POST /realtime/simulation/store
POST /scheme/*/batch
PATCH /scheme/*
DELETE /scheme/*
POST /scheme/simulation/store
POST /scada/batch
PATCH /scada/{device_id}/field
DELETE /scada/by-id-time-range
POST /composite/clean-scada
POST /setscadadevice/
POST /addscadadevice/
POST /deletescadadevice/
POST /cleanscadadevice/
POST /setscadadevicedata/
POST /addscadadevicedata/
POST /deletescadadevicedata/
POST /cleanscadadevicedata/
POST /setscadaelement/
POST /addscadaelement/
POST /deletescadaelement/
POST /cleanscadaelement/
POST /setextensiondata/
POST /test_dict/
GET /getjson/
```
### 不纳入首批 CLI 的运维接口
来源:
```text
app/api/v1/endpoints/snapshots.py
app/api/v1/endpoints/cache.py
app/api/v1/endpoints/audit.py
app/api/v1/endpoints/users.py
app/api/v1/endpoints/user_management.py
```
这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计、用户管理或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。
暂不暴露:
```text
GET /getcurrentoperationid/
GET /getsnapshots/
GET /havesnapshot/
GET /havesnapshotforoperation/
GET /havesnapshotforcurrentoperation/
GET /getrestoreoperation/
POST /undo/
POST /redo/
POST /takesnapshot*/
POST /picksnapshot/
POST /pickoperation/
GET /syncwithserver/
POST /batch/
POST /compressedbatch/
POST /setrestoreoperation/
GET /queryredis/
POST /clearrediskey/
POST /clearrediskeys/
POST /clearallredis/
GET /audit/logs
GET /audit/logs/my
GET /audit/logs/count
GET /getuserschema/
GET /getuser/
GET /getallusers/
PUT /users/{user_id}
DELETE /users/{user_id}
POST /users/{user_id}/activate
POST /users/{user_id}/deactivate
```
## Help
`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。
| 命令 | 说明 |
|---|---|
| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 |
| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 |
输出补充约束:
- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。
- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`
- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。
## 输出规范
进程级契约:
- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。
- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。
- 退出码必须稳定,不能简单透传底层 HTTP status。
建议退出码:
| 退出码 | 含义 |
|---|---|
| `0` | 成功 |
| `2` | CLI 参数错误 / 用法错误 |
| `3` | 认证失败 |
| `4` | 权限不足 |
| `5` | 资源不存在 |
| `6` | 冲突、前置条件不满足或非法状态 |
| `7` | 服务端错误 |
成功:
```json
{
"ok": true,
"schema_version": "tjwater-cli/v1",
"summary": "读取成功",
"data": {},
"metadata": {},
"next_commands": []
}
```
失败:
```json
{
"ok": false,
"schema_version": "tjwater-cli/v1",
"summary": "认证失败",
"error": {
"code": "UNAUTHENTICATED",
"message": "missing access token for agent context",
"retryable": false
},
"data": null,
"metadata": {},
"next_commands": [
"tjwater-cli <command> --auth-context /path/to/auth-context.json"
]
}
```
补充约束:
- `metadata` 至少建议包含:`request_id``server``duration_ms``generated_at`
- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。
- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。
## 后续开放条件
如后续要开放写操作,需要单独设计:
- 权限校验
- dry-run / preview
- 显式确认机制
- 审计日志
- 变更快照
- 回滚策略
- Agent 可读的错误恢复建议
+2 -1
View File
@@ -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:
-105205
View File
File diff suppressed because it is too large Load Diff
-26498
View File
File diff suppressed because it is too large Load Diff
-30973
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-111322
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More