8 Commits

Author SHA1 Message Date
jiang 4fa8e55748 删除 copilot 自述文件 2026-06-09 18:24:14 +08:00
jiang 7a9fcaae81 ci: add deployment trigger script
Server CI/CD / docker-image (push) Has been cancelled
Server CI/CD / deploy-fallback-log (push) Has been cancelled
2026-06-09 18:22:16 +08:00
jiang a1e9673d9a ci: add Gitea package workflow 2026-06-09 18:18:22 +08:00
jiang e588d1cf33 feat(api): add Tianditu geocoding 2026-06-09 17:09:42 +08:00
jiang 1712ecd4c7 feat(api): add web search endpoint 2026-06-09 16:13:24 +08:00
jiang 441979f581 修改默认超时时间 2026-06-05 19:11:53 +08:00
jiang e336ffcd46 移除存在无效数据的 cli 命令 2026-06-05 16:42:03 +08:00
jiang 52b8f07abd 更新 cli 命令,新增 network 其他元素的属性查询 2026-06-05 15:48:53 +08:00
27 changed files with 1398 additions and 496 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.github
.gitea
__pycache__/
.pytest_cache/
.mypy_cache/
.venv/
venv/
build/
dist/
package/
temp/
data/
db_inp/
inp/
.env
*.pyc
*.dump
+15
View File
@@ -48,3 +48,18 @@ METADATA_DB_PASSWORD="password"
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_ALGORITHM=RS256
KEYCLOAK_AUDIENCE="account" 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
+211
View File
@@ -0,0 +1,211 @@
name: Server CI/CD
on:
push:
tags:
- "v*"
- "latest"
workflow_dispatch: {}
jobs:
docker-image:
runs-on: ubuntu-22.04
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
defaults:
run:
shell: bash
steps:
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
RAW_REF_NAME: ${{ github.ref_name }}
run: |
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ -z "${RAW_REGISTRY_HOST}" ]; then
echo "Missing required repository variable: REGISTRY_HOST"
exit 1
fi
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
if [ -z "${REGISTRY_HOST}" ]; then
echo "Repository variable REGISTRY_HOST resolves to an empty host"
exit 1
fi
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
IMAGE_TAG="${RAW_REF_NAME}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
if [ -z "${REGISTRY_HOST:-}" ]; then
echo "Missing resolved environment value: REGISTRY_HOST"
exit 1
fi
if [ -z "${REGISTRY_USERNAME}" ]; then
echo "Missing required repository secret: REGISTRY_USERNAME"
exit 1
fi
if [ -z "${REGISTRY_PASSWORD}" ]; then
echo "Missing required repository secret: REGISTRY_PASSWORD"
exit 1
fi
echo "Logging into registry host: ${REGISTRY_HOST}"
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
--username "${REGISTRY_USERNAME}" \
--password-stdin
- name: Build and Push Image
run: |
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
exit 1
fi
push_with_retry() {
image_ref="$1"
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
if docker push "$image_ref"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
return 1
fi
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
}
if [ "${IMAGE_TAG}" = "latest" ]; then
docker build \
-f ./Dockerfile \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:latest"
else
docker build \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
fi
- name: Notify Deploy Server
run: |
post_deploy_webhook() {
label="$1"
payload="$2"
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
webhook_url=$(echo "$webhook_url" | xargs)
echo "[$label] Calling webhook: $webhook_url"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
-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
+38
View File
@@ -0,0 +1,38 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`.
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
## Build, Test, and Development Commands
Use the existing conda environment when available:
```bash
conda run -n server python -m pytest tests/unit tests/auth -q
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
docker build -t tjwater-server:local .
docker compose -f infra/docker/docker-compose.yml config
```
`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion.
## Coding Style & Naming Conventions
Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions.
## Testing Guidelines
The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`.
## Commit & Pull Request Guidelines
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior.
## Security & Configuration Tips
Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. Secrets for CI/CD belong in Gitea repository secrets such as `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and deploy webhook credentials.
+8 -5
View File
@@ -2,19 +2,22 @@ FROM condaforge/miniforge3:latest
WORKDIR /app 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 避免编译问题) # 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
RUN mamba install -y python=3.12 pymetis && \ RUN mamba install -y python=3.12 pymetis && \
mamba clean -afy mamba clean -afy
COPY requirements.txt . 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 RUN uv pip install --system --no-cache-dir -r requirements.txt
# 将代码放入子目录 'app'将数据放入子目录 'db_inp' # 将代码放入子目录 'app'临时数据目录运行时创建。
# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 # db_inp 和 .env 都不应依赖 Git 跟踪或被烘焙进镜像。
COPY app ./app COPY app ./app
COPY db_inp ./db_inp RUN mkdir -p ./db_inp
COPY .env .
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
+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
+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
+4
View File
@@ -18,6 +18,8 @@ from app.api.v1.endpoints import (
user_management, # 新增:用户管理 user_management, # 新增:用户管理
audit, # 新增:审计日志 audit, # 新增:审计日志
meta, meta,
web_search,
geocoding,
) )
from app.api.v1.endpoints.network import ( from app.api.v1.endpoints.network import (
general, general,
@@ -93,6 +95,8 @@ api_router.include_router(schemes.router, tags=["Schemes"])
api_router.include_router(misc.router, tags=["Misc"]) api_router.include_router(misc.router, tags=["Misc"])
api_router.include_router(risk.router, tags=["Risk"]) api_router.include_router(risk.router, tags=["Risk"])
api_router.include_router(cache.router, tags=["Cache"]) 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(leakage.router, prefix="/leakage", tags=["Leakage"])
api_router.include_router( api_router.include_router(
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"] burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
+10
View File
@@ -64,6 +64,16 @@ class Settings(BaseSettings):
KEYCLOAK_ALGORITHM: str = "RS256" KEYCLOAK_ALGORITHM: str = "RS256"
KEYCLOAK_AUDIENCE: str = "" 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 @property
def SQLALCHEMY_DATABASE_URI(self) -> str: def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD) db_password = quote_plus(self.DB_PASSWORD)
+4 -35
View File
@@ -1,36 +1,5 @@
from app.services.network_import import network_update, submit_scada_info """Service package.
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
__all__ = [ Keep package initialization lightweight. Import concrete service modules directly,
"network_update", for example: `from app.services.tjnetwork import open_project`.
"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",
]
+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
+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
+325 -12
View File
@@ -71,14 +71,14 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
def fake_request_json(ctx, **kwargs): def fake_request_json(ctx, **kwargs):
observed_runtime_ids.append(id(ctx)) observed_runtime_ids.append(id(ctx))
assert ctx.auth.access_token == "token-1" assert ctx.auth.access_token == "token-1"
assert kwargs["params"] == {"network": "tjwater", "node": "11"} assert kwargs["params"] == {"network": "tjwater", "junction": "11"}
return {"node": "11"}, 5 return {"id": "11"}, 5
monkeypatch.setattr(common, "request_json", fake_request_json) monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke( result = runner.invoke(
app, app,
["--auth-stdin", "network", "get-node-properties", "--node", "11"], ["--auth-stdin", "network", "get-junction-properties", "--junction", "11"],
input=json.dumps( input=json.dumps(
{ {
"server": "http://server", "server": "http://server",
@@ -93,37 +93,70 @@ def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
assert result.exit_code == 0 assert result.exit_code == 0
assert payload["ok"] is True assert payload["ok"] is True
assert payload["data"] == {"node": "11"} assert payload["data"] == {"id": "11"}
assert len(observed_runtime_ids) == 1 assert len(observed_runtime_ids) == 1
def test_network_get_all_junction_properties_uses_network_context(monkeypatch): def test_network_get_junction_properties_uses_network_context(monkeypatch):
captured = {} captured = {}
def fake_request_json(ctx, **kwargs): def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"] captured["params"] = kwargs["params"]
return [{"id": "J1"}], 5 return {"id": "J1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server") monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc") monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json) monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-junction-properties"]) result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert result.exit_code == 0 assert result.exit_code == 0
assert payload["ok"] is True assert payload["ok"] is True
assert payload["data"] == [{"id": "J1"}] assert payload["data"] == {"id": "J1"}
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} assert captured == {
"access_token": "abc",
"path": "/getjunctionproperties/",
"params": {"network": "tjwater", "junction": "J1"},
}
def test_network_get_all_pipe_properties_uses_network_context(monkeypatch): def test_network_get_pipe_properties_uses_network_context(monkeypatch):
captured = {} captured = {}
def fake_request_json(ctx, **kwargs): def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "P1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "P1"}
assert captured == {
"access_token": "abc",
"path": "/getpipeproperties/",
"params": {"network": "tjwater", "pipe": "P1"},
}
def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"] captured["params"] = kwargs["params"]
return [{"id": "P1"}], 5 return [{"id": "P1"}], 5
@@ -132,13 +165,233 @@ def test_network_get_all_pipe_properties_uses_network_context(monkeypatch):
monkeypatch.setenv("TJWATER_NETWORK", "tjwater") monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json) monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pipe-properties"]) result = runner.invoke(app, ["network", "get-all-pipes-properties"])
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert result.exit_code == 0 assert result.exit_code == 0
assert payload["ok"] is True assert payload["ok"] is True
assert payload["data"] == [{"id": "P1"}] assert payload["data"] == [{"id": "P1"}]
assert captured == {"access_token": "abc", "params": {"network": "tjwater"}} assert captured == {
"access_token": "abc",
"path": "/getallpipeproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "R1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "R1"}
assert captured == {
"access_token": "abc",
"path": "/getreservoirproperties/",
"params": {"network": "tjwater", "reservoir": "R1"},
}
def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "R1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-reservoirs-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "R1"}]
assert captured == {
"access_token": "abc",
"path": "/getallreservoirproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_tank_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "T1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "T1"}
assert captured == {
"access_token": "abc",
"path": "/gettankproperties/",
"params": {"network": "tjwater", "tank": "T1"},
}
def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "T1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-tanks-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "T1"}]
assert captured == {
"access_token": "abc",
"path": "/getalltankproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_pump_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "PU1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "PU1"}
assert captured == {
"access_token": "abc",
"path": "/getpumpproperties/",
"params": {"network": "tjwater", "pump": "PU1"},
}
def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "PU1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-pumps-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "PU1"}]
assert captured == {
"access_token": "abc",
"path": "/getallpumpproperties/",
"params": {"network": "tjwater"},
}
def test_network_get_valve_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return {"id": "V1"}, 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == {"id": "V1"}
assert captured == {
"access_token": "abc",
"path": "/getvalveproperties/",
"params": {"network": "tjwater", "valve": "V1"},
}
def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
captured = {}
def fake_request_json(ctx, **kwargs):
captured["access_token"] = ctx.auth.access_token
captured["path"] = kwargs["path"]
captured["params"] = kwargs["params"]
return [{"id": "V1"}], 5
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "tjwater")
monkeypatch.setattr(common, "request_json", fake_request_json)
result = runner.invoke(app, ["network", "get-all-valves-properties"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
assert payload["ok"] is True
assert payload["data"] == [{"id": "V1"}]
assert captured == {
"access_token": "abc",
"path": "/getallvalveproperties/",
"params": {"network": "tjwater"},
}
def test_help_outputs_json_lists_commands(): def test_help_outputs_json_lists_commands():
@@ -598,6 +851,66 @@ def test_main_invalid_scada_field_is_rejected_before_request(capsys):
assert "cleaned_value" in stdout assert "cleaned_value" in stdout
def test_data_scada_get_rejects_removed_kind_before_request(capsys):
exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"])
stdout = capsys.readouterr().out
assert exit_code == 2
assert '"code": "INVALID_PARAMETER"' in stdout
assert "device" in stdout
assert "info" in stdout
def test_data_scada_list_help_only_shows_info_kind():
result = runner.invoke(app, ["data", "scada", "list", "--help"])
assert result.exit_code == 0
assert "info" in result.stdout
assert "device" not in result.stdout
assert "element" not in result.stdout
def test_data_scada_help_no_longer_lists_schema():
result = runner.invoke(app, ["data", "scada", "help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
commands = {command["command"] for command in payload["commands"]}
assert "data scada get" in commands
assert "data scada list" in commands
assert "data scada schema" not in commands
def test_data_scada_schema_command_is_removed():
result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"])
assert result.exit_code == 2
assert "No such command 'schema'" in result.output
def test_data_help_no_longer_lists_extension_or_misc():
result = runner.invoke(app, ["data", "help"])
payload = json.loads(result.stdout)
assert result.exit_code == 0
commands = {command["command"] for command in payload["commands"]}
assert "data timeseries" in commands
assert "data scada" in commands
assert "data scheme" in commands
assert "data extension" not in commands
assert "data misc" not in commands
def test_removed_data_extension_and_misc_commands_fail():
extension_result = runner.invoke(app, ["data", "extension", "list"])
misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"])
assert extension_result.exit_code == 2
assert "No such command 'extension'" in extension_result.output
assert misc_result.exit_code == 2
assert "No such command 'misc'" in misc_result.output
def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
exit_code = main(["analysis"]) exit_code = main(["analysis"])
stdout = capsys.readouterr().out stdout = capsys.readouterr().out
-6
View File
@@ -26,8 +26,6 @@ data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) data_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_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scheme_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(network_app, name="network")
app.add_typer(component_app, name="component") app.add_typer(component_app, name="component")
@@ -50,8 +48,6 @@ data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada")
data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite") data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite")
data_app.add_typer(data_scada_app, name="scada") data_app.add_typer(data_scada_app, name="scada")
data_app.add_typer(data_scheme_app, name="scheme") 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, ...]]] = [ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(network_app, ("network",)), (network_app, ("network",)),
@@ -75,8 +71,6 @@ GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
(data_timeseries_composite_app, ("data", "timeseries", "composite")), (data_timeseries_composite_app, ("data", "timeseries", "composite")),
(data_scada_app, ("data", "scada")), (data_scada_app, ("data", "scada")),
(data_scheme_app, ("data", "scheme")), (data_scheme_app, ("data", "scheme")),
(data_extension_app, ("data", "extension")),
(data_misc_app, ("data", "misc")),
] ]
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"} TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
+2 -105
View File
@@ -5,8 +5,6 @@ from typing import Annotated
import typer import typer
from .apps import ( from .apps import (
data_extension_app,
data_misc_app,
data_scada_app, data_scada_app,
data_scheme_app, data_scheme_app,
data_timeseries_composite_app, data_timeseries_composite_app,
@@ -22,7 +20,6 @@ from .option_types import (
JUNCTION_TIMESERIES_FIELDS, JUNCTION_TIMESERIES_FIELDS,
SCADA_TIMESERIES_FIELDS, SCADA_TIMESERIES_FIELDS,
ScadaListKind, ScadaListKind,
ScadaSchemaKind,
SimulationQuery, SimulationQuery,
timeseries_fields_for_element_type, timeseries_fields_for_element_type,
) )
@@ -414,15 +411,6 @@ def data_composite_pipeline_health(
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
mapping = { 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", "get"): ("/getscadainfo/", {"id_param": "id"}),
("info", "list"): ("/getallscadainfo/", {}), ("info", "list"): ("/getallscadainfo/", {}),
} }
@@ -437,28 +425,10 @@ def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
return result return result
@data_scada_app.command("schema")
def data_scada_schema(
ctx: typer.Context,
kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")],
) -> None:
runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind.value, "schema")
emit_api(
ctx,
summary="读取 SCADA schema 成功",
method="GET",
path=path,
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@data_scada_app.command("get") @data_scada_app.command("get")
def data_scada_get( def data_scada_get(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[ScadaSchemaKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|device-data|element|info")], kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
id: Annotated[str, typer.Option("--id", help="记录 ID")], id: Annotated[str, typer.Option("--id", help="记录 ID")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
@@ -478,7 +448,7 @@ def data_scada_get(
@data_scada_app.command("list") @data_scada_app.command("list")
def data_scada_list( def data_scada_list(
ctx: typer.Context, ctx: typer.Context,
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 device|element|infodevice-data 无 list 接口")], kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
path, _ = _scada_mapping(kind.value, "list") path, _ = _scada_mapping(kind.value, "list")
@@ -536,76 +506,3 @@ def data_scheme_list(ctx: typer.Context) -> None:
require_auth=True, require_auth=True,
require_network_ctx=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,
)
+137 -27
View File
@@ -10,56 +10,42 @@ from .core import CLIError, require_network
from .option_types import ComponentOptionKind from .option_types import ComponentOptionKind
@network_app.command("get-node-properties") @network_app.command("get-junction-properties")
def network_get_node_properties( def network_get_junction_properties(
ctx: typer.Context, ctx: typer.Context,
node: Annotated[str, typer.Option("--node", help="节点 ID")], junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
emit_api( emit_api(
ctx, ctx,
summary="读取节点属性成功", summary="读取节点属性成功",
method="GET", method="GET",
path="/getnodeproperties/", path="/getjunctionproperties/",
params={"network": require_network(runtime), "node": node}, params={"network": require_network(runtime), "junction": junction},
require_auth=True, require_auth=True,
require_network_ctx=True, require_network_ctx=True,
) )
@network_app.command("get-link-properties") @network_app.command("get-pipe-properties")
def network_get_link_properties( def network_get_pipe_properties(
ctx: typer.Context, ctx: typer.Context,
link: Annotated[str, typer.Option("--link", help="线 ID")], pipe: Annotated[str, typer.Option("--pipe", help=" ID")],
) -> None: ) -> None:
runtime = runtime_context(ctx) runtime = runtime_context(ctx)
emit_api( emit_api(
ctx, ctx,
summary="读取管线属性成功", summary="读取管属性成功",
method="GET", method="GET",
path="/getlinkproperties/", path="/getpipeproperties/",
params={"network": require_network(runtime), "link": link}, params={"network": require_network(runtime), "pipe": pipe},
require_auth=True, require_auth=True,
require_network_ctx=True, require_network_ctx=True,
) )
@network_app.command("get-all-junction-properties") @network_app.command("get-all-pipes-properties")
def network_get_all_junction_properties(ctx: typer.Context) -> None: def network_get_all_pipes_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) runtime = runtime_context(ctx)
emit_api( emit_api(
ctx, ctx,
@@ -72,6 +58,130 @@ def network_get_all_pipe_properties(ctx: typer.Context) -> None:
) )
@network_app.command("get-reservoir-properties")
def network_get_reservoir_properties(
ctx: typer.Context,
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水库属性成功",
method="GET",
path="/getreservoirproperties/",
params={"network": require_network(runtime), "reservoir": reservoir},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-reservoirs-properties")
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水库属性成功",
method="GET",
path="/getallreservoirproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-tank-properties")
def network_get_tank_properties(
ctx: typer.Context,
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水箱属性成功",
method="GET",
path="/gettankproperties/",
params={"network": require_network(runtime), "tank": tank},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-tanks-properties")
def network_get_all_tank_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水箱属性成功",
method="GET",
path="/getalltankproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-pump-properties")
def network_get_pump_properties(
ctx: typer.Context,
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取水泵属性成功",
method="GET",
path="/getpumpproperties/",
params={"network": require_network(runtime), "pump": pump},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-pumps-properties")
def network_get_all_pump_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部水泵属性成功",
method="GET",
path="/getallpumpproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-valve-properties")
def network_get_valve_properties(
ctx: typer.Context,
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取阀门属性成功",
method="GET",
path="/getvalveproperties/",
params={"network": require_network(runtime), "valve": valve},
require_auth=True,
require_network_ctx=True,
)
@network_app.command("get-all-valves-properties")
def network_get_all_valve_properties(ctx: typer.Context) -> None:
runtime = runtime_context(ctx)
emit_api(
ctx,
summary="读取全部阀门属性成功",
method="GET",
path="/getallvalveproperties/",
params={"network": require_network(runtime)},
require_auth=True,
require_network_ctx=True,
)
@component_option_app.command("schema") @component_option_app.command("schema")
def component_option_schema( def component_option_schema(
ctx: typer.Context, ctx: typer.Context,
+1 -1
View File
@@ -15,7 +15,7 @@ import typer
SCHEMA_VERSION = "tjwater-cli/v1" SCHEMA_VERSION = "tjwater-cli/v1"
CLI_NAME = "tjwater-cli" CLI_NAME = "tjwater-cli"
DEFAULT_TIMEOUT = 60 DEFAULT_TIMEOUT = 180
DEFAULT_SERVER = "http://192.168.1.114:8000" DEFAULT_SERVER = "http://192.168.1.114:8000"
+2 -3
View File
@@ -100,9 +100,8 @@ def _sample_option_value(path: tuple[str, ...], option_name: str) -> str:
(("component", "option", "schema"), "kind"): "time", (("component", "option", "schema"), "kind"): "time",
(("component", "option", "get"), "kind"): "time", (("component", "option", "get"), "kind"): "time",
(("data", "timeseries", "composite"), "kind"): "scada-simulation", (("data", "timeseries", "composite"), "kind"): "scada-simulation",
(("data", "scada", "schema"), "kind"): "device", (("data", "scada", "get"), "kind"): "info",
(("data", "scada", "get"), "kind"): "device", (("data", "scada", "list"), "kind"): "info",
(("data", "scada", "list"), "kind"): "device",
} }
if (path, option_name) in path_specific_samples: if (path, option_name) in path_specific_samples:
return path_specific_samples[(path, option_name)] return path_specific_samples[(path, option_name)]
-9
View File
@@ -36,16 +36,7 @@ class DataSource(str, Enum):
SIMULATION = "simulation" SIMULATION = "simulation"
class ScadaSchemaKind(str, Enum):
DEVICE = "device"
DEVICE_DATA = "device-data"
ELEMENT = "element"
INFO = "info"
class ScadaListKind(str, Enum): class ScadaListKind(str, Enum):
DEVICE = "device"
ELEMENT = "element"
INFO = "info" INFO = "info"
+71 -77
View File
@@ -16,7 +16,7 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。", ("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
("analysis", "risk"): "风险分析相关命令。", ("analysis", "risk"): "风险分析相关命令。",
("analysis", "sensor-placement"): "传感器选址相关命令。", ("analysis", "sensor-placement"): "传感器选址相关命令。",
("data",): "时序、SCADA、方案和扩展数据查询命令。", ("data",): "时序、SCADA 和方案数据查询命令。",
("data", "timeseries"): "时序数据查询命令。", ("data", "timeseries"): "时序数据查询命令。",
("data", "timeseries", "realtime"): "实时模拟时序查询命令。", ("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
("data", "timeseries", "scheme"): "方案时序查询命令。", ("data", "timeseries", "scheme"): "方案时序查询命令。",
@@ -24,8 +24,6 @@ GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
("data", "timeseries", "composite"): "复合时序查询命令。", ("data", "timeseries", "composite"): "复合时序查询命令。",
("data", "scada"): "SCADA 元数据查询命令。", ("data", "scada"): "SCADA 元数据查询命令。",
("data", "scheme"): "方案数据查询命令。", ("data", "scheme"): "方案数据查询命令。",
("data", "extension"): "扩展数据查询命令。",
("data", "misc"): "其他结果数据查询命令。",
} }
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
@@ -34,31 +32,77 @@ HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
) )
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
("network", "get-node-properties"): CommandDoc( ("network", "get-junction-properties"): CommandDoc(
path=("network", "get-node-properties"), path=("network", "get-junction-properties"),
summary="读取节点属性", summary="读取节点属性",
description="调用 /getnodeproperties/。", description="调用 /getjunctionproperties/。",
options=(CommandOptionDoc("node", "节点 ID", required=True),), options=(CommandOptionDoc("junction", "节点 ID", required=True),),
examples=("tjwater-cli network get-node-properties --node J1",), examples=("tjwater-cli network get-junction-properties --junction J1",),
), ),
("network", "get-link-properties"): CommandDoc( ("network", "get-pipe-properties"): CommandDoc(
path=("network", "get-link-properties"), path=("network", "get-pipe-properties"),
summary="读取管线属性", summary="读取管属性",
description="调用 /getlinkproperties/。", description="调用 /getpipeproperties/。",
options=(CommandOptionDoc("link", "线 ID", required=True),), options=(CommandOptionDoc("pipe", " ID", required=True),),
examples=("tjwater-cli network get-link-properties --link P1",), examples=("tjwater-cli network get-pipe-properties --pipe P1",),
), ),
("network", "get-all-junction-properties"): CommandDoc( ("network", "get-all-pipes-properties"): CommandDoc(
path=("network", "get-all-junction-properties"), path=("network", "get-all-pipes-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="读取全部管道属性", summary="读取全部管道属性",
description="调用 /getallpipeproperties/。", description="调用 /getallpipeproperties/。",
examples=("tjwater-cli network get-all-pipe-properties",), examples=("tjwater-cli network get-all-pipes-properties",),
),
("network", "get-reservoir-properties"): CommandDoc(
path=("network", "get-reservoir-properties"),
summary="读取水库属性",
description="调用 /getreservoirproperties/。",
options=(CommandOptionDoc("reservoir", "水库 ID", required=True),),
examples=("tjwater-cli network get-reservoir-properties --reservoir R1",),
),
("network", "get-all-reservoirs-properties"): CommandDoc(
path=("network", "get-all-reservoirs-properties"),
summary="读取全部水库属性",
description="调用 /getallreservoirproperties/。",
examples=("tjwater-cli network get-all-reservoirs-properties",),
),
("network", "get-tank-properties"): CommandDoc(
path=("network", "get-tank-properties"),
summary="读取水箱属性",
description="调用 /gettankproperties/。",
options=(CommandOptionDoc("tank", "水箱 ID", required=True),),
examples=("tjwater-cli network get-tank-properties --tank T1",),
),
("network", "get-all-tanks-properties"): CommandDoc(
path=("network", "get-all-tanks-properties"),
summary="读取全部水箱属性",
description="调用 /getalltankproperties/。",
examples=("tjwater-cli network get-all-tanks-properties",),
),
("network", "get-pump-properties"): CommandDoc(
path=("network", "get-pump-properties"),
summary="读取水泵属性",
description="调用 /getpumpproperties/。",
options=(CommandOptionDoc("pump", "水泵 ID", required=True),),
examples=("tjwater-cli network get-pump-properties --pump PU1",),
),
("network", "get-all-pumps-properties"): CommandDoc(
path=("network", "get-all-pumps-properties"),
summary="读取全部水泵属性",
description="调用 /getallpumpproperties/。",
examples=("tjwater-cli network get-all-pumps-properties",),
),
("network", "get-valve-properties"): CommandDoc(
path=("network", "get-valve-properties"),
summary="读取阀门属性",
description="调用 /getvalveproperties/。",
options=(CommandOptionDoc("valve", "阀门 ID", required=True),),
examples=("tjwater-cli network get-valve-properties --valve V1",),
),
("network", "get-all-valves-properties"): CommandDoc(
path=("network", "get-all-valves-properties"),
summary="读取全部阀门属性",
description="调用 /getallvalveproperties/。",
examples=("tjwater-cli network get-all-valves-properties",),
), ),
("component", "option", "schema"): CommandDoc( ("component", "option", "schema"): CommandDoc(
path=("component", "option", "schema"), path=("component", "option", "schema"),
@@ -422,41 +466,22 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
), ),
examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",), 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( ("data", "scada", "get"): CommandDoc(
path=("data", "scada", "get"), path=("data", "scada", "get"),
summary="读取单条 SCADA 元数据", summary="读取单条 SCADA 元数据",
description="kind 支持 device、device-data、element、info。", description="kind 支持 info。",
options=( options=(
CommandOptionDoc("kind", "SCADA 数据类型", required=True), CommandOptionDoc("kind", "SCADA 数据类型", required=True),
CommandOptionDoc("id", "记录 ID", required=True), CommandOptionDoc("id", "记录 ID", required=True),
), ),
examples=( examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
"tjwater-cli data scada get --kind device --id D1",
"tjwater-cli data scada get --kind element --id E1",
),
), ),
("data", "scada", "list"): CommandDoc( ("data", "scada", "list"): CommandDoc(
path=("data", "scada", "list"), path=("data", "scada", "list"),
summary="列出 SCADA 元数据", summary="列出 SCADA 元数据",
description="kind 支持 device、element、infodevice-data 当前后端无 list 接口", description="kind 支持 info",
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),), options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
examples=( examples=("tjwater-cli data scada list --kind info",),
"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( ("data", "scheme", "schema"): CommandDoc(
path=("data", "scheme", "schema"), path=("data", "scheme", "schema"),
@@ -477,37 +502,6 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
description="调用 /getallschemes/。", description="调用 /getallschemes/。",
examples=("tjwater-cli data scheme list",), 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",),
),
} }
+1 -5
View File
@@ -259,12 +259,8 @@ app/api/v1/endpoints/project_data.py
| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range``GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | | `tjwater-cli data timeseries 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 --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 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 info` | `GET /getscadainfo/``GET /getallscadainfo/` | `SCADA info` 元数据 |
| `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 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` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links``nodes``simulation-by-id-time``simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
- `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 - `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
+2 -1
View File
@@ -3,9 +3,10 @@ services:
# Core API Service # Core API Service
# ========================================== # ==========================================
api: api:
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
build: build:
context: ../.. context: ../..
dockerfile: infra/docker/Dockerfile dockerfile: Dockerfile
container_name: tjwater_api container_name: tjwater_api
restart: always restart: always
ports: ports:
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
echo ""
echo "Examples:"
echo " bash scripts/trigger-gitea-pipeline.sh"
echo " bash scripts/trigger-gitea-pipeline.sh origin latest"
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
echo " bash scripts/trigger-gitea-pipeline.sh origin v2026.06.09.1"
exit 0
fi
resolve_default_remote() {
if git remote get-url gitea >/dev/null 2>&1; then
echo "gitea"
return 0
fi
if git remote get-url origin >/dev/null 2>&1; then
echo "origin"
return 0
fi
return 1
}
REMOTE="${1:-}"
TAG="${2:-latest}"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "[ERROR] Current directory is not a git repository."
exit 1
fi
if [[ -z "$REMOTE" ]]; then
if ! REMOTE="$(resolve_default_remote)"; then
echo "[ERROR] No default remote found. Expected 'gitea' or 'origin'."
echo "Available remotes:"
git remote -v || true
exit 1
fi
fi
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
echo "[ERROR] Remote '$REMOTE' does not exist."
echo "Available remotes:"
git remote -v
exit 1
fi
HEAD_SHA="$(git rev-parse --short HEAD)"
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
echo "[INFO] HEAD: ${HEAD_SHA}"
echo "[INFO] Recreate annotated tag '${TAG}'"
git tag -fa "$TAG" -m "$MESSAGE"
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
git push "$REMOTE" "refs/tags/${TAG}" --force
echo "[INFO] Verify remote tag reference"
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
+140
View File
@@ -0,0 +1,140 @@
import asyncio
import importlib.util
import json
from pathlib import Path
import httpx
import pytest
def _load_geocoding_module():
module_path = Path(__file__).resolve().parents[2] / "app" / "services" / "geocoding.py"
spec = importlib.util.spec_from_file_location("tests_geocoding_under_test", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
geocoding = _load_geocoding_module()
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def get(self, url, *, params):
self.calls.append({"url": url, "params": params})
return self.response
def test_geocode_tianditu_gets_expected_params(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
monkeypatch.setattr(
geocoding.settings,
"TIANDITU_GEOCODER_URL",
"https://api.tianditu.gov.cn/geocoder",
)
response = httpx.Response(
200,
json={
"location": {"lon": "116.407526", "lat": "39.904030", "level": "地名地址"},
"status": "0",
"msg": "ok",
},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
client = FakeClient(response)
result = asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=client,
)
)
assert result["location"] == {
"lon": "116.407526",
"lat": "39.904030",
"level": "地名地址",
}
assert client.calls == [
{
"url": "https://api.tianditu.gov.cn/geocoder",
"params": {
"ds": json.dumps({"keyWord": "北京市人民政府"}, ensure_ascii=False),
"tk": "tk-test",
},
}
]
def test_geocode_tianditu_accepts_key_word_alias(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
response = httpx.Response(
200,
json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
result = asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyWord="北京市人民政府"),
client=FakeClient(response),
)
)
assert result["status"] == "0"
def test_geocode_tianditu_requires_token(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "")
with pytest.raises(geocoding.TiandituGeocodingConfigError):
asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=FakeClient(httpx.Response(200, json={})),
)
)
def test_geocode_tianditu_surfaces_http_error(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
response = httpx.Response(
403,
json={"msg": "invalid tk"},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info:
asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == {"msg": "invalid tk"}
def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
response = httpx.Response(
200,
json={"status": "100", "msg": "bad request"},
request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"),
)
with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info:
asyncio.run(
geocoding.geocode_tianditu(
geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 502
assert exc_info.value.detail == {"status": "100", "msg": "bad request"}
+115
View File
@@ -0,0 +1,115 @@
import asyncio
import importlib.util
from pathlib import Path
import httpx
import pytest
def _load_web_search_module():
module_path = (
Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py"
)
spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
web_search = _load_web_search_module()
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def post(self, url, *, headers, json):
self.calls.append({"url": url, "headers": headers, "json": json})
return self.response
def test_search_bocha_web_posts_expected_payload(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
monkeypatch.setattr(
web_search.settings,
"BOCHA_WEB_SEARCH_URL",
"https://api.bochaai.com/v1/web-search",
)
response = httpx.Response(
200,
json={"data": {"webPages": {"value": []}}},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
client = FakeClient(response)
result = asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(
query="天津水务",
freshness="oneWeek",
summary=True,
count=5,
include=["example.com", "news.example.com"],
exclude=["spam.example.com"],
),
client=client,
)
)
assert result == {"data": {"webPages": {"value": []}}}
assert client.calls == [
{
"url": "https://api.bochaai.com/v1/web-search",
"headers": {
"Authorization": "Bearer sk-test",
"Content-Type": "application/json",
},
"json": {
"query": "天津水务",
"freshness": "oneWeek",
"summary": True,
"count": 5,
"include": "example.com,news.example.com",
"exclude": "spam.example.com",
},
}
]
def test_search_bocha_web_requires_api_key(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "")
with pytest.raises(web_search.BochaSearchConfigError):
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(httpx.Response(200, json={})),
)
)
def test_search_bocha_web_surfaces_upstream_error(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
response = httpx.Response(
401,
json={"error": "invalid api key"},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
with pytest.raises(web_search.BochaSearchAPIError) as exc_info:
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == {"error": "invalid api key"}
def test_web_search_request_validates_count_range():
with pytest.raises(ValueError):
web_search.WebSearchRequest(query="天津水务", count=51)