初始化集成qgis

This commit is contained in:
wzy-warehouse
2026-06-19 17:04:03 +08:00
parent 6a03f66b7d
commit b4cce93af0
17 changed files with 1485 additions and 18 deletions
+2
View File
@@ -8,6 +8,8 @@ def register_routers(application: FastAPI):
"""注册所有路由"""
from app.api.rainfall import router as rainfall_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(earthquake_router)
application.include_router(qgis_router)
+385
View File
@@ -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
View File
@@ -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.schemas.api_schemas import HealthResponse
from app.config.paths import get_logger
from config import settings
logger = get_logger("api")
@@ -20,9 +21,29 @@ async def lifespan(app: FastAPI):
get_rainfall_model()
get_earthquake_model()
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:
"""创建 FastAPI 应用实例"""
+2 -1
View File
@@ -2,5 +2,6 @@
Repositories package - 数据访问层
"""
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']
+31
View File
@@ -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()
+15
View File
@@ -44,6 +44,21 @@ class EarthquakePredictRequest(BaseModel):
description="操作类型(如 '模拟', '实时监测', '应急评估'")
# ============================================================
# 专题图产出
# ============================================================
class QgisMapExportRequest(BaseModel):
"""专题图导出请求"""
inferenceId: int = Field(..., description="推理结果IDxian_inference_result.id")
class QgisMapExportResponse(BaseModel):
"""专题图导出响应"""
code: int = Field(200, description="状态码")
message: str = Field("success", description="提示信息")
data: Optional[str] = Field(None, description="导出图片的访问路径")
# ============================================================
# 通用响应
# ============================================================
+80
View File
@@ -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()
+13
View File
@@ -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',
]
+31
View File
@@ -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}'")
+49
View File
@@ -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}")
+135
View File
@@ -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"],
)
# 更新 schematable="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 图层跳过远程连接")
+266
View File
@@ -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 应用目录。
WindowsOSGeo4W 安装): {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 初始化完成")
+120
View File
@@ -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("已清理所有缓存")
+111
View File
@@ -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
+123
View File
@@ -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))
+1
View File
@@ -9,3 +9,4 @@ pyyaml == 6.0.3
fastapi == 0.136.3
uvicorn[standard] == 0.48.0
loguru == 0.7.3
geopandas == 0.14.4
+97 -14
View File
@@ -1,57 +1,140 @@
# 公共配置
# ============================================================
# 公共配置(所有环境共享)
# ============================================================
[default]
APP_NAME = "西安项目算法服务"
LOG_DIR = "logs"
# 雨量站栅格存储位置,:id会被替换成数据id
RAIN_STATION_GRID_DIR = "/xian/rainfall/grid/images/:id"
# 雨量站栅格存储redis的key
REDIS_RAIN_STATION_GRID_KEY = "xian:rainfall:rain_station_grid"
# 雨量站存储标识符的redis的key
REDIS_RAIN_STATION_IDENTIFIER_KEY = "xian:rainfall:rain_station_identifier"
# 预测结果概率阈值(低于此值不返回给前端)
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]
DEBUG = true
# 数据库
# ============================================================
# 数据库配置
# ============================================================
DB_HOST = "47.92.216.173"
DB_PORT = 7654
DB_USER = "postgres"
DB_PASSWORD = "zhangsan"
DB_NAME = "xian_new"
# FastAPI配置
# ============================================================
# FastAPI 配置
# ============================================================
API_HOST = "127.0.0.1"
API_PORT = 8082
# ============================================================
# 日志配置
# ============================================================
LOG_LEVEL = "DEBUG"
# redis
# ============================================================
# Redis 配置
# ============================================================
REDIS_HOST = "47.92.216.173"
REDIS_PORT = 7655
REDIS_PASSWORD = "zhangsan"
REDIS_DB = 0
# 文件存储
# ============================================================
# 文件路径配置
# ============================================================
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]
DEBUG = false
# ============================================================
# 数据库配置
# ============================================================
DB_HOST = "10.22.245.138"
DB_PORT = 54321
DB_USER = "zaihailian"
DB_PASSWORD = "XAYJ@gis2603"
DB_NAME = "xianDC"
# FastAPI配置
# ============================================================
# FastAPI 配置
# ============================================================
API_HOST = "127.0.0.1"
API_PORT = 8081
# ============================================================
# 日志配置
# ============================================================
LOG_LEVEL = "WARNING"
# redis
# ============================================================
# Redis 配置
# ============================================================
REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_PASSWORD = "XAYJ@gis2603"
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"