初始化集成qgis
This commit is contained in:
@@ -8,6 +8,8 @@ def register_routers(application: FastAPI):
|
|||||||
"""注册所有路由"""
|
"""注册所有路由"""
|
||||||
from app.api.rainfall import router as rainfall_router
|
from app.api.rainfall import router as rainfall_router
|
||||||
from app.api.earthquake import router as earthquake_router
|
from app.api.earthquake import router as earthquake_router
|
||||||
|
from app.api.qgis_map_export import router as qgis_router
|
||||||
|
|
||||||
application.include_router(rainfall_router)
|
application.include_router(rainfall_router)
|
||||||
application.include_router(earthquake_router)
|
application.include_router(earthquake_router)
|
||||||
|
application.include_router(qgis_router)
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
QGIS 专题图导出接口
|
||||||
|
- 查询该记录的 occurred_time,格式化为时间戳(如 20260619143000)作为文件夹名
|
||||||
|
- 同一 occurred_time 视为同一场灾害,共享文件夹,只产图一次
|
||||||
|
- 线程池并发产图(默认 4 worker)
|
||||||
|
- 输出:FILE_STORE_DIR/xian/qgis/map/{disasterTime}/{模板名称}.jpg
|
||||||
|
- 标题格式:陕西西安{区县名称}{震级/降雨量}{模板名称}
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
from app.repositories import qgis_repository
|
||||||
|
from app.schemas.api_schemas import QgisMapExportResponse, QgisMapExportRequest
|
||||||
|
from app.utils.api_deps import get_prediction_semaphore
|
||||||
|
from app.utils.db_helper import db_helper
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = get_logger("api.qgis")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/qgis", tags=["专题图导出"])
|
||||||
|
|
||||||
|
# 线程池(按配置初始化 worker 数量)
|
||||||
|
_worker_threads = getattr(settings, "QGIS_WORKER_THREADS", 4)
|
||||||
|
_thread_pool = concurrent.futures.ThreadPoolExecutor(
|
||||||
|
max_workers=_worker_threads,
|
||||||
|
thread_name_prefix="qgis-worker",
|
||||||
|
)
|
||||||
|
logger.info(f"QGIS 线程池初始化: {_worker_threads} workers")
|
||||||
|
|
||||||
|
# 去重锁:in_progress_locks[disaster_time] = asyncio.Lock
|
||||||
|
# 同一灾害时间的并发请求会排队,避免重复产图
|
||||||
|
_in_progress_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
_locks_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# 西安市中心
|
||||||
|
xian_center = getattr( settings, "XIAN_CENTER", [108.948024, 34.263161])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 时间戳格式化
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def format_disaster_time(occurred_time) -> str:
|
||||||
|
"""将 occurred_time 格式化为时间戳字符串(YYYYMMDDHHmmss),作为文件夹名"""
|
||||||
|
if isinstance(occurred_time, datetime):
|
||||||
|
return occurred_time.strftime("%Y%m%d%H%M%S")
|
||||||
|
elif occurred_time:
|
||||||
|
return str(occurred_time).replace("-", "").replace(":", "").replace(" ", "")[:14]
|
||||||
|
else:
|
||||||
|
return datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# region_code → 区县名称映射
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
REGION_CODE_MAP = settings.area.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_district(condition: dict) -> str:
|
||||||
|
"""从 condition.region_code 映射区县名称"""
|
||||||
|
code = condition.get("region_code")
|
||||||
|
if code and str(code) in REGION_CODE_MAP:
|
||||||
|
return REGION_CODE_MAP[str(code)]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_map_title(event_type: str, condition: dict, template_name: str) -> str:
|
||||||
|
"""
|
||||||
|
构建地图标题。
|
||||||
|
|
||||||
|
格式:陕西西安{区县名称}{震级/降雨量}{模板名称}
|
||||||
|
- 地震:陕西西安临潼区5.1级地震专题图
|
||||||
|
- 暴雨(有降雨量):陕西西安长安区120mm降雨专题图
|
||||||
|
- 暴雨(无降雨量):陕西西安降雨专题图
|
||||||
|
"""
|
||||||
|
district = _resolve_district(condition)
|
||||||
|
prefix = f"陕西西安{district}" if district else "陕西西安"
|
||||||
|
|
||||||
|
if event_type == "earthquake":
|
||||||
|
magnitude = condition.get("magnitude")
|
||||||
|
if magnitude is not None:
|
||||||
|
return f"{prefix}{float(magnitude)}级{template_name}"
|
||||||
|
return f"{prefix}{template_name}"
|
||||||
|
|
||||||
|
elif event_type == "rainfall":
|
||||||
|
rainfall = condition.get("rainfall")
|
||||||
|
if rainfall is not None and rainfall != "":
|
||||||
|
return f"{prefix}{float(rainfall)}mm{template_name}"
|
||||||
|
return f"{prefix}{template_name}"
|
||||||
|
|
||||||
|
return f"{prefix}{template_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_model_params(
|
||||||
|
inference: dict, batch_folder: str, out_filename: str
|
||||||
|
) -> dict:
|
||||||
|
"""从推理结果 + 配置文件推导专题图生成所需的全部参数。"""
|
||||||
|
event_type = inference["event_type"]
|
||||||
|
condition = inference["condition"]
|
||||||
|
occurred_time = inference["occurred_time"]
|
||||||
|
|
||||||
|
map_layout = getattr(settings, "QGIS_DEFAULTS_MAP_LAYOUT", "A3")
|
||||||
|
zoom_rule = getattr(settings, "QGIS_DEFAULTS_ZOOM_RULE", "11")
|
||||||
|
zoom_value = getattr(settings, "QGIS_DEFAULTS_ZOOM_VALUE", "50")
|
||||||
|
map_unit = getattr(settings, "QGIS_DEFAULTS_MAP_UNIT", "西安市应急管理局")
|
||||||
|
template_base = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"app", "data", "template"
|
||||||
|
)
|
||||||
|
|
||||||
|
template_path = os.path.join(template_base, event_type, f"{map_layout}.qgz")
|
||||||
|
|
||||||
|
template_name_map = {"earthquake": "地震专题图", "rainfall": "降雨专题图"}
|
||||||
|
template_name = template_name_map.get(event_type, f"{event_type}专题图")
|
||||||
|
|
||||||
|
map_title = _build_map_title(event_type, condition, template_name)
|
||||||
|
|
||||||
|
safe_name = re.sub(r'[\\/:*?"<>|]', '_', template_name)
|
||||||
|
out_file = os.path.join(batch_folder, f"{safe_name}.jpg").replace("\\", "/")
|
||||||
|
|
||||||
|
if occurred_time and isinstance(occurred_time, datetime):
|
||||||
|
map_time = occurred_time.strftime("%Y-%m-%d %H:%M")
|
||||||
|
elif occurred_time:
|
||||||
|
map_time = str(occurred_time)
|
||||||
|
else:
|
||||||
|
map_time = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
center_x, center_y = _extract_center_from_condition(
|
||||||
|
event_type, condition
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": f"inference_{inference['id']}",
|
||||||
|
"path": template_path,
|
||||||
|
"outFile": out_file,
|
||||||
|
"mapLayout": map_layout,
|
||||||
|
"mapTitle": map_title,
|
||||||
|
"mapTime": map_time,
|
||||||
|
"mapUint": map_unit,
|
||||||
|
"info": "",
|
||||||
|
"centerX": center_x,
|
||||||
|
"centerY": center_y,
|
||||||
|
"event": str(inference["id"]),
|
||||||
|
"queueId": str(inference["id"]),
|
||||||
|
"zoomRule": zoom_rule,
|
||||||
|
"zoomValue": zoom_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_center_from_condition(event_type: str, condition: dict) -> tuple:
|
||||||
|
"""从 condition 提取地图中心坐标"""
|
||||||
|
if event_type == "earthquake":
|
||||||
|
lon = condition.get("epicenter_lon", xian_center[0])
|
||||||
|
lat = condition.get("epicenter_lat", xian_center[1])
|
||||||
|
return float(lon), float(lat)
|
||||||
|
else:
|
||||||
|
return xian_center[0], xian_center[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 构建 QGIS 服务配置字典
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _build_qgis_config(batch_folder: str) -> dict:
|
||||||
|
"""构建 QGIS 服务配置(含批次输出目录)"""
|
||||||
|
gpkg_subdir = getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg")
|
||||||
|
project_root = os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
)
|
||||||
|
gpkg_dir = os.path.join(project_root, gpkg_subdir).replace("\\", "/")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"db": {
|
||||||
|
"host": settings.DB_HOST,
|
||||||
|
"port": settings.DB_PORT,
|
||||||
|
"database": settings.DB_NAME,
|
||||||
|
"username": settings.DB_USER,
|
||||||
|
"password": settings.DB_PASSWORD,
|
||||||
|
},
|
||||||
|
"qgis": {
|
||||||
|
"exportDpi": getattr(settings, "QGIS_EXPORT_DPI", 300),
|
||||||
|
},
|
||||||
|
"template_override": {
|
||||||
|
"enabled": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ENABLED", False),
|
||||||
|
"original": {
|
||||||
|
"host": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_HOST", "localhost"),
|
||||||
|
"port": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_PORT", 5432),
|
||||||
|
"dbname": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_DB_NAME", "yjzyk_xian"),
|
||||||
|
"schema": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ORIGINAL_SCHEMA", "base"),
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"host": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_HOST", ""),
|
||||||
|
"port": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_PORT", ""),
|
||||||
|
"dbname": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_DB_NAME", "xian_new"),
|
||||||
|
"schema": getattr(settings, "QGIS_TEMPLATE_OVERRIDE_ACTUAL_SCHEMA", "qgis"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"static_layers": _build_static_layers_config(gpkg_dir),
|
||||||
|
"batch_folder": batch_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_static_layers_config(gpkg_dir: str) -> dict:
|
||||||
|
"""构建静态底图配置"""
|
||||||
|
layer_defs = {
|
||||||
|
"水库": ("qgis.rivers", "rivers.gpkg"),
|
||||||
|
"市州驻地": ("qgis.sx_capital", "sx_capital.gpkg"),
|
||||||
|
"河流": ("qgis.river", "river.gpkg"),
|
||||||
|
"active_fault": ("qgis.active_fault", "active_fault.gpkg"),
|
||||||
|
"陕西省": ("qgis.sx", "sx.gpkg"),
|
||||||
|
"乡镇驻地": ("qgis.sx_street", "sx_street.gpkg"),
|
||||||
|
"区县驻地": ("qgis.sx_xa_county", "sx_xa_county.gpkg"),
|
||||||
|
"县界": ("qgis.sx_xa_county_boundary", "sx_xa_county_boundary.gpkg"),
|
||||||
|
"周边区县": ("qgis.sx_zb_county_boundary", "sx_zb_county_boundary.gpkg"),
|
||||||
|
"周边市州": ("qgis.sx_zb_city", "sx_zb_city.gpkg"),
|
||||||
|
"周边县区": ("qgis.sx_zb_county", "sx_zb_county.gpkg"),
|
||||||
|
"traffic_expressway": ("qgis.traffic_expressway", "traffic_expressway.gpkg"),
|
||||||
|
"traffic_provincial": ("qgis.traffic_provincial", "traffic_provincial.gpkg"),
|
||||||
|
"traffic_railway": ("qgis.traffic_railway", "traffic_railway.gpkg"),
|
||||||
|
"traffic_township": ("qgis.traffic_township", "traffic_township.gpkg"),
|
||||||
|
"traffic_trunk_line": ("qgis.traffic_trunk_line", "traffic_trunk_line.gpkg"),
|
||||||
|
}
|
||||||
|
|
||||||
|
layers = {}
|
||||||
|
for name, (table, gpkg_file) in layer_defs.items():
|
||||||
|
layers[name] = {"file": gpkg_file, "table": table}
|
||||||
|
|
||||||
|
return {"enabled": True, "gpkg_dir": gpkg_dir, "layers": layers}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 接口实现
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.post("/export/map", response_model=QgisMapExportResponse, summary="QGIS 批量专题图导出")
|
||||||
|
async def export_map(req: QgisMapExportRequest):
|
||||||
|
"""
|
||||||
|
根据模拟ID批量导出专题图。同一 occurred_time 视为同一场灾害,共享文件夹
|
||||||
|
"""
|
||||||
|
from app.services.qgis.qgis_env import is_qgis_ready
|
||||||
|
if not is_qgis_ready():
|
||||||
|
raise HTTPException(status_code=503, detail="QGIS 环境未初始化,专题图功能不可用")
|
||||||
|
|
||||||
|
semaphore = get_prediction_semaphore()
|
||||||
|
|
||||||
|
async with semaphore:
|
||||||
|
simulation_id = req.simulationId
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 查询推理记录,获取 occurred_time
|
||||||
|
inference = await loop.run_in_executor(
|
||||||
|
None, qgis_repository.query_inference_result, simulation_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 将 occurred_time 格式化为时间戳作为文件夹名
|
||||||
|
disaster_time = format_disaster_time(inference["occurred_time"])
|
||||||
|
|
||||||
|
# 构建批次文件夹路径
|
||||||
|
file_store = getattr(settings, "FILE_STORE_DIR", "G:/files")
|
||||||
|
output_tmpl = getattr(settings, "QGIS_OUTPUT_DIR", "xian/qgis/map/:disasterTime")
|
||||||
|
output_dir = output_tmpl.replace(":disasterTime", disaster_time)
|
||||||
|
batch_folder = os.path.join(file_store, output_dir).replace("\\", "/")
|
||||||
|
|
||||||
|
# 去重检查:同一 disasterTime 只产图一次
|
||||||
|
if os.path.isdir(batch_folder):
|
||||||
|
existing_files = [
|
||||||
|
f for f in os.listdir(batch_folder) if f.endswith(".jpg")
|
||||||
|
]
|
||||||
|
if existing_files and disaster_time not in _in_progress_locks:
|
||||||
|
logger.info(
|
||||||
|
f"[去重] disasterTime={disaster_time} 已产图,直接返回 "
|
||||||
|
f"({len(existing_files)} 张)"
|
||||||
|
)
|
||||||
|
return QgisMapExportResponse(
|
||||||
|
code=200,
|
||||||
|
message=f"已存在,共{len(existing_files)}张",
|
||||||
|
data=disaster_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 去重锁:同一灾害时间排队等待
|
||||||
|
async with _locks_lock:
|
||||||
|
if disaster_time not in _in_progress_locks:
|
||||||
|
_in_progress_locks[disaster_time] = asyncio.Lock()
|
||||||
|
task_lock = _in_progress_locks[disaster_time]
|
||||||
|
|
||||||
|
async with task_lock:
|
||||||
|
if os.path.isdir(batch_folder):
|
||||||
|
existing_files = [
|
||||||
|
f for f in os.listdir(batch_folder) if f.endswith(".jpg")
|
||||||
|
]
|
||||||
|
if existing_files:
|
||||||
|
logger.info(
|
||||||
|
f"[去重] disasterTime={disaster_time} 已被并发请求产图完成"
|
||||||
|
)
|
||||||
|
return QgisMapExportResponse(
|
||||||
|
code=200,
|
||||||
|
message=f"已存在,共{len(existing_files)}张",
|
||||||
|
data=disaster_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"推理结果查询成功: id={inference['id']}, "
|
||||||
|
f"type={inference['event_type']}, "
|
||||||
|
f"occurred_time={inference['occurred_time']}, "
|
||||||
|
f"disasterTime={disaster_time}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 推导参数 + 构建配置
|
||||||
|
os.makedirs(batch_folder, exist_ok=True)
|
||||||
|
|
||||||
|
event_type = inference["event_type"]
|
||||||
|
template_name_map = {"earthquake": "地震专题图", "rainfall": "降雨专题图"}
|
||||||
|
template_name = template_name_map.get(event_type, f"{event_type}专题图")
|
||||||
|
out_filename = f"{template_name}.jpg"
|
||||||
|
|
||||||
|
model = _derive_model_params(inference, batch_folder, out_filename)
|
||||||
|
config = _build_qgis_config(batch_folder)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"模板路径: {model['path']}, "
|
||||||
|
f"输出: {model['outFile']}, "
|
||||||
|
f"标题: {model['mapTitle']}, "
|
||||||
|
f"中心: ({model['centerX']}, {model['centerY']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提交到线程池
|
||||||
|
_thread_pool.submit(
|
||||||
|
_generate_single_map, model, config, disaster_time
|
||||||
|
)
|
||||||
|
|
||||||
|
return QgisMapExportResponse(
|
||||||
|
code=200,
|
||||||
|
message="任务已提交",
|
||||||
|
data=disaster_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"参数错误: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
logger.error(f"模板文件不存在: {e}")
|
||||||
|
raise HTTPException(status_code=404, detail=f"模板文件不存在: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"专题图导出失败: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"导出失败: {e}")
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_single_map(model: dict, config: dict, disaster_time: str) -> None:
|
||||||
|
"""线程池工作函数:在工作线程中生成单张专题图"""
|
||||||
|
from app.services.qgis.map_service import MapService
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"[线程池] 开始产图: {model['mapTitle']} → {model['outFile']}")
|
||||||
|
service = MapService(config)
|
||||||
|
service.generate(model)
|
||||||
|
|
||||||
|
if os.path.exists(model["outFile"]):
|
||||||
|
logger.info(f"[线程池] 产图成功: {model['outFile']}")
|
||||||
|
else:
|
||||||
|
logger.error(f"[线程池] 产图后文件不存在: {model['outFile']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[线程池] 产图失败: {model['name']} — {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 清理函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def shutdown_thread_pool() -> None:
|
||||||
|
"""关闭线程池(在 server.py lifespan 关闭阶段调用)"""
|
||||||
|
_thread_pool.shutdown(wait=False)
|
||||||
|
logger.info("QGIS 线程池已关闭")
|
||||||
+23
-2
@@ -9,6 +9,7 @@ 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, is_model_loaded
|
||||||
from app.schemas.api_schemas import HealthResponse
|
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
|
||||||
|
|
||||||
logger = get_logger("api")
|
logger = get_logger("api")
|
||||||
|
|
||||||
@@ -20,9 +21,29 @@ async def lifespan(app: FastAPI):
|
|||||||
get_rainfall_model()
|
get_rainfall_model()
|
||||||
get_earthquake_model()
|
get_earthquake_model()
|
||||||
logger.info("DBN模型预加载完成")
|
logger.info("DBN模型预加载完成")
|
||||||
yield
|
|
||||||
logger.info("应用关闭")
|
|
||||||
|
|
||||||
|
# 初始化 QGIS 环境
|
||||||
|
qgis_root = getattr(settings, "QGIS_ROOT", None)
|
||||||
|
if qgis_root:
|
||||||
|
try:
|
||||||
|
from app.services.qgis.qgis_env import init_qgis_env
|
||||||
|
init_qgis_env(qgis_root)
|
||||||
|
logger.info("QGIS 环境初始化完成")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"QGIS 环境初始化失败(专题图功能不可用): {e}")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 清理 QGIS 资源
|
||||||
|
try:
|
||||||
|
from app.services.qgis.qgis_env import cleanup_qgis_env
|
||||||
|
from app.services.qgis.map_service import template_cache
|
||||||
|
template_cache.cleanup()
|
||||||
|
cleanup_qgis_env()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("应用关闭")
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""创建 FastAPI 应用实例"""
|
"""创建 FastAPI 应用实例"""
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
Repositories package - 数据访问层
|
Repositories package - 数据访问层
|
||||||
"""
|
"""
|
||||||
from app.repositories.rainfall_repository import rainfall_repository, RainfallRepository
|
from app.repositories.rainfall_repository import rainfall_repository, RainfallRepository
|
||||||
|
from app.repositories.qgis_repository import qgis_repository, QgisRepository
|
||||||
|
|
||||||
__all__ = ['rainfall_repository', 'RainfallRepository']
|
__all__ = ['rainfall_repository', 'RainfallRepository', 'qgis_repository', 'QgisRepository']
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from app.config.paths import get_logger
|
||||||
|
from app.utils.db_helper import db_helper
|
||||||
|
|
||||||
|
logger = get_logger("qgis")
|
||||||
|
|
||||||
|
|
||||||
|
class QgisRepository:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def query_inference_result(inference_id: int) -> dict:
|
||||||
|
"""根据 inferenceId 查询 xian_inference_result"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, name, event_type, occurred_time, condition
|
||||||
|
FROM xian_inference_result
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
rows = db_helper.execute_query(sql, (inference_id,))
|
||||||
|
if not rows:
|
||||||
|
raise ValueError(f"推理结果不存在: id={inference_id}")
|
||||||
|
|
||||||
|
row = rows[0]
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"name": row[1] or "",
|
||||||
|
"event_type": row[2] or "",
|
||||||
|
"occurred_time": row[3],
|
||||||
|
"condition": row[4] if isinstance(row[4], dict) else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
qgis_repository = QgisRepository()
|
||||||
@@ -44,6 +44,21 @@ class EarthquakePredictRequest(BaseModel):
|
|||||||
description="操作类型(如 '模拟', '实时监测', '应急评估')")
|
description="操作类型(如 '模拟', '实时监测', '应急评估')")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 专题图产出
|
||||||
|
# ============================================================
|
||||||
|
class QgisMapExportRequest(BaseModel):
|
||||||
|
"""专题图导出请求"""
|
||||||
|
inferenceId: int = Field(..., description="推理结果ID(xian_inference_result.id)")
|
||||||
|
|
||||||
|
|
||||||
|
class QgisMapExportResponse(BaseModel):
|
||||||
|
"""专题图导出响应"""
|
||||||
|
code: int = Field(200, description="状态码")
|
||||||
|
message: str = Field("success", description="提示信息")
|
||||||
|
data: Optional[str] = Field(None, description="导出图片的访问路径")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 通用响应
|
# 通用响应
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
一次性脚本:从 PostgreSQL 导出静态底图为 GeoPackage 文件。
|
||||||
|
运行一次即可,之后服务直接读本地 GPKG。
|
||||||
|
|
||||||
|
用法: python -m app.script.export_static_layers
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 确保项目根目录在 sys.path 中
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
import geopandas as gpd
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
# GPKG 输出目录(相对于项目根目录)
|
||||||
|
GPKG_DIR = os.path.join(project_root, getattr(settings, "QGIS_GPKG_DIR", "app/data/gpkg"))
|
||||||
|
|
||||||
|
# 静态图层定义: {显示名: (schema.table, geom_column, srid)}
|
||||||
|
STATIC_LAYERS = {
|
||||||
|
"水库": ("qgis.rivers", "Geometry", 0),
|
||||||
|
"市州驻地": ("qgis.sx_capital", "geometry", 0),
|
||||||
|
"河流": ("qgis.river", "Geometry", 0),
|
||||||
|
"active_fault": ("qgis.active_fault", "Geometry", 0),
|
||||||
|
"陕西省": ("qgis.sx", "Geometry", 0),
|
||||||
|
"乡镇驻地": ("qgis.sx_street", "dgeom", 0),
|
||||||
|
"区县驻地": ("qgis.sx_xa_county", "Geometry", 0),
|
||||||
|
"县界": ("qgis.sx_xa_county_boundary", "Geometry", 0),
|
||||||
|
"周边区县": ("qgis.sx_zb_county_boundary", "Geometry", 0),
|
||||||
|
"周边市州": ("qgis.sx_zb_city", "Geometry", 4326),
|
||||||
|
"周边县区": ("qgis.sx_zb_county", "Geometry", 0),
|
||||||
|
"traffic_expressway": ("qgis.traffic_expressway", "Geometry", 0),
|
||||||
|
"traffic_provincial": ("qgis.traffic_provincial", "Geometry", 0),
|
||||||
|
"traffic_railway": ("qgis.traffic_railway", "Geometry", 0),
|
||||||
|
"traffic_township": ("qgis.traffic_township", "Geometry", 0),
|
||||||
|
"traffic_trunk_line": ("qgis.traffic_trunk_line", "Geometry", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def export_layers():
|
||||||
|
os.makedirs(GPKG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
conn_str = (
|
||||||
|
f"postgresql://{settings.DB_USER}:{settings.DB_PASSWORD}"
|
||||||
|
f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"
|
||||||
|
)
|
||||||
|
engine = create_engine(conn_str)
|
||||||
|
|
||||||
|
for layer_name, (table_ref, geom_col, srid) in STATIC_LAYERS.items():
|
||||||
|
t0 = time.time()
|
||||||
|
schema, table = table_ref.split(".", 1)
|
||||||
|
print(f"导出 {layer_name} ({table_ref}) ...", end=" ", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sql = f'SELECT * FROM "{schema}"."{table}"'
|
||||||
|
gdf = gpd.read_postgis(sql, engine, geom_col=geom_col)
|
||||||
|
|
||||||
|
if srid and srid > 0:
|
||||||
|
gdf = gdf.set_crs(epsg=srid, allow_override=True)
|
||||||
|
elif gdf.crs is None:
|
||||||
|
gdf = gdf.set_crs(epsg=4326)
|
||||||
|
|
||||||
|
gpkg_path = os.path.join(GPKG_DIR, f"{table}.gpkg")
|
||||||
|
gdf.to_file(gpkg_path, driver="GPKG")
|
||||||
|
|
||||||
|
print(f"✓ {len(gdf)} 行, {time.time() - t0:.1f}s")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 失败: {e}")
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
print(f"\n导出完成!文件目录: {GPKG_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
export_layers()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from app.services.qgis.map_service import MapService
|
||||||
|
from app.services.qgis.map_exporter import MapExporter
|
||||||
|
from app.services.qgis.template_modifier import TemplateModifier
|
||||||
|
from app.services.qgis.template_cache import TemplateCache
|
||||||
|
from app.services.qgis.layer_filter import LayerFilter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'MapService',
|
||||||
|
'MapExporter',
|
||||||
|
'TemplateModifier',
|
||||||
|
'TemplateCache',
|
||||||
|
'LayerFilter',
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
图层过滤模块。按 event 和 eqqueue_id 筛选要素。
|
||||||
|
"""
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.filter")
|
||||||
|
|
||||||
|
EVENT_LAYERS = ["eqcenter", "震中"]
|
||||||
|
QUEUE_LAYERS = [
|
||||||
|
"intensity", "intensity_mian",
|
||||||
|
"dz_ryss", "dz_jjss", "dz_rysw", "dz_jzph", "dz_xzjl",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LayerFilter:
|
||||||
|
def apply(self, project, model: dict) -> None:
|
||||||
|
"""对项目中的图层应用过滤条件"""
|
||||||
|
event = model.get("event", "")
|
||||||
|
queue_id = model.get("queueId", "")
|
||||||
|
|
||||||
|
logger.info(f"图层过滤: event='{event}', queueId='{queue_id}'")
|
||||||
|
|
||||||
|
for name in EVENT_LAYERS:
|
||||||
|
layers = project.mapLayersByName(name)
|
||||||
|
if layers:
|
||||||
|
layers[0].setSubsetString(f"event = '{event}'")
|
||||||
|
|
||||||
|
for name in QUEUE_LAYERS:
|
||||||
|
layers = project.mapLayersByName(name)
|
||||||
|
if layers:
|
||||||
|
layers[0].setSubsetString(f"eqqueue_id = '{queue_id}'")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
地图导出模块。布局文本更新、比例尺调整、图片导出。
|
||||||
|
"""
|
||||||
|
from qgis.core import QgsLayoutExporter, QgsScaleBarSettings
|
||||||
|
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.exporter")
|
||||||
|
|
||||||
|
|
||||||
|
class MapExporter:
|
||||||
|
def __init__(self, config: dict, layout):
|
||||||
|
self.config = config
|
||||||
|
self.layout = layout
|
||||||
|
|
||||||
|
def update_texts(self, model: dict) -> None:
|
||||||
|
"""更新布局中的文本标签"""
|
||||||
|
for key in ["mapTitle", "mapTime", "mapUnit", "info"]:
|
||||||
|
label = self.layout.itemById(key)
|
||||||
|
if label is not None:
|
||||||
|
label.setText(model[key])
|
||||||
|
|
||||||
|
def update_scale_bar(self) -> None:
|
||||||
|
"""调整比例尺为自适应宽度模式"""
|
||||||
|
scale_bar = self.layout.itemById("ScaleBar")
|
||||||
|
if scale_bar is None:
|
||||||
|
logger.warning("比例尺控件不存在")
|
||||||
|
return
|
||||||
|
|
||||||
|
scale_bar.setSegmentSizeMode(
|
||||||
|
QgsScaleBarSettings.SegmentSizeMode.SegmentSizeFitWidth
|
||||||
|
)
|
||||||
|
scale_bar.setMaximumBarWidth(70)
|
||||||
|
scale_bar.setMinimumBarWidth(40)
|
||||||
|
|
||||||
|
def export(self, path: str) -> None:
|
||||||
|
"""导出布局为图片"""
|
||||||
|
dpi = self.config["qgis"]["exportDpi"]
|
||||||
|
|
||||||
|
settings = QgsLayoutExporter.ImageExportSettings()
|
||||||
|
settings.dpi = dpi
|
||||||
|
|
||||||
|
exporter = QgsLayoutExporter(self.layout)
|
||||||
|
result = exporter.exportToImage(path, settings)
|
||||||
|
|
||||||
|
if result != QgsLayoutExporter.Success:
|
||||||
|
raise RuntimeError(f"图片导出失败: {path}")
|
||||||
|
|
||||||
|
logger.info(f"图片已导出: {path}")
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
地图生成主流程控制器。
|
||||||
|
协调模板加载、图层过滤、缩放、文本更新、导出。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from qgis.core import QgsProject, QgsDataSourceUri
|
||||||
|
|
||||||
|
from .template_cache import TemplateCache
|
||||||
|
from .template_modifier import TemplateModifier
|
||||||
|
from .layer_filter import LayerFilter
|
||||||
|
from .map_exporter import MapExporter
|
||||||
|
from app.utils.map_zoom import MapZoom
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.service")
|
||||||
|
|
||||||
|
# 全局模板缓存(跨请求复用)
|
||||||
|
template_cache = TemplateCache()
|
||||||
|
|
||||||
|
|
||||||
|
class MapService:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def generate(self, model: dict) -> str:
|
||||||
|
"""
|
||||||
|
执行完整的地图生成流程。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: 包含地图参数的字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
地图名称
|
||||||
|
"""
|
||||||
|
t_start = time.time()
|
||||||
|
template_path = model["path"]
|
||||||
|
project = QgsProject.instance()
|
||||||
|
is_cache_hit = template_cache.is_loaded(template_path)
|
||||||
|
|
||||||
|
# 加载/恢复模板
|
||||||
|
if is_cache_hit:
|
||||||
|
logger.info(f"[缓存命中] {os.path.basename(template_path)}")
|
||||||
|
project, texts, extent = template_cache.restore_template(template_path)
|
||||||
|
template_cache.reset_project_state(project, texts, extent)
|
||||||
|
else:
|
||||||
|
logger.info(f"[首次加载] {os.path.basename(template_path)}")
|
||||||
|
modifier = TemplateModifier(self.config)
|
||||||
|
actual_path = modifier.modify(template_path)
|
||||||
|
template_cache.load_template(actual_path, layout_name=model.get("mapLayout", "A4"))
|
||||||
|
if actual_path != template_path:
|
||||||
|
try:
|
||||||
|
os.remove(actual_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 更新 PostgreSQL 图层连接(仅首次加载)
|
||||||
|
if not is_cache_hit:
|
||||||
|
self._update_db_connections(project)
|
||||||
|
|
||||||
|
# 图层过滤
|
||||||
|
LayerFilter().apply(project, model)
|
||||||
|
|
||||||
|
# 地图缩放
|
||||||
|
layout = project.layoutManager().layoutByName(model["mapLayout"])
|
||||||
|
if layout is None:
|
||||||
|
available = [l.name() for l in project.layoutManager().layouts()]
|
||||||
|
raise RuntimeError(
|
||||||
|
f"模板中未找到布局 '{model['mapLayout']}',可用布局:{available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
map_item = layout.itemById("Map")
|
||||||
|
zoom = MapZoom(project, layout, map_item)
|
||||||
|
zoom.execute(model["zoomRule"], {
|
||||||
|
"X": model["centerX"],
|
||||||
|
"Y": model["centerY"],
|
||||||
|
"value": model["zoomValue"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 文本更新 + 比例尺 + 导出
|
||||||
|
exporter = MapExporter(self.config, layout)
|
||||||
|
exporter.update_texts(model)
|
||||||
|
exporter.update_scale_bar()
|
||||||
|
exporter.export(model["outFile"])
|
||||||
|
|
||||||
|
elapsed = time.time() - t_start
|
||||||
|
logger.info(
|
||||||
|
f"{'[缓存命中]' if is_cache_hit else '[首次加载]'} "
|
||||||
|
f"导出完成: {model['name']},耗时 {elapsed:.1f}s"
|
||||||
|
)
|
||||||
|
return model["name"]
|
||||||
|
|
||||||
|
def _update_db_connections(self, project: QgsProject) -> None:
|
||||||
|
"""更新所有 PostgreSQL 图层的数据库连接参数"""
|
||||||
|
db_config = self.config["db"]
|
||||||
|
override = self.config.get("template_override", {})
|
||||||
|
actual_schema = override.get("actual", {}).get("schema", "qgis")
|
||||||
|
static_count = 0
|
||||||
|
|
||||||
|
for layer in project.mapLayers().values():
|
||||||
|
if layer.providerType() == "ogr":
|
||||||
|
static_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if layer.providerType() != "postgres":
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
uri = layer.dataProvider().uri()
|
||||||
|
uri.setConnection(
|
||||||
|
db_config["host"],
|
||||||
|
str(db_config["port"]),
|
||||||
|
db_config["database"],
|
||||||
|
db_config["username"],
|
||||||
|
db_config["password"],
|
||||||
|
)
|
||||||
|
# 更新 schema(table="base"."xxx" → table="qgis"."xxx")
|
||||||
|
old_uri = uri.uri()
|
||||||
|
if f'table="{actual_schema}".' not in old_uri:
|
||||||
|
new_uri = old_uri.replace('table="base".', f'table="{actual_schema}".')
|
||||||
|
if new_uri != old_uri:
|
||||||
|
uri = QgsDataSourceUri(new_uri)
|
||||||
|
|
||||||
|
layer.setDataSource(uri.uri(), layer.name(), "postgres")
|
||||||
|
|
||||||
|
if layer.isValid():
|
||||||
|
logger.info(f"图层 {layer.name()} 连接更新成功")
|
||||||
|
else:
|
||||||
|
logger.error(f"图层 {layer.name()} 更新后仍无效")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新图层 {layer.name()} 连接失败: {e}")
|
||||||
|
|
||||||
|
if static_count:
|
||||||
|
logger.info(f"静态底图已本地化: {static_count} 个 GPKG 图层跳过远程连接")
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
QGIS 环境初始化模块(跨平台:Windows / Linux)。
|
||||||
|
在 server.py lifespan 启动阶段调用 init_qgis_env(),
|
||||||
|
完成共享库注入、环境变量设置、QgsApplication 初始化。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.env")
|
||||||
|
|
||||||
|
_qgs_app = None
|
||||||
|
_initialized = False
|
||||||
|
_IS_WINDOWS = platform.system() == "Windows"
|
||||||
|
|
||||||
|
|
||||||
|
def init_qgis_env(qgis_root: str) -> None:
|
||||||
|
"""
|
||||||
|
初始化 QGIS 运行环境。整个应用生命周期只需调用一次。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qgis_root: QGIS 安装根目录
|
||||||
|
Windows: "D:/QGIS"
|
||||||
|
Linux: "/usr" 或 "/opt/QGIS"
|
||||||
|
"""
|
||||||
|
global _qgs_app, _initialized
|
||||||
|
if _initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
qgis_app_dir = _find_qgis_app_dir(qgis_root)
|
||||||
|
|
||||||
|
# 共享库搜索路径(平台相关)
|
||||||
|
# Windows: os.add_dll_directory() 显式注册 DLL 目录
|
||||||
|
# Linux: LD_LIBRARY_PATH 追加 .so 搜索目录
|
||||||
|
_add_library_paths(qgis_root, qgis_app_dir)
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
_set_environment(qgis_root, qgis_app_dir)
|
||||||
|
|
||||||
|
# Python 模块路径
|
||||||
|
_add_python_paths(qgis_root, qgis_app_dir)
|
||||||
|
|
||||||
|
# 初始化 QgsApplication
|
||||||
|
_init_qgs_application(qgis_app_dir)
|
||||||
|
|
||||||
|
_initialized = True
|
||||||
|
logger.info(f"QGIS 环境初始化完成 ({platform.system()})")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_qgis_env() -> None:
|
||||||
|
"""清理 QGIS 资源(应用退出时调用)"""
|
||||||
|
global _qgs_app, _initialized
|
||||||
|
if _qgs_app is not None:
|
||||||
|
_qgs_app.exitQgis()
|
||||||
|
_qgs_app = None
|
||||||
|
_initialized = False
|
||||||
|
logger.info("QGIS 资源已清理")
|
||||||
|
|
||||||
|
|
||||||
|
def is_qgis_ready() -> bool:
|
||||||
|
"""检查 QGIS 是否已初始化"""
|
||||||
|
return _initialized
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 平台检测
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _find_qgis_app_dir(root: str) -> str:
|
||||||
|
"""
|
||||||
|
自动检测 QGIS 应用目录。
|
||||||
|
|
||||||
|
Windows(OSGeo4W 安装): {root}/apps/qgis-ltr/
|
||||||
|
Linux(包管理器安装): {root}/share/qgis/(Python 在 {root}/share/qgis/python)
|
||||||
|
"""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
return _find_qgis_app_dir_windows(root)
|
||||||
|
else:
|
||||||
|
return _find_qgis_app_dir_linux(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_qgis_app_dir_windows(root: str) -> str:
|
||||||
|
"""Windows: 在 {root}/apps/ 下查找 qgis* 目录"""
|
||||||
|
apps_dir = Path(root) / "apps"
|
||||||
|
if apps_dir.is_dir():
|
||||||
|
for name in sorted(apps_dir.iterdir()):
|
||||||
|
if name.name.startswith("qgis") and name.is_dir():
|
||||||
|
logger.info(f"检测到 QGIS 应用目录: {name}")
|
||||||
|
return str(name)
|
||||||
|
fallback = str(apps_dir / "qgis")
|
||||||
|
logger.warning(f"未检测到 qgis* 目录,使用默认路径: {fallback}")
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _find_qgis_app_dir_linux(root: str) -> str:
|
||||||
|
"""
|
||||||
|
Linux: 返回 QGIS_PREFIX_PATH。
|
||||||
|
包管理器安装的标准路径:
|
||||||
|
Debian/Ubuntu: /usr (qgis-core 在 /usr/lib/qgis/)
|
||||||
|
RHEL/CentOS: /usr
|
||||||
|
QGIS.org 官方: /usr 或 /opt/QGIS
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
Path(root) / "share" / "qgis", # /usr/share/qgis
|
||||||
|
Path(root) / "lib" / "qgis", # /usr/lib/qgis
|
||||||
|
Path(root) / "share" / "qgis-ltr", # qgis-ltr 变体
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_dir():
|
||||||
|
logger.info(f"检测到 QGIS 应用目录: {c}")
|
||||||
|
return str(c)
|
||||||
|
|
||||||
|
# 回退:用 root 本身(/usr),由 QgsApplication 自行探测
|
||||||
|
logger.warning(f"未找到 QGIS 标准目录,使用 root: {root}")
|
||||||
|
return str(root)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 共享库注入
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add_library_paths(root: str, qgis_app: str) -> None:
|
||||||
|
"""
|
||||||
|
注入共享库搜索路径。
|
||||||
|
|
||||||
|
Windows: 调用 os.add_dll_directory() 显式注册每个 DLL 目录,
|
||||||
|
Python 3.8+ 不再自动搜索 PATH 中的 DLL。
|
||||||
|
Linux: 追加 LD_LIBRARY_PATH,让动态链接器 (ld-linux) 找到 .so。
|
||||||
|
"""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
_add_dll_directories_windows(root, qgis_app)
|
||||||
|
else:
|
||||||
|
_add_ld_library_path_linux(root, qgis_app)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_dll_directories_windows(root: str, qgis_app: str) -> None:
|
||||||
|
"""Windows: 显式注册 DLL 搜索目录"""
|
||||||
|
if not hasattr(os, "add_dll_directory"):
|
||||||
|
return # Python < 3.8
|
||||||
|
|
||||||
|
dll_dirs = [
|
||||||
|
os.path.join(root, "apps", "Qt5", "bin"),
|
||||||
|
os.path.join(qgis_app, "bin"),
|
||||||
|
os.path.join(root, "bin"),
|
||||||
|
os.path.join(root, "apps", "gdal", "bin"),
|
||||||
|
os.path.join(root, "apps", "gdal", "lib"),
|
||||||
|
]
|
||||||
|
for d in dll_dirs:
|
||||||
|
if os.path.isdir(d):
|
||||||
|
os.add_dll_directory(d)
|
||||||
|
logger.debug(f"注册 DLL 目录: {d}")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_ld_library_path_linux(root: str, qgis_app: str) -> None:
|
||||||
|
"""
|
||||||
|
Linux: 追加 LD_LIBRARY_PATH,使动态链接器找到 QGIS/GDAL 的 .so。
|
||||||
|
|
||||||
|
QGIS 包管理器安装时的标准 .so 路径:
|
||||||
|
/usr/lib/ — libqgis_core.so, libqgis_analysis.so
|
||||||
|
/usr/lib/qgis/ — 插件 .so
|
||||||
|
/usr/lib/qgis/plugins/ — provider .so
|
||||||
|
/usr/share/qgis/python/ — Python 模块
|
||||||
|
"""
|
||||||
|
ld_dirs = [
|
||||||
|
os.path.join(root, "lib"), # /usr/lib
|
||||||
|
os.path.join(root, "lib", "qgis"), # /usr/lib/qgis
|
||||||
|
os.path.join(root, "lib", "qgis", "plugins"), # /usr/lib/qgis/plugins
|
||||||
|
os.path.join(root, "share", "qgis", "lib"), # 某些安装方式
|
||||||
|
os.path.join(root, "apps", "gdal", "lib"), # OSGeo4W 风格
|
||||||
|
]
|
||||||
|
existing = os.environ.get("LD_LIBRARY_PATH", "")
|
||||||
|
new_dirs = [d for d in ld_dirs if os.path.isdir(d) and d not in existing]
|
||||||
|
if new_dirs:
|
||||||
|
joined = ":".join(new_dirs)
|
||||||
|
os.environ["LD_LIBRARY_PATH"] = f"{joined}:{existing}" if existing else joined
|
||||||
|
logger.info(f"LD_LIBRARY_PATH 追加: {new_dirs}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 环境变量
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_environment(root: str, qgis_app: str) -> None:
|
||||||
|
"""设置 QGIS 和 GDAL 相关环境变量(平台自适应)"""
|
||||||
|
env_vars = {
|
||||||
|
"QGIS_PREFIX_PATH": qgis_app,
|
||||||
|
"PYTHONUTF8": "1",
|
||||||
|
"GDAL_FILENAME_IS_UTF8": "YES",
|
||||||
|
"VSI_CACHE": "TRUE",
|
||||||
|
"VSI_CACHE_SIZE": "1000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
env_vars["QT_PLUGIN_PATH"] = (
|
||||||
|
f"{os.path.join(qgis_app, 'qtplugins')};"
|
||||||
|
f"{os.path.join(root, 'apps', 'Qt5', 'plugins')}"
|
||||||
|
)
|
||||||
|
env_vars["GDAL_DATA"] = os.path.join(root, "apps", "gdal", "share", "gdal")
|
||||||
|
else:
|
||||||
|
# Linux: GDAL 数据通常在系统路径 /usr/share/gdal/ 下
|
||||||
|
gdal_candidates = [
|
||||||
|
os.path.join(root, "share", "gdal"),
|
||||||
|
os.path.join(root, "share", "qgis", "resources"),
|
||||||
|
]
|
||||||
|
for p in gdal_candidates:
|
||||||
|
if os.path.isdir(p):
|
||||||
|
env_vars["GDAL_DATA"] = p
|
||||||
|
break
|
||||||
|
|
||||||
|
# QGIS 插件路径(Linux 标准)
|
||||||
|
qt_plugin = os.path.join(root, "lib", "qt", "plugins")
|
||||||
|
if os.path.isdir(qt_plugin):
|
||||||
|
env_vars["QT_PLUGIN_PATH"] = qt_plugin
|
||||||
|
|
||||||
|
for key, value in env_vars.items():
|
||||||
|
os.environ[key] = value
|
||||||
|
logger.debug(f"环境变量: {key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Python 模块路径
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add_python_paths(root: str, qgis_app: str) -> None:
|
||||||
|
"""将 QGIS Python 模块路径加入 sys.path(平台自适应)"""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
python_paths = [
|
||||||
|
os.path.join(qgis_app, "python"),
|
||||||
|
os.path.join(root, "apps", "Python312", "Lib", "site-packages"),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
python_paths = [
|
||||||
|
os.path.join(qgis_app, "python"), # /usr/share/qgis/python
|
||||||
|
os.path.join(root, "lib", "python3", "dist-packages"), # Debian/Ubuntu
|
||||||
|
os.path.join(root, "lib", "python3.10", "site-packages"), # 通用
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in python_paths:
|
||||||
|
if os.path.isdir(p) and p not in sys.path:
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
logger.info(f"添加 Python 路径: {p}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# QgsApplication 初始化
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _init_qgs_application(qgis_app: str) -> None:
|
||||||
|
"""创建并初始化 QgsApplication 实例"""
|
||||||
|
global _qgs_app
|
||||||
|
|
||||||
|
from qgis.core import QgsApplication, QgsSettings
|
||||||
|
|
||||||
|
QgsApplication.setPrefixPath(qgis_app, True)
|
||||||
|
_qgs_app = QgsApplication([], False) # False = 不启动 GUI
|
||||||
|
|
||||||
|
settings = QgsSettings()
|
||||||
|
settings.setValue("/qgis/render_decorations", False)
|
||||||
|
settings.setValue("/qgis/parallel_rendering", True)
|
||||||
|
settings.setValue("/qgis/use_spatial_index", True)
|
||||||
|
|
||||||
|
_qgs_app.initQgis()
|
||||||
|
logger.info("QgsApplication 初始化完成")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
模板缓存引擎。解决 QgsProject 单例问题。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 首次请求:project.read() 加载模板(慢,仅一次)
|
||||||
|
2. 加载后 project.write() 保存到临时文件
|
||||||
|
3. 后续同模板请求:从临时文件恢复(快,连接复用)
|
||||||
|
4. 手动恢复文本/过滤/缩放(毫秒级)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from qgis.core import QgsProject, QgsRectangle
|
||||||
|
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.cache")
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCache:
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def is_loaded(self, template_path: str) -> bool:
|
||||||
|
return template_path in self._cache
|
||||||
|
|
||||||
|
def load_template(self, template_path: str, layout_name: str = "A4") -> None:
|
||||||
|
"""首次加载模板"""
|
||||||
|
start = time.time()
|
||||||
|
project = QgsProject.instance()
|
||||||
|
|
||||||
|
logger.info(f"首次加载: {os.path.basename(template_path)}")
|
||||||
|
project.read(template_path)
|
||||||
|
logger.info(f"project.read() 耗时: {time.time() - start:.1f}s")
|
||||||
|
|
||||||
|
# 保存到临时文件
|
||||||
|
tmp_file = tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".qgz", delete=False,
|
||||||
|
dir=os.path.dirname(template_path),
|
||||||
|
)
|
||||||
|
tmp_path = tmp_file.name
|
||||||
|
tmp_file.close()
|
||||||
|
|
||||||
|
t_save = time.time()
|
||||||
|
project.write(tmp_path)
|
||||||
|
logger.info(f"项目保存耗时: {time.time() - t_save:.1f}s")
|
||||||
|
|
||||||
|
# 记录初始状态
|
||||||
|
texts = {}
|
||||||
|
extent = None
|
||||||
|
layout = project.layoutManager().layoutByName(layout_name)
|
||||||
|
if layout:
|
||||||
|
for item_id in ["mapTitle", "mapTime", "mapUnit", "info"]:
|
||||||
|
item = layout.itemById(item_id)
|
||||||
|
if item:
|
||||||
|
texts[item_id] = item.text()
|
||||||
|
map_item = layout.itemById("Map")
|
||||||
|
if map_item:
|
||||||
|
extent = QgsRectangle(map_item.extent())
|
||||||
|
|
||||||
|
self._cache[template_path] = {
|
||||||
|
"file": tmp_path,
|
||||||
|
"texts": texts,
|
||||||
|
"extent": extent,
|
||||||
|
"layout": layout_name,
|
||||||
|
}
|
||||||
|
logger.info(f"模板加载完成,总耗时: {time.time() - start:.1f}s")
|
||||||
|
|
||||||
|
def restore_template(self, template_path: str) -> tuple:
|
||||||
|
"""从缓存恢复模板"""
|
||||||
|
cached = self._cache.get(template_path)
|
||||||
|
if not cached:
|
||||||
|
raise RuntimeError(f"模板未缓存: {template_path}")
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
project = QgsProject.instance()
|
||||||
|
|
||||||
|
logger.info(f"恢复模板: {os.path.basename(template_path)}")
|
||||||
|
project.read(cached["file"])
|
||||||
|
logger.info(f"project.read() 耗时: {time.time() - start:.1f}s")
|
||||||
|
|
||||||
|
return project, cached["texts"], cached["extent"]
|
||||||
|
|
||||||
|
def reset_project_state(self, project: QgsProject, texts: dict, extent) -> None:
|
||||||
|
"""重置项目到干净状态"""
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
for layer in project.mapLayers().values():
|
||||||
|
if layer.subsetString():
|
||||||
|
layer.setSubsetString("")
|
||||||
|
|
||||||
|
# 获取 layout 名称(从缓存中)
|
||||||
|
layout_name = "A4"
|
||||||
|
for cached in self._cache.values():
|
||||||
|
layout_name = cached.get("layout", "A4")
|
||||||
|
break
|
||||||
|
|
||||||
|
layout = project.layoutManager().layoutByName(layout_name)
|
||||||
|
if layout:
|
||||||
|
for item_id, text in texts.items():
|
||||||
|
item = layout.itemById(item_id)
|
||||||
|
if item:
|
||||||
|
item.setText(text)
|
||||||
|
if extent:
|
||||||
|
map_item = layout.itemById("Map")
|
||||||
|
if map_item:
|
||||||
|
map_item.zoomToExtent(extent)
|
||||||
|
|
||||||
|
logger.info(f"状态重置耗时: {time.time() - start:.3f}s")
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""清理所有临时文件"""
|
||||||
|
for cached in self._cache.values():
|
||||||
|
try:
|
||||||
|
os.remove(cached["file"])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._cache.clear()
|
||||||
|
logger.info("已清理所有缓存")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
模板 XML 修改模块。在 project.read() 之前:
|
||||||
|
1. 替换 PostgreSQL 连接参数(host/port/dbname/schema)
|
||||||
|
2. 将静态底图的 datasource 替换为本地 GeoPackage 路径
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.modifier")
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateModifier:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.config = config
|
||||||
|
self._static_map = self._build_static_map()
|
||||||
|
|
||||||
|
def _build_static_map(self) -> dict[str, str]:
|
||||||
|
"""构建 table_key → gpkg_abs_path 的映射"""
|
||||||
|
sl = self.config.get("static_layers")
|
||||||
|
if not sl or not sl.get("enabled", False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
gpkg_dir = sl["gpkg_dir"]
|
||||||
|
mapping = {}
|
||||||
|
for info in sl["layers"].values():
|
||||||
|
schema, table = info["table"].split(".", 1)
|
||||||
|
xml_key = f'table="{schema}"."{table}"'
|
||||||
|
gpkg_path = os.path.join(gpkg_dir, info["file"]).replace("\\", "/")
|
||||||
|
mapping[xml_key] = gpkg_path
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
def modify(self, template_path: str) -> str:
|
||||||
|
"""修改模板文件,返回修改后的临时 .qgz 路径"""
|
||||||
|
override = self.config.get("template_override")
|
||||||
|
has_override = override and override.get("enabled", False)
|
||||||
|
has_static = bool(self._static_map)
|
||||||
|
|
||||||
|
if not has_override and not has_static:
|
||||||
|
return template_path
|
||||||
|
|
||||||
|
orig = override["original"] if has_override else None
|
||||||
|
actual = override["actual"] if has_override else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".qgz", delete=False,
|
||||||
|
dir=os.path.dirname(template_path),
|
||||||
|
)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
tmp.close()
|
||||||
|
|
||||||
|
datasource_re = re.compile(r"(<datasource>)(.*?)(</datasource>)", re.DOTALL)
|
||||||
|
table_re = re.compile(r'table="(\w+)"\."(\w+)"')
|
||||||
|
|
||||||
|
with zipfile.ZipFile(template_path, "r") as zin, \
|
||||||
|
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
|
||||||
|
for item in zin.infolist():
|
||||||
|
data = zin.read(item.filename)
|
||||||
|
|
||||||
|
if item.filename.endswith(".qgs"):
|
||||||
|
content = data.decode("utf-8")
|
||||||
|
|
||||||
|
# 替换 host/port/dbname/schema
|
||||||
|
if orig and actual:
|
||||||
|
content = re.sub(
|
||||||
|
rf"(host\s*=\s*['\"])({re.escape(orig['host'])})(['\"])",
|
||||||
|
rf"\g<1>{actual['host']}\3",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = re.sub(
|
||||||
|
rf"(port\s*=\s*['\"])({re.escape(str(orig['port']))})(['\"])",
|
||||||
|
rf"\g<1>{actual['port']}\3",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = re.sub(
|
||||||
|
rf"(dbname\s*=\s*['\"])({re.escape(orig['dbname'])})(['\"])",
|
||||||
|
rf"\g<1>{actual['dbname']}\3",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
f'table="{orig["schema"]}".',
|
||||||
|
f'table="{actual["schema"]}".',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 替换静态底图 datasource
|
||||||
|
if self._static_map:
|
||||||
|
def _replace(m):
|
||||||
|
tbl = table_re.search(m.group(2))
|
||||||
|
if tbl:
|
||||||
|
key = f'table="{tbl.group(1)}"."{tbl.group(2)}"'
|
||||||
|
if key in self._static_map:
|
||||||
|
return f"{m.group(1)}{self._static_map[key]}{m.group(3)}"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
content = datasource_re.sub(_replace, content)
|
||||||
|
|
||||||
|
data = content.encode("utf-8")
|
||||||
|
|
||||||
|
zout.writestr(item, data)
|
||||||
|
|
||||||
|
logger.info(f"模板已修改并写入: {tmp_path}")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"模板修改失败,将使用原始模板: {e}")
|
||||||
|
return template_path
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
地图缩放策略模块。支持 5 种缩放模式。
|
||||||
|
"""
|
||||||
|
from qgis.core import (
|
||||||
|
QgsPointXY, QgsGeometry, QgsRectangle,
|
||||||
|
QgsCoordinateTransform, QgsCoordinateReferenceSystem,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config.paths import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("qgis.zoom")
|
||||||
|
|
||||||
|
ZOOM_RULES = {
|
||||||
|
"11": "pan_to_center",
|
||||||
|
"12": "by_layer",
|
||||||
|
"13": "layer_intersect",
|
||||||
|
"14": "center_distance",
|
||||||
|
"15": "layer_merged",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MapZoom:
|
||||||
|
def __init__(self, project, layout, map_item):
|
||||||
|
self.project = project
|
||||||
|
self.layout = layout
|
||||||
|
self.map_item = map_item
|
||||||
|
|
||||||
|
def execute(self, rule: str, data: dict) -> None:
|
||||||
|
"""执行缩放操作"""
|
||||||
|
method_name = ZOOM_RULES.get(rule, "pan_to_center")
|
||||||
|
method = getattr(self, method_name)
|
||||||
|
logger.info(f"缩放: {method_name}, 参数: {data}")
|
||||||
|
method(data)
|
||||||
|
|
||||||
|
def _make_transform(self, source_crs=None) -> QgsCoordinateTransform:
|
||||||
|
qct = QgsCoordinateTransform()
|
||||||
|
qct.setDestinationCrs(self.map_item.crs())
|
||||||
|
qct.setSourceCrs(
|
||||||
|
source_crs if source_crs
|
||||||
|
else QgsCoordinateReferenceSystem("EPSG:4326")
|
||||||
|
)
|
||||||
|
return qct
|
||||||
|
|
||||||
|
def pan_to_center(self, data: dict) -> None:
|
||||||
|
qct = self._make_transform()
|
||||||
|
point = QgsPointXY(float(data["X"]), float(data["Y"]))
|
||||||
|
geom = QgsGeometry.fromWkt(point.asWkt())
|
||||||
|
geom.transform(qct, QgsCoordinateTransform.ForwardTransform)
|
||||||
|
center = geom.asPoint()
|
||||||
|
qr = QgsRectangle.fromCenterAndSize(
|
||||||
|
center, self.map_item.extent().width(), self.map_item.extent().height()
|
||||||
|
)
|
||||||
|
self.map_item.zoomToExtent(qr)
|
||||||
|
|
||||||
|
def center_distance(self, data: dict) -> None:
|
||||||
|
qct = self._make_transform()
|
||||||
|
point = QgsPointXY(float(data["X"]), float(data["Y"]))
|
||||||
|
geom = QgsGeometry.fromWkt(point.asWkt())
|
||||||
|
geom.transform(qct, QgsCoordinateTransform.ForwardTransform)
|
||||||
|
box = geom.buffer(float(data["value"]) / 1000, 100).boundingBox()
|
||||||
|
self.map_item.zoomToExtent(box.buffered(box.width() * 0.1))
|
||||||
|
|
||||||
|
def by_layer(self, data: dict) -> None:
|
||||||
|
layers = self.project.mapLayersByName(data["value"])
|
||||||
|
if not layers:
|
||||||
|
logger.warning(f"图层不存在: {data['value']}")
|
||||||
|
return
|
||||||
|
layer = layers[0]
|
||||||
|
if layer.featureCount() == 0:
|
||||||
|
self.pan_to_center(data)
|
||||||
|
return
|
||||||
|
qct = self._make_transform(layer.crs())
|
||||||
|
geom = QgsGeometry.fromWkt(layer.extent().asWktPolygon())
|
||||||
|
geom.transform(qct, QgsCoordinateTransform.ForwardTransform)
|
||||||
|
box = geom.boundingBox()
|
||||||
|
self.map_item.zoomToExtent(box.buffered(box.width() * 0.1))
|
||||||
|
|
||||||
|
def layer_merged(self, data: dict) -> None:
|
||||||
|
names = data["value"].split(",")
|
||||||
|
combined = None
|
||||||
|
for name in names:
|
||||||
|
layers = self.project.mapLayersByName(name.strip())
|
||||||
|
if not layers:
|
||||||
|
continue
|
||||||
|
layer = layers[0]
|
||||||
|
qct = self._make_transform(layer.crs())
|
||||||
|
geom = QgsGeometry.fromWkt(layer.extent().asWktPolygon())
|
||||||
|
geom.transform(qct, QgsCoordinateTransform.ForwardTransform)
|
||||||
|
box = geom.boundingBox()
|
||||||
|
if combined is None:
|
||||||
|
combined = box
|
||||||
|
else:
|
||||||
|
combined.combineExtentWith(box)
|
||||||
|
if combined:
|
||||||
|
self.map_item.zoomToExtent(combined.buffered(combined.width() * 0.1))
|
||||||
|
|
||||||
|
def layer_intersect(self, data: dict) -> None:
|
||||||
|
names = data["value"].split(",")
|
||||||
|
intersection = None
|
||||||
|
for name in names:
|
||||||
|
layers = self.project.mapLayersByName(name.strip())
|
||||||
|
if not layers:
|
||||||
|
continue
|
||||||
|
layer = layers[0]
|
||||||
|
qct = self._make_transform(layer.crs())
|
||||||
|
layer.selectAll()
|
||||||
|
geom = None
|
||||||
|
for fid in layer.selectedFeatureIds():
|
||||||
|
if geom is None:
|
||||||
|
geom = layer.getGeometry(fid)
|
||||||
|
else:
|
||||||
|
geom = geom.combine(layer.getGeometry(fid))
|
||||||
|
if geom:
|
||||||
|
qgm = QgsGeometry.fromWkt(geom.asWkt())
|
||||||
|
qgm.transform(qct, QgsCoordinateTransform.ForwardTransform)
|
||||||
|
box = qgm.boundingBox()
|
||||||
|
if intersection is None:
|
||||||
|
intersection = box
|
||||||
|
else:
|
||||||
|
intersection = intersection.intersection(box)
|
||||||
|
if intersection:
|
||||||
|
bbox = intersection.boundingBox()
|
||||||
|
self.map_item.zoomToExtent(bbox.buffered(bbox.width() * 0.1))
|
||||||
@@ -9,3 +9,4 @@ pyyaml == 6.0.3
|
|||||||
fastapi == 0.136.3
|
fastapi == 0.136.3
|
||||||
uvicorn[standard] == 0.48.0
|
uvicorn[standard] == 0.48.0
|
||||||
loguru == 0.7.3
|
loguru == 0.7.3
|
||||||
|
geopandas == 0.14.4
|
||||||
+97
-14
@@ -1,57 +1,140 @@
|
|||||||
# 公共配置
|
# ============================================================
|
||||||
|
# 公共配置(所有环境共享)
|
||||||
|
# ============================================================
|
||||||
[default]
|
[default]
|
||||||
APP_NAME = "西安项目算法服务"
|
APP_NAME = "西安项目算法服务"
|
||||||
LOG_DIR = "logs"
|
LOG_DIR = "logs"
|
||||||
# 雨量站栅格存储位置,:id会被替换成数据id
|
|
||||||
RAIN_STATION_GRID_DIR = "/xian/rainfall/grid/images/:id"
|
RAIN_STATION_GRID_DIR = "/xian/rainfall/grid/images/:id"
|
||||||
# 雨量站栅格存储redis的key
|
|
||||||
REDIS_RAIN_STATION_GRID_KEY = "xian:rainfall:rain_station_grid"
|
REDIS_RAIN_STATION_GRID_KEY = "xian:rainfall:rain_station_grid"
|
||||||
# 雨量站存储标识符的redis的key
|
|
||||||
REDIS_RAIN_STATION_IDENTIFIER_KEY = "xian:rainfall:rain_station_identifier"
|
REDIS_RAIN_STATION_IDENTIFIER_KEY = "xian:rainfall:rain_station_identifier"
|
||||||
# 预测结果概率阈值(低于此值不返回给前端)
|
|
||||||
PREDICT_PROBABILITY_THRESHOLD = 50
|
PREDICT_PROBABILITY_THRESHOLD = 50
|
||||||
|
# 静态底图 GeoPackage 目录(相对于项目根目录)
|
||||||
|
QGIS_GPKG_DIR = "app/data/gpkg"
|
||||||
|
# 专题图输出子目录(相对于 FILE_STORE_DIR)
|
||||||
|
QGIS_OUTPUT_DIR = "xian/qgis/map/:disasterTime"
|
||||||
|
# 专题图默认参数
|
||||||
|
QGIS_DEFAULTS_MAP_LAYOUT = "A3"
|
||||||
|
QGIS_DEFAULTS_ZOOM_RULE = "11"
|
||||||
|
QGIS_DEFAULTS_ZOOM_VALUE = "50"
|
||||||
|
QGIS_DEFAULTS_MAP_UNIT = "西安市应急管理局"
|
||||||
|
# 专题图DPI
|
||||||
|
QGIS_EXPORT_DPI = 300
|
||||||
|
# 批量产图线程池
|
||||||
|
QGIS_WORKER_THREADS = 4
|
||||||
|
|
||||||
|
# 西安市中心经纬度
|
||||||
|
XIAN_CENTER = [108.948024, 34.263161]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 行政区划代码映射
|
||||||
|
# ============================================================
|
||||||
|
[default.area]
|
||||||
|
"610102" = "新城区"
|
||||||
|
"610103" = "碑林区"
|
||||||
|
"610104" = "莲湖区"
|
||||||
|
"610111" = "灞桥区"
|
||||||
|
"610112" = "未央区"
|
||||||
|
"610113" = "雁塔区"
|
||||||
|
"610114" = "阎良区"
|
||||||
|
"610115" = "临潼区"
|
||||||
|
"610116" = "长安区"
|
||||||
|
"610117" = "高陵区"
|
||||||
|
"610118" = "鄠邑区"
|
||||||
|
"610122" = "蓝田县"
|
||||||
|
"610124" = "周至县"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# 开发环境
|
# 开发环境
|
||||||
|
# ============================================================
|
||||||
[development]
|
[development]
|
||||||
DEBUG = true
|
DEBUG = true
|
||||||
# 数据库
|
# ============================================================
|
||||||
|
# 数据库配置
|
||||||
|
# ============================================================
|
||||||
DB_HOST = "47.92.216.173"
|
DB_HOST = "47.92.216.173"
|
||||||
DB_PORT = 7654
|
DB_PORT = 7654
|
||||||
DB_USER = "postgres"
|
DB_USER = "postgres"
|
||||||
DB_PASSWORD = "zhangsan"
|
DB_PASSWORD = "zhangsan"
|
||||||
DB_NAME = "xian_new"
|
DB_NAME = "xian_new"
|
||||||
# FastAPI配置
|
# ============================================================
|
||||||
|
# FastAPI 配置
|
||||||
|
# ============================================================
|
||||||
API_HOST = "127.0.0.1"
|
API_HOST = "127.0.0.1"
|
||||||
API_PORT = 8082
|
API_PORT = 8082
|
||||||
|
# ============================================================
|
||||||
# 日志配置
|
# 日志配置
|
||||||
|
# ============================================================
|
||||||
LOG_LEVEL = "DEBUG"
|
LOG_LEVEL = "DEBUG"
|
||||||
# redis
|
# ============================================================
|
||||||
|
# Redis 配置
|
||||||
|
# ============================================================
|
||||||
REDIS_HOST = "47.92.216.173"
|
REDIS_HOST = "47.92.216.173"
|
||||||
REDIS_PORT = 7655
|
REDIS_PORT = 7655
|
||||||
REDIS_PASSWORD = "zhangsan"
|
REDIS_PASSWORD = "zhangsan"
|
||||||
REDIS_DB = 0
|
REDIS_DB = 0
|
||||||
# 文件存储
|
# ============================================================
|
||||||
|
# 文件路径配置
|
||||||
|
# ============================================================
|
||||||
FILE_STORE_DIR = "G:/files"
|
FILE_STORE_DIR = "G:/files"
|
||||||
|
# ============================================================
|
||||||
|
# QGIS 配置
|
||||||
|
# ============================================================
|
||||||
|
QGIS_ROOT = "D:/QGIS"
|
||||||
|
# 模板数据库覆盖:将模板中硬编码的连接替换为实际环境连接
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ENABLED = true
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_HOST = "localhost"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_PORT = 5432
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_DB_NAME = "yjzyk_xian"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_SCHEMA = "base"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_HOST = "47.92.216.173"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_PORT = 7654
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_DB_NAME = "xian_new"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_SCHEMA = "qgis"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# 生产环境
|
# 生产环境
|
||||||
|
# ============================================================
|
||||||
[production]
|
[production]
|
||||||
DEBUG = false
|
DEBUG = false
|
||||||
|
# ============================================================
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
|
# ============================================================
|
||||||
DB_HOST = "10.22.245.138"
|
DB_HOST = "10.22.245.138"
|
||||||
DB_PORT = 54321
|
DB_PORT = 54321
|
||||||
DB_USER = "zaihailian"
|
DB_USER = "zaihailian"
|
||||||
DB_PASSWORD = "XAYJ@gis2603"
|
DB_PASSWORD = "XAYJ@gis2603"
|
||||||
DB_NAME = "xianDC"
|
DB_NAME = "xianDC"
|
||||||
# FastAPI配置
|
# ============================================================
|
||||||
|
# FastAPI 配置
|
||||||
|
# ============================================================
|
||||||
API_HOST = "127.0.0.1"
|
API_HOST = "127.0.0.1"
|
||||||
API_PORT = 8081
|
API_PORT = 8081
|
||||||
|
# ============================================================
|
||||||
# 日志配置
|
# 日志配置
|
||||||
|
# ============================================================
|
||||||
LOG_LEVEL = "WARNING"
|
LOG_LEVEL = "WARNING"
|
||||||
# redis
|
# ============================================================
|
||||||
|
# Redis 配置
|
||||||
|
# ============================================================
|
||||||
REDIS_HOST = "localhost"
|
REDIS_HOST = "localhost"
|
||||||
REDIS_PORT = 6379
|
REDIS_PORT = 6379
|
||||||
REDIS_PASSWORD = "XAYJ@gis2603"
|
REDIS_PASSWORD = "XAYJ@gis2603"
|
||||||
REDIS_DB = 0
|
REDIS_DB = 0
|
||||||
# 文件存储
|
# ============================================================
|
||||||
FILE_STORE_DIR = "D:/files"
|
# 文件路径配置
|
||||||
|
# ============================================================
|
||||||
|
FILE_STORE_DIR = "/data"
|
||||||
|
# ============================================================
|
||||||
|
# QGIS 配置
|
||||||
|
# ============================================================
|
||||||
|
QGIS_ROOT = "/home/QGIS"
|
||||||
|
# 模板数据库覆盖:将模板中硬编码的连接替换为实际环境连接
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ENABLED = true
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_HOST = "localhost"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_PORT = 5432
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_DB_NAME = "yjzyk_xian"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ORIGINAL_SCHEMA = "base"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_HOST = "10.22.245.138"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_PORT = 54321
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_DB_NAME = "xian_new"
|
||||||
|
QGIS_TEMPLATE_OVERRIDE_ACTUAL_SCHEMA = "qgis"
|
||||||
|
|||||||
Reference in New Issue
Block a user