Compare commits
3
Commits
1712ecd4c7
...
7a9fcaae81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9fcaae81 | ||
|
|
a1e9673d9a | ||
|
|
e588d1cf33 |
@@ -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
|
||||||
@@ -56,3 +56,10 @@ KEYCLOAK_AUDIENCE="account"
|
|||||||
BOCHA_API_KEY="sk-your-bocha-api-key"
|
BOCHA_API_KEY="sk-your-bocha-api-key"
|
||||||
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
||||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
|
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
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,6 +19,7 @@ from app.api.v1.endpoints import (
|
|||||||
audit, # 新增:审计日志
|
audit, # 新增:审计日志
|
||||||
meta,
|
meta,
|
||||||
web_search,
|
web_search,
|
||||||
|
geocoding,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.network import (
|
from app.api.v1.endpoints.network import (
|
||||||
general,
|
general,
|
||||||
@@ -95,6 +96,7 @@ api_router.include_router(misc.router, tags=["Misc"])
|
|||||||
api_router.include_router(risk.router, tags=["Risk"])
|
api_router.include_router(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(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"]
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ class Settings(BaseSettings):
|
|||||||
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
Executable
+67
@@ -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}'."
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user