5 Commits

Author SHA1 Message Date
wzy-warehouse 1f01ceb062 添加降雨量与持续时间接口 2026-06-28 09:26:13 +08:00
wzy-warehouse 54f7300557 扫描QGIS模板 2026-06-27 10:51:13 +08:00
wzy-warehouse 1b08f2c4a2 添加中文字体 2026-06-24 15:52:40 +08:00
wzy-warehouse 7163ca67f9 修改适配Linux 2026-06-24 14:16:23 +08:00
wzy-warehouse e5582bab5d 修改适配Linux 2026-06-24 13:03:15 +08:00
21 changed files with 607 additions and 117 deletions
+84 -45
View File
@@ -32,15 +32,11 @@ docker load -i qgis-34411.tar
```bash ```bash
# ---- Linux / macOS ---- # ---- Linux / macOS ----
PROJECT_ROOT=/home/xian/xian_algorithm_new
FILE_STORE=/data
docker run -d \ docker run -d \
--name qgis-server \ --name qgis-server \
--restart unless-stopped \ --restart unless-stopped \
-v "${PROJECT_ROOT}:/app:ro" \ -v "/www/wwwroot/xian_algorithm_new:/app:ro" \
-v "${FILE_STORE}:${FILE_STORE}" \ -v "/www/wwwroot/xian_algorithm_new/files:/files" \
qgis/qgis:3.44.11 \ qgis/qgis:3.44.11 \
sleep infinity sleep infinity
@@ -124,60 +120,103 @@ docker exec qgis-server ls -la /data/template/earthquake
- 容器重建后(`/data` 目录丢失) - 容器重建后(`/data` 目录丢失)
- 首次部署时 - 首次部署时
## 5. 安装中文字体(手动) ## 5. 安装中文字体(手动,必须执行
QGIS 模板使用了 SimHei(黑体)、SimSun(宋体)、Microsoft YaHei(微软雅黑)等 Windows 中文字体, QGIS 模板使用了 SimHei(黑体)、SimSun(宋体)、Microsoft YaHei(微软雅黑)等 Windows 中文字体,
Docker 镜像默认不包含这些字体,会导致中文全部乱码。**字体需手动安装,代码不会自动安装。** Docker 镜像默认不包含这些字体,会导致中文全部乱码。**字体需手动安装,代码不会自动安装。**
### 步骤 ### 5.1 准备字体文件
字体文件存放在项目根目录的 `fonts/` 目录下,已预置 4 个常用中文字体:
```
fonts/
├── simhei.ttf — 黑体(模板默认字体)
├── simsun.ttc — 宋体
├── msyh.ttc — 微软雅黑
└── msyhbd.ttc — 微软雅黑粗体
```
如果字体缺失,从 Windows 主机复制(`C:\Windows\Fonts\`):
```bash ```bash
# 1. 创建字体目录 # Linux 服务器用 SCP 从 Windows 传
docker exec qgis-server mkdir -p /usr/share/fonts/truetype/winfonts scp "C:\Windows\Fonts\simhei.ttf" root@服务器IP:/www/wwwroot/xian_algorithm_new/fonts/
scp "C:\Windows\Fonts\simsun.ttc" root@服务器IP:/www/wwwroot/xian_algorithm_new/fonts/
scp "C:\Windows\Fonts\msyh.ttc" root@服务器IP:/www/wwwroot/xian_algorithm_new/fonts/
scp "C:\Windows\Fonts\msyhbd.ttc" root@服务器IP:/www/wwwroot/xian_algorithm_new/fonts/
```
# 2. 从主机复制 Windows 中文字体 ### 5.2 一键安装到容器
# Windows 路径(根据实际字体文件位置调整):
docker cp "C:\Windows\Fonts\simhei.ttf" qgis-server:/usr/share/fonts/truetype/winfonts/
docker cp "C:\Windows\Fonts\simsun.ttc" qgis-server:/usr/share/fonts/truetype/winfonts/
docker cp "C:\Windows\Fonts\msyh.ttc" qgis-server:/usr/share/fonts/truetype/winfonts/
docker cp "C:\Windows\Fonts\msyhbd.ttc" qgis-server:/usr/share/fonts/truetype/winfonts/
# Linux 字体路径示例: ```bash
# docker cp /usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf qgis-server:/usr/share/fonts/truetype/winfonts/ python app/script/install_fonts_to_container.py
```
# 3. 刷新字体缓存 输出示例:
```
=== 安装中文字体到 Docker 容器 qgis-server ===
字体目录: /www/wwwroot/xian_algorithm_new/fonts
字体文件: 4 个
- msyh.ttc (4165 KB)
- msyhbd.ttc (4167 KB)
- simhei.ttf (2637 KB)
- simsun.ttc (10126 KB)
容器目标: qgis-server:/usr/share/fonts/truetype/winfonts
[1/3] 复制字体文件...
OK msyh.ttc
OK msyhbd.ttc
OK simhei.ttf
OK simsun.ttc
[2/3] 刷新字体缓存...
OK
[3/3] 验证字体...
中文字体: ['SimHei', 'SimSun', 'Microsoft YaHei', 'Microsoft YaHei UI']
=== 完成,耗时 3.2s ===
```
其他用法:
```bash
python app/script/install_fonts_to_container.py --dry-run # 仅查看信息
python app/script/install_fonts_to_container.py --container my # 指定容器名
```
### 5.3 持久化(推荐)
容器重建后字体丢失,**强烈建议挂载 `fonts/` 目录**。
```bash
# 停掉旧容器
docker stop qgis-server && docker rm qgis-server
# 重建容器,加上字体挂载
docker run -d \
--name qgis-server \
--restart unless-stopped \
-v "/www/wwwroot/xian_algorithm_new:/app:ro" \
-v "/www/wwwroot/xian_algorithm_new/files:/files" \
-v "/www/wwwroot/xian_algorithm_new/fonts:/usr/share/fonts/truetype/winfonts:ro" \
qgis/qgis:3.44.11 \
sleep infinity
# 挂载后只需刷新一次缓存
docker exec qgis-server fc-cache -fv docker exec qgis-server fc-cache -fv
# 4. 验证字体已识别
docker exec qgis-server python3 -c "
from PyQt5.QtGui import QFontDatabase
db = QFontDatabase()
zh = [f for f in db.families() if 'SimHei' in f or 'YaHei' in f or 'SimSun' in f]
print('中文字体:', zh)
"
``` ```
### 持久化方案 ### 5.4 无法获取 Windows 字体时的替代方案
容器重建后字体丢失。可选方案:
**方案 A:挂载单个字体文件**
```bash ```bash
docker run -d ... \ # Linux 服务器安装开源中文字体
-v "C:\Windows\Fonts\simhei.ttf:/usr/share/fonts/truetype/winfonts/simhei.ttf" \ yum install wqy-microhei-fonts # CentOS / RHEL
-v "C:\Windows\Fonts\simsun.ttc:/usr/share/fonts/truetype/winfonts/simsun.ttc" \ apt install fonts-wqy-microhei # Debian / Ubuntu
...
```
**方案 B:挂载整个字体目录(推荐)** # 将系统字体复制到项目 fonts/ 目录
```bash cp /usr/share/fonts/wqy-microhei/wqy-microhei.ttc /www/wwwroot/xian_algorithm_new/fonts/
# 先在主机创建字体目录,放入所需字体文件
mkdir -p /opt/qgis-fonts
cp /usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf /opt/qgis-fonts/
docker run -d ... \
-v "/opt/qgis-fonts:/usr/share/fonts/truetype/winfonts:ro" \
...
``` ```
## 6. 验证容器 ## 6. 验证容器
+1 -1
View File
@@ -134,7 +134,7 @@ async def predict_earthquake(req: EarthquakePredictRequest):
"point_ids": req.point_ids, "point_ids": req.point_ids,
"region_code": req.region_code, "region_code": req.region_code,
"magnitude": req.magnitude, "magnitude": req.magnitude,
"depth": req.depth, # 已有默认值 10.0 "depth": req.depth,
"epicenter_lon": req.epicenter_lon, "epicenter_lon": req.epicenter_lon,
"epicenter_lat": req.epicenter_lat, "epicenter_lat": req.epicenter_lat,
"occurred_time": occurred_time.isoformat() if hasattr(occurred_time, 'isoformat') else str(occurred_time) "occurred_time": occurred_time.isoformat() if hasattr(occurred_time, 'isoformat') else str(occurred_time)
+57 -21
View File
@@ -154,23 +154,51 @@ def _extract_center_from_condition(event_type: str, condition: dict) -> tuple:
# 构建 QGIS 服务配置字典 # 构建 QGIS 服务配置字典
# ============================================================ # ============================================================
def _build_qgis_config(batch_folder: str) -> dict: def _check_docker_running() -> bool:
"""构建 QGIS 服务配置(含批次输出目录)""" """检测 Docker 容器是否在运行"""
from app.services.qgis.qgis_env import ( from app.services.qgis.qgis_env import get_docker_container
get_docker_container, get_host_file_store, get_container_file_store,
get_docker_project_dir,
)
gpkg_dir = get_gpkg_dir()
# Docker 模式:config 中的路径必须是容器内路径(模板修改器在容器内运行)
try: try:
result = subprocess.run( result = subprocess.run(
["docker", "inspect", "--format={{.State.Running}}", get_docker_container()], ["docker", "inspect", "--format={{.State.Running}}", get_docker_container()],
capture_output=True, text=True, timeout=5, capture_output=True, text=True, timeout=5,
) )
is_docker = result.stdout.strip() == "true" return result.stdout.strip() == "true"
except Exception: except Exception:
is_docker = False return False
def _list_container_templates(container: str, event_type: str) -> list:
"""Docker 模式下:扫描容器内模板目录,返回模板文件名列表"""
from app.services.qgis.qgis_env import get_docker_container
container_tpl_dir = getattr(settings, "QGIS_DOCKER_TEMPLATE_DIR", "") or "/data/template"
container_path = f"{container_tpl_dir.rstrip('/')}/{event_type}"
try:
result = subprocess.run(
["docker", "exec", container, "ls", container_path],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
logger.error(f"[Docker] 列出容器模板失败: {result.stderr.strip()}")
return []
files = [
f.strip() for f in result.stdout.splitlines()
if f.strip().endswith(".qgz") and not f.strip().startswith("tmp")
]
logger.info(f"[Docker] 容器内模板扫描: {container_path}{len(files)} 个模板")
return sorted(files)
except Exception as e:
logger.error(f"[Docker] 列出容器模板异常: {e}")
return []
def _build_qgis_config(batch_folder: str) -> dict:
"""构建 QGIS 服务配置(含批次输出目录和 Docker 模式标志)"""
from app.services.qgis.qgis_env import (
get_docker_container, get_host_file_store, get_container_file_store,
get_docker_project_dir,
)
gpkg_dir = get_gpkg_dir()
is_docker = _check_docker_running()
if is_docker: if is_docker:
# GPKG 目录:优先使用容器内本地路径(预拷贝后绕过 WSL2 9P) # GPKG 目录:优先使用容器内本地路径(预拷贝后绕过 WSL2 9P)
@@ -206,6 +234,7 @@ def _build_qgis_config(batch_folder: str) -> dict:
}, },
"static_layers": build_static_layers_config(gpkg_dir), "static_layers": build_static_layers_config(gpkg_dir),
"batch_folder": batch_folder, "batch_folder": batch_folder,
"is_docker": is_docker,
} }
@@ -242,15 +271,22 @@ def _background_export(inference_id: int) -> None:
config = _build_qgis_config(batch_folder) config = _build_qgis_config(batch_folder)
# 2. 扫描模板 # 2. 扫描模板
template_base = os.path.join( is_docker = config.get("is_docker", False)
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), if is_docker:
"app", "data", "template" container_tpl = getattr(settings, "QGIS_DOCKER_TEMPLATE_DIR", "") or "/data/template"
) template_dir = f"{container_tpl.rstrip('/')}/{event_type}"
template_dir = os.path.join(template_base, event_type) template_files = _list_container_templates(get_docker_container(), event_type)
template_files = sorted([ else:
f for f in os.listdir(template_dir) tpl_subdir = getattr(settings, "QGIS_HOST_TEMPLATE_SUBDIR", "app/data/template")
if f.endswith(".qgz") and not f.startswith("tmp") template_base = os.path.join(
]) os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
*tpl_subdir.replace("\\", "/").split("/")
)
template_dir = os.path.join(template_base, event_type)
template_files = sorted([
f for f in os.listdir(template_dir)
if f.endswith(".qgz") and not f.startswith("tmp")
])
priority_keywords = getattr(settings, "QGIS_PRIORITY_TEMPLATES", []) priority_keywords = getattr(settings, "QGIS_PRIORITY_TEMPLATES", [])
if priority_keywords: if priority_keywords:
def _priority_order(name: str) -> tuple: def _priority_order(name: str) -> tuple:
@@ -316,7 +352,7 @@ def _background_export(inference_id: int) -> None:
status = "FAIL" if "error" in r else "OK" status = "FAIL" if "error" in r else "OK"
logger.info(f"[Pool] {status} {r.get('name', '?')}: {r.get('error', r.get('output', ''))[:100]}") logger.info(f"[Pool] {status} {r.get('name', '?')}: {r.get('error', r.get('output', ''))[:100]}")
if inference_id and file_store and "error" not in r: if inference_id and file_store and "error" not in r:
_write_single_path(inference_id, r.get("output", ""), file_store, db_lock) _write_single_path(inference_id, r["output"], file_store, db_lock)
results, summary = qgis_pool.submit_job(config, container_models, _on_progress) results, summary = qgis_pool.submit_job(config, container_models, _on_progress)
+74 -6
View File
@@ -7,11 +7,16 @@ from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.schemas.api_schemas import RainfallPredictRequest, PredictResponse, PredictData, UpdateMonitoringTimeRequest from app.schemas.api_schemas import (
RainfallPredictRequest, PredictResponse, PredictData,
UpdateMonitoringTimeRequest, DistrictSummaryRequest, DistrictSummaryItem
)
from app.utils.api_deps import get_rainfall_model, get_prediction_semaphore from app.utils.api_deps import get_rainfall_model, get_prediction_semaphore
from app.repositories.dbn_repository import dbn_repository from app.repositories.dbn_repository import dbn_repository
from app.repositories.rainfall_repository import rainfall_repository as rain_repo
from app.core.rainfall_manager import rainfall_manager from app.core.rainfall_manager import rainfall_manager
from app.config.paths import get_logger from app.config.paths import get_logger
from app.utils.db_helper import db_helper
from app.utils.time_converter import TimeConverter from app.utils.time_converter import TimeConverter
router = APIRouter(prefix="/rainfall", tags=["暴雨灾害链"]) router = APIRouter(prefix="/rainfall", tags=["暴雨灾害链"])
@@ -141,13 +146,17 @@ async def update_monitoring_time(req: UpdateMonitoringTimeRequest):
@router.post("/predict", response_model=PredictResponse, summary="暴雨灾害链预测") @router.post("/predict", response_model=PredictResponse, summary="暴雨灾害链预测")
async def predict_rainfall(req: RainfallPredictRequest): async def predict_rainfall(req: RainfallPredictRequest):
""" """
根据降雨量和持续时间,批量预测隐患点/风险点的灾害概率。 批量预测隐患点/风险点的灾害概率。两种模式(二选一):
**自动推演模式**rainfall、duration、region_code 全部不传
→ 从气象表自动获取各点最近站降雨数据,不限区域
**指定条件模式**rainfall、duration、region_code 全部传入
→ 按指定降雨条件和区域预测
- **disaster_name**: 灾害名称 - **disaster_name**: 灾害名称
- **point_ids**: 点位ID列表(可选,不传则查询所有点 - **point_ids**: 点位ID列表(可选)
- **region_code**: 行政区划代码(可选,不传则不限区域 - **occurred_time**: 事件发生时间(可选,不传则为当前时间
- **rainfall**: 累计降雨量(mm),不传则从气象表自动获取
- **duration**: 降雨持续时间(h),不传则从气象表自动获取
- **operation_type**: 操作类型(如 '实时监测', '情景模拟', '应急评估' - **operation_type**: 操作类型(如 '实时监测', '情景模拟', '应急评估'
""" """
semaphore = get_prediction_semaphore() semaphore = get_prediction_semaphore()
@@ -180,3 +189,62 @@ async def predict_rainfall(req: RainfallPredictRequest):
logger.error(f"保存推理结果失败: {e}", exc_info=True) logger.error(f"保存推理结果失败: {e}", exc_info=True)
return PredictResponse(code=200, message="success", data=PredictData(record_id=record_id, list=result_map_with_location)) return PredictResponse(code=200, message="success", data=PredictData(record_id=record_id, list=result_map_with_location))
@router.post("/district-summary", summary="获取各区降雨概况")
async def district_summary(req: DistrictSummaryRequest):
"""
根据推理结果ID获取各行政区的降雨概况(用于报告生成)。
逻辑:
- 如果预测时传了 rainfall → 直接用 condition 中的值
- 如果预测时没传 rainfall → 从 xian_meteorology 按区聚合实际气象数据
- **inference_id**: 推理结果ID
"""
# 1. 查推理结果
record = dbn_repository.get_inference_result(req.inference_id)
if not record:
raise HTTPException(status_code=404, detail=f"推理结果不存在: {req.inference_id}")
condition = record.get('condition') or {}
occurred_time = record.get('occurred_time')
# 2. 如果传了 rainfall → 直接用 condition 中的值
if condition.get('rainfall') is not None:
region_code = condition.get('region_code')
rainfall_val = float(condition['rainfall'])
duration_val = float(condition.get('duration', 0))
if region_code:
# 查 district 名称
district_row = db_helper.execute_query_one(
"SELECT name FROM xian_district WHERE code = %s AND is_delete = 0", (region_code,)
)
district_name = district_row['name'] if district_row else region_code
items = [{
'district_name': district_name,
'district_code': region_code,
'rainfall': rainfall_val,
'duration_hours': duration_val
}]
else:
items = [{
'district_name': '全市',
'district_code': '',
'rainfall': rainfall_val,
'duration_hours': duration_val
}]
else:
# 3. 自动推演模式 → 从气象表按区聚合
items = rain_repo.get_district_rainfall_summary(occurred_time)
if not items:
return {"code": 200, "message": "success", "data": []}
# 转换为响应格式
result_items = [
DistrictSummaryItem(**item).model_dump()
for item in items
]
return {"code": 200, "message": "success", "data": result_items}
+13 -13
View File
@@ -39,26 +39,26 @@ class AppLauncher:
check_virtualenv(self.project_root) check_virtualenv(self.project_root)
# 检查是否正在使用虚拟环境运行 # 检查是否正在使用虚拟环境运行
import platform # sys.prefix != sys.base_prefix 是 Python 检测 venv 的标准方式
import sys # 不依赖路径解析,Windows/Linux 均适用
venv_path = self.project_root / ".venv" in_venv = hasattr(sys, 'real_prefix') or (
os_name = platform.system() hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix
)
if os_name == 'Windows': if not in_venv:
venv_python = venv_path / "Scripts" / "python.exe" import platform
else: # Linux/Mac venv_path = self.project_root / ".venv"
venv_python = venv_path / "bin" / "python3" os_name = platform.system()
# 如果当前不是使用虚拟环境的Python,则重新启动 if os_name == 'Windows':
current_python = Path(sys.executable).resolve() venv_python = venv_path / "Scripts" / "python.exe"
venv_python_resolved = venv_python.resolve() else: # Linux/Mac
venv_python = venv_path / "bin" / "python3"
if current_python != venv_python_resolved:
print("\n" + "=" * 50) print("\n" + "=" * 50)
print("检测到未使用虚拟环境,正在切换到虚拟环境...") print("检测到未使用虚拟环境,正在切换到虚拟环境...")
print("=" * 50) print("=" * 50)
# 使用虚拟环境的Python重新启动应用(不传递参数避免重复检查)
import subprocess import subprocess
cmd = [str(venv_python)] + sys.argv cmd = [str(venv_python)] + sys.argv
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
+4 -8
View File
@@ -6,8 +6,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from app.utils.api_deps import get_rainfall_model, get_earthquake_model, is_model_loaded from app.utils.api_deps import get_rainfall_model, get_earthquake_model
from app.schemas.api_schemas import HealthResponse
from app.config.paths import get_logger from app.config.paths import get_logger
from config import settings from config import settings
@@ -57,6 +56,9 @@ def create_app() -> FastAPI:
start = time.time() start = time.time()
response = await call_next(request) response = await call_next(request)
elapsed = time.time() - start elapsed = time.time() - start
# 静默健康检查探针
if request.url.path == "/" and request.method == "GET":
return response
logger.info(f"{request.method} {request.url.path} -> {response.status_code} ({elapsed:.3f}s)") logger.info(f"{request.method} {request.url.path} -> {response.status_code} ({elapsed:.3f}s)")
return response return response
@@ -64,10 +66,4 @@ def create_app() -> FastAPI:
from app.api import register_routers from app.api import register_routers
register_routers(application) register_routers(application)
@application.get("/health", response_model=HealthResponse, tags=["系统"])
async def health_check():
"""健康检查"""
status = is_model_loaded()
return HealthResponse(status="ok", **status)
return application return application
+20 -1
View File
@@ -195,6 +195,7 @@ class DbnRepository:
COUNT(*) as record_count COUNT(*) as record_count
FROM xian_meteorology FROM xian_meteorology
WHERE datetime BETWEEN %s AND %s WHERE datetime BETWEEN %s AND %s
AND is_delete = 0
AND rainfall_1h IS NOT NULL AND rainfall_1h IS NOT NULL
AND CAST(rainfall_1h AS DOUBLE PRECISION) > 0 AND CAST(rainfall_1h AS DOUBLE PRECISION) > 0
GROUP BY lon, lat GROUP BY lon, lat
@@ -259,6 +260,7 @@ class DbnRepository:
ST_SetSRID(ST_MakePoint(%s, %s), 4326) ST_SetSRID(ST_MakePoint(%s, %s), 4326)
) as dist ) as dist
FROM xian_meteorology FROM xian_meteorology
WHERE is_delete = 0
) t ) t
WHERE dist < 50000 WHERE dist < 50000
ORDER BY dist ORDER BY dist
@@ -283,6 +285,7 @@ class DbnRepository:
CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall
FROM xian_meteorology FROM xian_meteorology
WHERE lon = %s AND lat = %s WHERE lon = %s AND lat = %s
AND is_delete = 0
AND datetime BETWEEN %s AND %s AND datetime BETWEEN %s AND %s
ORDER BY datetime DESC ORDER BY datetime DESC
""" """
@@ -326,7 +329,7 @@ class DbnRepository:
"""一次性加载所有气象站点坐标到内存(188个站点,约2KB)""" """一次性加载所有气象站点坐标到内存(188个站点,约2KB)"""
if cls._cached_stations is not None: if cls._cached_stations is not None:
return cls._cached_stations return cls._cached_stations
sql = "SELECT DISTINCT lon, lat FROM xian_meteorology" sql = "SELECT DISTINCT lon, lat FROM xian_meteorology WHERE is_delete = 0"
cls._cached_stations = db_helper.execute_query(sql) cls._cached_stations = db_helper.execute_query(sql)
logger.info(f"已缓存 {len(cls._cached_stations)} 个气象站点坐标") logger.info(f"已缓存 {len(cls._cached_stations)} 个气象站点坐标")
return cls._cached_stations return cls._cached_stations
@@ -407,6 +410,7 @@ class DbnRepository:
SELECT lon, lat, datetime, CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall SELECT lon, lat, datetime, CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall
FROM xian_meteorology FROM xian_meteorology
WHERE (lon, lat) IN ({placeholders}) WHERE (lon, lat) IN ({placeholders})
AND is_delete = 0
AND datetime BETWEEN %s AND %s AND datetime BETWEEN %s AND %s
ORDER BY lon, lat, datetime DESC ORDER BY lon, lat, datetime DESC
""" """
@@ -688,6 +692,21 @@ class DbnRepository:
return float(result['aspect']) return float(result['aspect'])
return None return None
@staticmethod
def get_inference_result(inference_id: int) -> Optional[Dict[str, Any]]:
"""
根据ID获取推理结果
Returns:
{id, name, event_type, occurred_time, operation_type, condition, result}
"""
sql = """
SELECT id, name, event_type, occurred_time, operation_type, condition, result
FROM xian_inference_result
WHERE id = %s AND is_delete = 0
"""
return db_helper.execute_query_one(sql, (inference_id,))
@staticmethod @staticmethod
def save_inference_result(disaster_name: str, event_type: str, occurred_time, operation_type: str, def save_inference_result(disaster_name: str, event_type: str, occurred_time, operation_type: str,
condition: dict, result: list) -> int: condition: dict, result: list) -> int:
+1 -1
View File
@@ -12,7 +12,7 @@ class QgisRepository:
sql = """ sql = """
SELECT id, name, event_type, occurred_time, condition SELECT id, name, event_type, occurred_time, condition
FROM xian_inference_result FROM xian_inference_result
WHERE id = %s WHERE id = %s AND is_delete = 0
""" """
rows = db_helper.execute_query(sql, (inference_id,)) rows = db_helper.execute_query(sql, (inference_id,))
if not rows: if not rows:
+97
View File
@@ -28,6 +28,7 @@ class RainfallRepository:
SELECT max(id) as max_id SELECT max(id) as max_id
FROM xian_meteorology FROM xian_meteorology
WHERE datetime BETWEEN %s AND %s WHERE datetime BETWEEN %s AND %s
AND is_delete = 0
""" """
result = db_helper.execute_query_one(sql, (start_time, end_time)) result = db_helper.execute_query_one(sql, (start_time, end_time))
@@ -58,6 +59,7 @@ class RainfallRepository:
CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall_1h CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall_1h
FROM xian_meteorology FROM xian_meteorology
WHERE datetime BETWEEN %s AND %s WHERE datetime BETWEEN %s AND %s
AND is_delete = 0
ORDER BY lon, lat, datetime DESC ORDER BY lon, lat, datetime DESC
""" """
@@ -100,5 +102,100 @@ class RainfallRepository:
return station_data return station_data
def get_district_rainfall_summary(self, query_time, region_code: Optional[str] = None) -> List[Dict[str, Any]]:
"""
按行政区聚合降雨统计(取区内最大站点值)
Args:
query_time: 查询时间
region_code: 行政区划代码,不传则返回所有区
Returns:
[{district_name, district_code, rainfall, duration_hours}, ...]
"""
start_time, end_time = time_converter.to_db_time_range(query_time, hours=72)
# 一次查询:area_code + lon + lat + rainfall_1h
sql = """
SELECT area_code, lon, lat, datetime,
CAST(rainfall_1h AS DOUBLE PRECISION) as rainfall_1h
FROM xian_meteorology
WHERE datetime BETWEEN %s AND %s
AND is_delete = 0
ORDER BY area_code, lon, lat, datetime DESC
"""
rows = db_helper.execute_query(sql, (start_time, end_time))
if not rows:
return []
# 按站点分组,计算 accum_rain + duration_hours(复用现有算法)
from itertools import groupby
station_stats: Dict[tuple, Dict[str, Any]] = {} # (area_code, lon, lat) → stats
for (area_code, lon, lat), group in groupby(rows,
key=lambda r: (r['area_code'], r['lon'], r['lat'])):
accum_rain = 0.0
duration_hours = 0
consecutive_no_rain = 0
for row in group:
rainfall = float(row['rainfall_1h']) if row['rainfall_1h'] else 0.0
if rainfall > 0:
accum_rain += rainfall
duration_hours += 1
consecutive_no_rain = 0
else:
consecutive_no_rain += 1
if consecutive_no_rain >= 3:
break
if accum_rain > 0:
duration_hours += 1
key = (area_code, lon, lat)
station_stats[key] = {
'area_code': area_code,
'accum_rain': accum_rain,
'duration_hours': duration_hours
}
# 按 area_code 聚合:取区内最大降雨站点
district_max: Dict[str, Dict[str, Any]] = {}
for key, stats in station_stats.items():
code = stats['area_code']
if region_code and code != region_code:
continue
if code not in district_max or stats['accum_rain'] > district_max[code]['rainfall']:
district_max[code] = {
'district_code': code,
'rainfall': round(stats['accum_rain'], 1),
'duration_hours': stats['duration_hours']
}
if not district_max:
return []
# 查 xian_district 获取名称
codes = list(district_max.keys())
placeholders = ', '.join(['%s'] * len(codes))
sql = f"SELECT code, name FROM xian_district WHERE code IN ({placeholders}) AND is_delete = 0"
district_rows = db_helper.execute_query(sql, tuple(codes))
code_to_name = {r['code']: r['name'] for r in district_rows}
result = []
for code, info in district_max.items():
name = code_to_name.get(code)
if name is None:
continue # 跳过 xian_district 中不存在的代码
result.append({
'district_name': name,
'district_code': code,
'rainfall': info['rainfall'],
'duration_hours': info['duration_hours']
})
# 按名称排序
result.sort(key=lambda x: x['district_name'])
return result
# 创建全局实例 # 创建全局实例
rainfall_repository = RainfallRepository() rainfall_repository = RainfallRepository()
+47 -7
View File
@@ -3,7 +3,7 @@ API 请求/响应数据模型
""" """
from datetime import datetime from datetime import datetime
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
# ============================================================ # ============================================================
@@ -11,19 +11,35 @@ from pydantic import BaseModel, Field
# ============================================================ # ============================================================
class RainfallPredictRequest(BaseModel): class RainfallPredictRequest(BaseModel):
"""暴雨灾害链预测请求""" """
暴雨灾害链预测请求
参数规则(二选一):
1. 自动推演模式:rainfall、duration、region_code 全部不传 → 从气象表自动获取,不限区域
2. 指定条件模式:rainfall、duration、region_code 全部传入 → 按指定条件预测
"""
disaster_name: str = Field(min_length=1, max_length=255) disaster_name: str = Field(min_length=1, max_length=255)
point_ids: Optional[List[int]] = Field(None, max_length=500, point_ids: Optional[List[int]] = Field(None, max_length=500,
description="点位ID列表,不传则查询所有点") description="点位ID列表,不传则查询所有点")
region_code: Optional[str] = Field(None, description="行政区划代码(如 '610104',不传则不限区域") region_code: Optional[str] = Field(None, description="行政区划代码(如 '610104'")
rainfall: Optional[float] = Field(None, ge=0, rainfall: Optional[float] = Field(None, ge=0, description="累计降雨量(mm)")
description="累计降雨量(mm),不传则从气象表自动获取") duration: Optional[float] = Field(None, ge=0, description="降雨持续时间(h)")
duration: Optional[float] = Field(None, ge=0,
description="降雨持续时间(h),不传则从气象表自动获取")
occurred_time: Optional[datetime] = Field(None, description="事件发生时间,不传则为当前时间") occurred_time: Optional[datetime] = Field(None, description="事件发生时间,不传则为当前时间")
operation_type: str = Field("模拟", min_length=1, max_length=50, operation_type: str = Field("模拟", min_length=1, max_length=50,
description="操作类型(如 '模拟', '实时监测', '应急评估'") description="操作类型(如 '模拟', '实时监测', '应急评估'")
@model_validator(mode='after')
def validate_mode_exclusivity(self):
"""校验 rainfall / duration / region_code 必须要么全不传,要么全传"""
trio = (self.rainfall, self.duration, self.region_code)
none_count = sum(1 for v in trio if v is None)
if none_count not in (0, 3):
raise ValueError(
"rainfall、duration、region_code 必须全部传入或全部不传,"
"不允许只传部分参数"
)
return self
# ============================================================ # ============================================================
# 地震预测 # 地震预测
@@ -86,3 +102,27 @@ class HealthResponse(BaseModel):
status: str = "ok" status: str = "ok"
rainfall_model_loaded: bool = False rainfall_model_loaded: bool = False
earthquake_model_loaded: bool = False earthquake_model_loaded: bool = False
# ============================================================
# 各区降雨概况
# ============================================================
class DistrictSummaryRequest(BaseModel):
"""各区降雨概况请求"""
inference_id: int = Field(..., ge=1, description="推理结果IDxian_inference_result.id")
class DistrictSummaryItem(BaseModel):
"""单个区的降雨概况"""
district_name: str = Field(..., description="行政区名称(如 '碑林区'")
district_code: str = Field(..., description="行政区划代码(如 '610103'")
rainfall: float = Field(..., description="累计降雨量(mm)")
duration_hours: float = Field(..., description="持续降雨时间(h)")
class DistrictSummaryResponse(BaseModel):
"""各区降雨概况响应"""
code: int = Field(200, description="状态码")
message: str = Field("success", description="提示信息")
data: Optional[List[DistrictSummaryItem]] = Field(None, description="各区降雨概况列表")
+159
View File
@@ -0,0 +1,159 @@
"""
将项目 fonts/ 目录下的中文字体安装到 Docker QGIS 容器。
QGIS 官方 Docker 镜像不包含中文字体,模板中的 SimHei/SimSun/YaHei 字体会显示为方块。
本脚本将 fonts/ 目录下的字体文件复制到容器内并刷新字体缓存。
用法:
python app/script/install_fonts_to_container.py [--container qgis-server] [--dry-run]
前置条件:
- Docker 容器已启动(docker start qgis-server
- 项目根目录下 fonts/ 目录包含所需的 .ttf/.ttc 字体文件
"""
import argparse
import subprocess
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
FONTS_DIR = PROJECT_ROOT / "fonts"
# 容器内字体目录
CONTAINER_FONT_DIR = "/usr/share/fonts/truetype/winfonts"
def _run(cmd, timeout=10, **kwargs):
"""subprocess.run 封装,统一 UTF-8 编码"""
return subprocess.run(
cmd, capture_output=True, timeout=timeout,
encoding="utf-8", errors="replace", **kwargs,
)
def _load_config():
"""从 settings.toml 读取配置"""
try:
from config import settings
return {
"container": getattr(settings, "QGIS_DOCKER_CONTAINER", "qgis-server"),
}
except ImportError:
pass
cfg = {"container": "qgis-server"}
toml_path = PROJECT_ROOT / "settings.toml"
if toml_path.exists():
for line in toml_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("QGIS_DOCKER_CONTAINER") and "=" in line:
cfg["container"] = line.split("=", 1)[1].strip().strip('"').strip("'")
return cfg
def _check_container(container: str):
"""检查容器是否运行"""
result = _run(["docker", "inspect", "--format={{.State.Running}}", container], timeout=5)
if (result.stdout or "").strip() != "true":
raise RuntimeError(f"容器 {container} 未运行,请先 docker start {container}")
def install_fonts(container: str = None, dry_run: bool = False):
"""将 fonts/ 目录下的字体安装到容器"""
cfg = _load_config()
if container is None:
container = cfg["container"]
_check_container(container)
# 扫描字体文件
if not FONTS_DIR.is_dir():
print(f"字体目录不存在: {FONTS_DIR}")
print(f"请先在项目根目录下创建 fonts/ 目录,并放入 .ttf/.ttc 字体文件。")
print(f"Windows 字体路径: C:\\Windows\\Fonts\\")
print(f" simhei.ttf — 黑体(模板默认字体)")
print(f" simsun.ttc — 宋体")
print(f" msyh.ttc — 微软雅黑")
print(f" msyhbd.ttc — 微软雅黑粗体")
sys.exit(1)
font_files = [f for f in FONTS_DIR.iterdir()
if f.is_file() and f.suffix.lower() in (".ttf", ".ttc", ".otf")]
if not font_files:
print(f"字体目录为空或无字体文件: {FONTS_DIR}")
print(f"请放入 .ttf/.ttc/.otf 字体文件后重试。")
sys.exit(1)
print(f"=== 安装中文字体到 Docker 容器 {container} ===\n")
print(f" 字体目录: {FONTS_DIR}")
print(f" 字体文件: {len(font_files)}")
for f in sorted(font_files):
size_kb = f.stat().st_size / 1024
print(f" - {f.name} ({size_kb:.0f} KB)")
print(f" 容器目标: {container}:{CONTAINER_FONT_DIR}")
print()
if dry_run:
print(" [dry-run] 跳过安装")
return
# 1. 在容器内创建字体目录
t0 = time.time()
_run(["docker", "exec", container, "mkdir", "-p", CONTAINER_FONT_DIR])
# 2. 逐个复制字体文件到容器
print(" [1/3] 复制字体文件...")
for f in sorted(font_files):
result = _run(
["docker", "cp", str(f), f"{container}:{CONTAINER_FONT_DIR}/{f.name}"],
timeout=30,
)
if result.returncode != 0:
print(f" FAIL {f.name}: {result.stderr.strip()}")
else:
print(f" OK {f.name}")
# 3. 刷新字体缓存
print("\n [2/3] 刷新字体缓存...")
result = _run(["docker", "exec", container, "fc-cache", "-fv"], timeout=30)
if result.returncode != 0:
print(f" WARN fc-cache 输出: {result.stderr.strip()}")
else:
print(" OK")
# 4. 验证字体
print("\n [3/3] 验证字体...")
verify_script = (
"from PyQt5.QtGui import QFontDatabase; "
"db = QFontDatabase(); "
"zh = [f for f in db.families() if any(k in f for k in "
"['SimHei','YaHei','SimSun','WenQuanYi','Noto Sans CJK'])]; "
"print('中文字体:', zh if zh else '未安装!')"
)
result = _run(
["docker", "exec", container, "python3", "-c", verify_script],
timeout=10,
)
print(f" {(result.stdout or '').strip()}")
elapsed = time.time() - t0
print(f"\n=== 完成,耗时 {elapsed:.1f}s ===")
def main():
parser = argparse.ArgumentParser(description="将中文字体安装到 Docker QGIS 容器")
parser.add_argument("--container", default=None, help="Docker 容器名称")
parser.add_argument("--dry-run", action="store_true", help="仅显示信息,不实际安装")
args = parser.parse_args()
try:
install_fonts(container=args.container, dry_run=args.dry_run)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+12 -5
View File
@@ -172,6 +172,8 @@ def map_template_to_container(host_path: str) -> str:
主机: F:/project/xian/xian_algorithm_new/app/data/template/rainfall/xxx.qgz 主机: F:/project/xian/xian_algorithm_new/app/data/template/rainfall/xxx.qgz
容器: /data/template/rainfall/xxx.qgz 容器: /data/template/rainfall/xxx.qgz
已为容器路径时直接返回(幂等)。
""" """
try: try:
from config import settings from config import settings
@@ -179,16 +181,21 @@ def map_template_to_container(host_path: str) -> str:
except Exception: except Exception:
container_tpl = "" container_tpl = ""
# 已经是容器路径,直接返回
normalized = host_path.replace("\\", "/")
if container_tpl and normalized.startswith(container_tpl.rstrip("/") + "/"):
return normalized
if not container_tpl: if not container_tpl:
# fallback: 用通用映射(走 9P,慢) # fallback: 用通用映射(走 9P,慢)
return map_host_to_container(host_path) return map_host_to_container(host_path)
normalized = host_path.replace("\\", "/").lower() normalized_lower = normalized.lower()
# 找 "app/data/template/" 后面的部分 # 找模板子目录后面的相对路径(主机端 marker 从配置读取)
marker = "app/data/template/" marker = getattr(settings, "QGIS_HOST_TEMPLATE_SUBDIR", "app/data/template").rstrip("/") + "/"
idx = normalized.find(marker) idx = normalized_lower.find(marker.lower())
if idx >= 0: if idx >= 0:
relative = host_path.replace("\\", "/")[idx + len(marker):] relative = normalized[idx + len(marker):]
return f"{container_tpl.rstrip('/')}/{relative}" return f"{container_tpl.rstrip('/')}/{relative}"
return map_host_to_container(host_path) return map_host_to_container(host_path)
+1 -1
View File
@@ -139,7 +139,7 @@ class QgisPool:
for line in worker.proc.stderr: for line in worker.proc.stderr:
line = line.strip() line = line.strip()
if line: if line:
logger.debug(f"[Worker-{worker.worker_id}] stderr: {line}") logger.info(f"[Worker-{worker.worker_id}] {line}")
except Exception: except Exception:
pass pass
+8 -1
View File
@@ -188,4 +188,11 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
except Exception as e:
# 捕获 main() 未处理的异常,确保输出到 stderr
import traceback
print(f"[worker] 未捕获异常: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
sys.exit(1)
+20
View File
@@ -23,6 +23,19 @@ class RedisHelper:
'socket_timeout': 5, # 读写超时时间(秒) 'socket_timeout': 5, # 读写超时时间(秒)
} }
self._client: Optional[redis.Redis] = None self._client: Optional[redis.Redis] = None
self._logged_config = False # 避免重复打印配置
def _log_config_once(self):
"""首次连接失败时打印配置信息,便于排查"""
if not self._logged_config:
from app.utils.logger import get_logger
_logger = get_logger("redis")
_logger.warning(
f"Redis 连接配置: host={self.redis_config['host']}, "
f"port={self.redis_config['port']}, db={self.redis_config['db']}, "
f"password={'***' if self.redis_config['password'] else 'None'}"
)
self._logged_config = True
@property @property
def client(self) -> redis.Redis: def client(self) -> redis.Redis:
@@ -32,8 +45,15 @@ class RedisHelper:
self._client = redis.Redis(**self.redis_config) self._client = redis.Redis(**self.redis_config)
# 测试连接 # 测试连接
self._client.ping() self._client.ping()
except redis.AuthenticationError as e:
self._log_config_once()
raise ConnectionError(f"Redis 认证失败(密码错误): {e}")
except redis.ConnectionError as e: except redis.ConnectionError as e:
self._log_config_once()
raise ConnectionError(f"无法连接到 Redis 服务器: {e}") raise ConnectionError(f"无法连接到 Redis 服务器: {e}")
except Exception as e:
self._log_config_once()
raise ConnectionError(f"Redis 连接异常: {e}")
return self._client return self._client
def close(self): def close(self):
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2
View File
@@ -12,6 +12,8 @@ PREDICT_PROBABILITY_THRESHOLD = 50
QGIS_GPKG_DIR = "app/data/gpkg" QGIS_GPKG_DIR = "app/data/gpkg"
# 容器内 GPKG 本地路径 # 容器内 GPKG 本地路径
QGIS_DOCKER_GPKG_DIR = "/data/gpkg" QGIS_DOCKER_GPKG_DIR = "/data/gpkg"
# 模板目录(相对于项目根,用于路径映射时的 marker 匹配)
QGIS_HOST_TEMPLATE_SUBDIR = "app/data/template"
# 容器内模板本地路径 # 容器内模板本地路径
QGIS_DOCKER_TEMPLATE_DIR = "/data/template" QGIS_DOCKER_TEMPLATE_DIR = "/data/template"
# 专题图输出子目录 # 专题图输出子目录
Binary file not shown.