QGIS完成初步重构
This commit is contained in:
@@ -1,13 +1,31 @@
|
||||
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
|
||||
"""
|
||||
QGIS 服务模块。
|
||||
|
||||
注意:此包的核心模块(map_service、template_cache 等)依赖 qgis.core,
|
||||
仅在 QGIS Python 3.12 子进程中可导入。主进程(Python 3.10)不应直接导入
|
||||
这些模块,而应通过 qgis_runner.py 子进程调用。
|
||||
|
||||
安全导入(不依赖 qgis.core):
|
||||
- qgis_env: 环境检测与子进程配置
|
||||
- qgis_runner: 子进程入口脚本路径
|
||||
"""
|
||||
|
||||
def _lazy_import(module_name: str, attr: str):
|
||||
"""延迟导入,仅在子进程中实际使用时才加载"""
|
||||
import importlib
|
||||
mod = importlib.import_module(f".{module_name}", package=__name__)
|
||||
return getattr(mod, attr)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'MapService',
|
||||
'MapExporter',
|
||||
'TemplateModifier',
|
||||
'TemplateCache',
|
||||
'LayerFilter',
|
||||
"MapService",
|
||||
"MapExporter",
|
||||
"TemplateModifier",
|
||||
"TemplateCache",
|
||||
"LayerFilter",
|
||||
]
|
||||
|
||||
# 不在顶层导入 QGIS 依赖模块,避免主进程崩溃
|
||||
# 使用方式:
|
||||
# from app.services.qgis.map_service import MapService (仅在子进程中)
|
||||
# from app.services.qgis.qgis_env import is_qgis_available (主进程安全)
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
图层过滤模块。按 event 和 eqqueue_id 筛选要素。
|
||||
"""
|
||||
from app.config.paths import get_logger
|
||||
from app.config.qgis_mappings import EVENT_LAYERS, QUEUE_LAYERS
|
||||
|
||||
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:
|
||||
|
||||
@@ -15,7 +15,7 @@ class MapExporter:
|
||||
|
||||
def update_texts(self, model: dict) -> None:
|
||||
"""更新布局中的文本标签"""
|
||||
for key in ["mapTitle", "mapTime", "mapUnit", "info"]:
|
||||
for key in ["mapTitle", "mapTime", "mapUint", "info"]:
|
||||
label = self.layout.itemById(key)
|
||||
if label is not None:
|
||||
label.setText(model[key])
|
||||
|
||||
@@ -7,6 +7,8 @@ import time
|
||||
|
||||
from qgis.core import QgsProject, QgsDataSourceUri
|
||||
|
||||
from app.config.paths import get_logger
|
||||
from app.config.qgis_mappings import TABLE_RENAMES, SCHEMA_REPLACEMENTS
|
||||
from .template_cache import TemplateCache
|
||||
from .template_modifier import TemplateModifier
|
||||
from .layer_filter import LayerFilter
|
||||
@@ -115,17 +117,32 @@ class MapService:
|
||||
db_config["username"],
|
||||
db_config["password"],
|
||||
)
|
||||
# 更新 schema(table="base"."xxx" → table="qgis"."xxx")
|
||||
# 更新 schema
|
||||
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)
|
||||
for old_schema in SCHEMA_REPLACEMENTS:
|
||||
new_uri = old_uri.replace(
|
||||
f'table="{old_schema}".',
|
||||
f'table="{actual_schema}".',
|
||||
)
|
||||
if new_uri != old_uri:
|
||||
uri = QgsDataSourceUri(new_uri)
|
||||
break
|
||||
|
||||
# 表名映射(模板表名 ≠ 目标库表名)
|
||||
uri_str = uri.uri()
|
||||
for old_name, new_name in TABLE_RENAMES.items():
|
||||
full_old = f'table="{actual_schema}"."{old_name}"'
|
||||
full_new = f'table="{actual_schema}"."{new_name}"'
|
||||
if full_old in uri_str:
|
||||
uri_str = uri_str.replace(full_old, full_new)
|
||||
uri = QgsDataSourceUri(uri_str)
|
||||
|
||||
layer.setDataSource(uri.uri(), layer.name(), "postgres")
|
||||
|
||||
if layer.isValid():
|
||||
logger.info(f"图层 {layer.name()} 连接更新成功")
|
||||
fc = layer.featureCount()
|
||||
logger.info(f"图层 {layer.name()} 连接更新成功 ({fc} features)")
|
||||
else:
|
||||
logger.error(f"图层 {layer.name()} 更新后仍无效")
|
||||
except Exception as e:
|
||||
|
||||
+164
-242
@@ -1,266 +1,188 @@
|
||||
"""
|
||||
QGIS 环境初始化模块(跨平台:Windows / Linux)。
|
||||
在 server.py lifespan 启动阶段调用 init_qgis_env(),
|
||||
完成共享库注入、环境变量设置、QgsApplication 初始化。
|
||||
QGIS 环境检测与子进程配置模块。
|
||||
|
||||
主进程运行在 Python 3.10,无法直接加载 QGIS 的 Python 3.12 C 扩展。
|
||||
所有 QGIS 操作通过 subprocess 调用 QGIS Python 3.12 执行。
|
||||
|
||||
本模块提供:
|
||||
- get_qgis_python_path(): 检测 QGIS Python 3.12 解释器路径
|
||||
- build_qgis_command(): 构建通过 .bat 启动 QGIS 子进程的命令
|
||||
- is_qgis_available(): 检查 QGIS 是否可用
|
||||
- get_runner_script(): 获取 qgis_runner.py 的路径
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import tempfile
|
||||
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:
|
||||
def get_qgis_python_path(qgis_root: str = None) -> str | None:
|
||||
"""
|
||||
初始化 QGIS 运行环境。整个应用生命周期只需调用一次。
|
||||
检测 QGIS 自带的 Python 3.12 解释器路径。
|
||||
|
||||
Args:
|
||||
qgis_root: QGIS 安装根目录
|
||||
Windows: "D:/QGIS"
|
||||
Linux: "/usr" 或 "/opt/QGIS"
|
||||
Windows: {QGIS_ROOT}/apps/Python312/python3.exe
|
||||
|
||||
Returns:
|
||||
解释器绝对路径,不存在则返回 None
|
||||
"""
|
||||
global _qgs_app, _initialized
|
||||
if _initialized:
|
||||
return
|
||||
if qgis_root is None:
|
||||
qgis_root = _detect_qgis_root()
|
||||
if qgis_root is None:
|
||||
return None
|
||||
|
||||
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)
|
||||
import platform
|
||||
if platform.system() == "Windows":
|
||||
for name in ("python3.exe", "python.exe"):
|
||||
candidate = os.path.join(qgis_root, "apps", "Python312", name)
|
||||
if os.path.isfile(candidate):
|
||||
logger.info(f"检测到 QGIS Python: {candidate}")
|
||||
return candidate
|
||||
logger.warning(f"未找到 QGIS Python 3.12")
|
||||
return None
|
||||
else:
|
||||
return _find_qgis_app_dir_linux(root)
|
||||
import shutil
|
||||
|
||||
# 优先检查环境变量
|
||||
env_python = os.environ.get("QGIS_PYTHON_PATH")
|
||||
if env_python and os.path.isfile(env_python):
|
||||
logger.info(f"QGIS_PYTHON_PATH 指定: {env_python}")
|
||||
return env_python
|
||||
|
||||
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"),
|
||||
# 搜索标准 Linux QGIS 安装路径
|
||||
linux_paths = [
|
||||
"/usr/bin/qgis", # Ubuntu/Debian apt 安装
|
||||
"/usr/bin/qgis.bin",
|
||||
"/opt/QGIS/apps/Python312/bin/python3", # 独立安装器
|
||||
"/opt/QGIS/apps/Python3/bin/python3",
|
||||
"/usr/libexec/qgis/python3", # Fedora/RHEL
|
||||
]
|
||||
for p in gdal_candidates:
|
||||
if os.path.isdir(p):
|
||||
env_vars["GDAL_DATA"] = p
|
||||
break
|
||||
for candidate in linux_paths:
|
||||
if os.path.isfile(candidate):
|
||||
logger.info(f"检测到 QGIS 可执行文件: {candidate}")
|
||||
return candidate
|
||||
|
||||
# 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(如果 qgis.core 可导入)
|
||||
sys_python = shutil.which("python3") or shutil.which("python")
|
||||
if sys_python:
|
||||
logger.info(f"Linux 环境,使用系统 Python: {sys_python}")
|
||||
return sys_python
|
||||
return None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Python 模块路径
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def build_qgis_command(qgis_root: str = None) -> list[str]:
|
||||
"""
|
||||
构建通过 .bat 包装器启动 QGIS 子进程的命令列表。
|
||||
|
||||
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"),
|
||||
]
|
||||
.bat 文件设置环境变量并启动 QGIS Python 3.12。
|
||||
DLL 加载由 qgis_runner.py 中的 ctypes 预加载机制处理。
|
||||
|
||||
Returns:
|
||||
可直接传给 subprocess.run() 的命令列表
|
||||
"""
|
||||
import platform
|
||||
if platform.system() != "Windows":
|
||||
# Linux: 直接用 Python,不需要 .bat 包装
|
||||
python_path = get_qgis_python_path(qgis_root)
|
||||
if not python_path:
|
||||
raise RuntimeError("未找到 QGIS Python 解释器")
|
||||
return [python_path, get_runner_script()]
|
||||
|
||||
if qgis_root is None:
|
||||
qgis_root = _detect_qgis_root() or "D:/QGIS"
|
||||
|
||||
python_path = get_qgis_python_path(qgis_root)
|
||||
if not python_path:
|
||||
raise RuntimeError("未找到 QGIS Python 3.12 解释器")
|
||||
|
||||
runner_script = get_runner_script()
|
||||
if not os.path.isfile(runner_script):
|
||||
raise RuntimeError(f"QGIS Runner 脚本不存在: {runner_script}")
|
||||
|
||||
# 生成 .bat 包装脚本
|
||||
bat_path = _generate_bat_wrapper(qgis_root, python_path, runner_script)
|
||||
return ["cmd.exe", "/c", bat_path]
|
||||
|
||||
|
||||
def is_qgis_available(qgis_root: str = None) -> bool:
|
||||
"""检查 QGIS 环境是否可用"""
|
||||
return get_qgis_python_path(qgis_root) is not None
|
||||
|
||||
|
||||
def get_runner_script() -> str:
|
||||
"""获取 qgis_runner.py 的绝对路径"""
|
||||
return str(Path(__file__).parent / "qgis_runner.py")
|
||||
|
||||
|
||||
def _generate_bat_wrapper(qgis_root: str, python_path: str, runner_script: str) -> str:
|
||||
"""
|
||||
生成 .bat 包装脚本,设置 QGIS 环境变量并启动 runner。
|
||||
|
||||
.bat 文件通过 cmd.exe 执行,设置必要的环境变量。
|
||||
DLL 加载由 qgis_runner.py 的 ctypes 预加载处理。
|
||||
"""
|
||||
qgis_app_dir = os.path.join(qgis_root, "apps", "qgis-ltr").replace("/", "\\")
|
||||
python_dir = os.path.join(qgis_root, "apps", "Python312").replace("/", "\\")
|
||||
qt5_plugins = os.path.join(qgis_root, "apps", "Qt5", "plugins").replace("/", "\\")
|
||||
qtplugins = os.path.join(qgis_app_dir, "qtplugins").replace("/", "\\")
|
||||
gdal_data = os.path.join(qgis_root, "apps", "gdal", "share", "gdal").replace("/", "\\")
|
||||
qgis_python_dir = os.path.join(qgis_app_dir, "python").replace("/", "\\")
|
||||
qgis_bin = os.path.join(qgis_app_dir, "bin").replace("/", "\\")
|
||||
qt5_bin = os.path.join(qgis_root, "apps", "Qt5", "bin").replace("/", "\\")
|
||||
gdal_lib = os.path.join(qgis_root, "apps", "gdal", "lib").replace("/", "\\")
|
||||
|
||||
bat_content = f"""@echo off
|
||||
set "PYTHONHOME={python_dir}"
|
||||
set "PYTHONPATH={qgis_python_dir}"
|
||||
set "QGIS_PREFIX_PATH={qgis_app_dir}"
|
||||
set "QT_PLUGIN_PATH={qtplugins};{qt5_plugins}"
|
||||
set "GDAL_DATA={gdal_data}"
|
||||
set "PYTHONUTF8=1"
|
||||
set "GDAL_FILENAME_IS_UTF8=YES"
|
||||
set "VSI_CACHE=TRUE"
|
||||
set "VSI_CACHE_SIZE=1000000"
|
||||
set "PATH={qgis_bin};{qt5_bin};{gdal_lib};%PATH%"
|
||||
"{python_path}" "{runner_script}" %*
|
||||
"""
|
||||
|
||||
# 写入临时 .bat 文件
|
||||
bat_dir = os.path.join(tempfile.gettempdir(), "qgis_runner")
|
||||
os.makedirs(bat_dir, exist_ok=True)
|
||||
bat_path = os.path.join(bat_dir, "run_qgis.bat")
|
||||
|
||||
with open(bat_path, "w", encoding="utf-8") as f:
|
||||
f.write(bat_content)
|
||||
|
||||
logger.debug(f"生成 QGIS 包装脚本: {bat_path}")
|
||||
return bat_path
|
||||
|
||||
|
||||
def _detect_qgis_root() -> str | None:
|
||||
"""
|
||||
自动检测 QGIS 安装根目录。
|
||||
|
||||
优先级:
|
||||
1. 环境变量 QGIS_ROOT
|
||||
2. Windows 默认路径 D:/QGIS
|
||||
3. Linux 常见路径
|
||||
"""
|
||||
env_root = os.environ.get("QGIS_ROOT")
|
||||
if env_root and os.path.isdir(env_root):
|
||||
return env_root
|
||||
|
||||
import platform
|
||||
if platform.system() == "Windows":
|
||||
for candidate in ["D:/QGIS", "C:/OSGeo4W", "C:/QGIS"]:
|
||||
if os.path.isdir(candidate):
|
||||
logger.info(f"检测到 QGIS 根目录: {candidate}")
|
||||
return candidate
|
||||
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 candidate in ["/usr", "/opt/QGIS", "/home/QGIS"]:
|
||||
if os.path.isdir(candidate):
|
||||
logger.info(f"检测到 QGIS 根目录: {candidate}")
|
||||
return candidate
|
||||
|
||||
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 初始化完成")
|
||||
logger.warning("未检测到 QGIS 安装目录")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QGIS 专题图生成子进程入口。
|
||||
|
||||
由主进程 (Python 3.10) 通过 subprocess 调用,
|
||||
运行在 QGIS 自带的 Python 3.12 环境中。
|
||||
|
||||
支持两种模式:
|
||||
- 批量模式(推荐):单次启动 QgsApplication,顺序处理多个模板
|
||||
输入: { "config": {...}, "models": [{...}, {...}, ...] }
|
||||
- 单任务模式(兼容):
|
||||
输入: { "config": {...}, "model": {...} }
|
||||
|
||||
输出 JSON (stdout):
|
||||
批量: { "results": [{"name": "...", "output": "..."}, ...] }
|
||||
单任务: { "name": "...", "output": "..." }
|
||||
|
||||
错误: stderr + exit code 1
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ============================================================
|
||||
# 1. 环境初始化(必须在任何 QGIS/Qt import 之前)
|
||||
# ============================================================
|
||||
|
||||
QGIS_ROOT = os.environ.get("QGIS_ROOT", "D:/QGIS")
|
||||
|
||||
|
||||
def _setup_python_path():
|
||||
"""将项目根目录和 QGIS Python 路径加入 sys.path"""
|
||||
project_root = os.path.dirname(
|
||||
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)
|
||||
|
||||
qgis_python = os.path.join(QGIS_ROOT, "apps", "qgis-ltr", "python")
|
||||
if os.path.isdir(qgis_python) and qgis_python not in sys.path:
|
||||
sys.path.insert(0, qgis_python)
|
||||
|
||||
|
||||
def _setup_environment():
|
||||
"""设置 QGIS 运行所需的环境变量"""
|
||||
qgis_app_dir = os.path.join(QGIS_ROOT, "apps", "qgis-ltr")
|
||||
os.environ["QGIS_PREFIX_PATH"] = qgis_app_dir
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
os.environ["GDAL_FILENAME_IS_UTF8"] = "YES"
|
||||
os.environ["VSI_CACHE"] = "TRUE"
|
||||
os.environ["VSI_CACHE_SIZE"] = "1000000"
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ["QT_PLUGIN_PATH"] = (
|
||||
f"{os.path.join(qgis_app_dir, 'qtplugins')};"
|
||||
f"{os.path.join(QGIS_ROOT, 'apps', 'Qt5', 'plugins')}"
|
||||
)
|
||||
gdal_data = os.path.join(QGIS_ROOT, "apps", "gdal", "share", "gdal")
|
||||
if os.path.isdir(gdal_data):
|
||||
os.environ["GDAL_DATA"] = gdal_data
|
||||
|
||||
import ctypes
|
||||
_dll_dirs = [
|
||||
os.path.join(qgis_app_dir, "bin"),
|
||||
os.path.join(QGIS_ROOT, "apps", "Qt5", "bin"),
|
||||
os.path.join(QGIS_ROOT, "apps", "gdal", "lib"),
|
||||
]
|
||||
_preload_dlls = [
|
||||
"qgis_core.dll", "qgispython.dll",
|
||||
"Qt5Core.dll", "Qt5Gui.dll", "Qt5Widgets.dll",
|
||||
"Qt5Network.dll", "Qt5Svg.dll", "Qt5Xml.dll",
|
||||
"Qt5Concurrent.dll", "Qt5PrintSupport.dll",
|
||||
]
|
||||
for dll_dir in _dll_dirs:
|
||||
if not os.path.isdir(dll_dir):
|
||||
continue
|
||||
os.add_dll_directory(dll_dir)
|
||||
for dll_name in _preload_dlls:
|
||||
dll_path = os.path.join(dll_dir, dll_name)
|
||||
if os.path.isfile(dll_path):
|
||||
try:
|
||||
ctypes.WinDLL(dll_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. 主逻辑
|
||||
# ============================================================
|
||||
|
||||
def _init_qgis():
|
||||
"""初始化 QgsApplication(只做一次)"""
|
||||
from qgis.core import QgsApplication
|
||||
|
||||
qgis_app_dir = os.path.join(QGIS_ROOT, "apps", "qgis-ltr")
|
||||
QgsApplication.setPrefixPath(qgis_app_dir, True)
|
||||
qgs_app = QgsApplication([], False)
|
||||
qgs_app.initQgis()
|
||||
return qgs_app
|
||||
|
||||
|
||||
def _process_single(service, model):
|
||||
"""处理单个模板,返回结果 dict"""
|
||||
name = service.generate(model)
|
||||
return {"name": name, "output": model["outFile"]}
|
||||
|
||||
|
||||
def main():
|
||||
t_start = time.time()
|
||||
|
||||
# 环境初始化
|
||||
_setup_environment()
|
||||
_setup_python_path()
|
||||
|
||||
# 读取请求 JSON
|
||||
if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
||||
request = json.load(f)
|
||||
else:
|
||||
request = json.load(sys.stdin)
|
||||
|
||||
config = request["config"]
|
||||
|
||||
# 兼容批量和单任务模式
|
||||
models = request.get("models") or [request["model"]]
|
||||
|
||||
# 初始化 QgsApplication(只做一次)
|
||||
qgs_app = _init_qgis()
|
||||
|
||||
try:
|
||||
from app.services.qgis.map_service import MapService
|
||||
|
||||
service = MapService(config)
|
||||
results = []
|
||||
|
||||
for i, model in enumerate(models):
|
||||
t_model = time.time()
|
||||
try:
|
||||
result = _process_single(service, model)
|
||||
results.append(result)
|
||||
elapsed = time.time() - t_model
|
||||
print(
|
||||
f"[qgis_runner] [{i+1}/{len(models)}] 完成: {result['name']}, "
|
||||
f"耗时 {elapsed:.1f}s",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t_model
|
||||
error_msg = f"{e}"
|
||||
print(
|
||||
f"[qgis_runner] [{i+1}/{len(models)}] 失败: {model.get('name', '?')}, "
|
||||
f"耗时 {elapsed:.1f}s — {error_msg}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
results.append({"name": model.get("name", ""), "output": "", "error": error_msg})
|
||||
|
||||
# 输出结果
|
||||
if len(models) == 1 and not request.get("models"):
|
||||
# 单任务模式兼容
|
||||
json.dump(results[0], sys.stdout, ensure_ascii=False)
|
||||
else:
|
||||
json.dump({"results": results}, sys.stdout, ensure_ascii=False)
|
||||
sys.stdout.flush()
|
||||
|
||||
total = time.time() - t_start
|
||||
ok = sum(1 for r in results if not r.get("error"))
|
||||
fail = len(results) - ok
|
||||
print(
|
||||
f"\n[qgis_runner] 批量完成: {ok}成功/{fail}失败, 总耗时 {total:.1f}s",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t_start
|
||||
print(f"[qgis_runner] 致命错误 ({elapsed:.1f}s): {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
qgs_app.exitQgis()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -51,7 +51,7 @@ class TemplateCache:
|
||||
extent = None
|
||||
layout = project.layoutManager().layoutByName(layout_name)
|
||||
if layout:
|
||||
for item_id in ["mapTitle", "mapTime", "mapUnit", "info"]:
|
||||
for item_id in ["mapTitle", "mapTime", "mapUint", "info"]:
|
||||
item = layout.itemById(item_id)
|
||||
if item:
|
||||
texts[item_id] = item.text()
|
||||
|
||||
@@ -9,6 +9,7 @@ import zipfile
|
||||
import tempfile
|
||||
|
||||
from app.config.paths import get_logger
|
||||
from app.config.qgis_mappings import OGR_TO_POSTGRES, TABLE_RENAMES, SCHEMA_REPLACEMENTS
|
||||
|
||||
logger = get_logger("qgis.modifier")
|
||||
|
||||
@@ -33,6 +34,26 @@ class TemplateModifier:
|
||||
mapping[xml_key] = gpkg_path
|
||||
return mapping
|
||||
|
||||
@staticmethod
|
||||
def _fix_provider_tags(content: str) -> str:
|
||||
"""
|
||||
将 GPKG 文件路径对应的 <provider ...>postgres</provider> 改为 ogr。
|
||||
|
||||
策略:按 <maplayer> 块处理,若块内 datasource 是文件路径(盘符开头),
|
||||
则该块内的 provider 改为 ogr。避免跨层误改。
|
||||
"""
|
||||
maplayer_re = re.compile(r'(<maplayer[^>]*>.*?</maplayer>)', re.DOTALL)
|
||||
provider_re = re.compile(r'(<provider[^>]*>)postgres(</provider>)')
|
||||
file_ds_re = re.compile(r'<datasource>([A-Za-z]:/[^<]+)</datasource>')
|
||||
|
||||
def _fix_layer(m):
|
||||
layer_xml = m.group(1)
|
||||
if file_ds_re.search(layer_xml):
|
||||
layer_xml = provider_re.sub(r'\1ogr\2', layer_xml)
|
||||
return layer_xml
|
||||
|
||||
return maplayer_re.sub(_fix_layer, content)
|
||||
|
||||
def modify(self, template_path: str) -> str:
|
||||
"""修改模板文件,返回修改后的临时 .qgz 路径"""
|
||||
override = self.config.get("template_override")
|
||||
@@ -65,29 +86,8 @@ class TemplateModifier:
|
||||
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
|
||||
# ★ 先替换静态底图 datasource(在 schema 替换之前)
|
||||
# 否则 table="base"."xxx" 会被改为 table="qgis"."xxx",导致匹配失败
|
||||
if self._static_map:
|
||||
def _replace(m):
|
||||
tbl = table_re.search(m.group(2))
|
||||
@@ -99,6 +99,74 @@ class TemplateModifier:
|
||||
|
||||
content = datasource_re.sub(_replace, content)
|
||||
|
||||
# ★ 转换避难场所 OGR 图层为 PostgreSQL(模板引用本地 JSON 文件)
|
||||
_target_schema = (actual or {}).get("schema") or "qgis"
|
||||
_db = self.config.get("db", {})
|
||||
content = self._convert_ogr_to_postgres(content, _db, _target_schema)
|
||||
|
||||
# 替换所有 PostgreSQL 连接参数(统一指向目标库)
|
||||
db = self.config.get("db", {})
|
||||
|
||||
# host: 模板中 host=localhost 无引号,匹配带/不带引号
|
||||
if db.get("host"):
|
||||
content = re.sub(
|
||||
r"(host\s*=\s*)(?:['\"])?(?:localhost|127\.0\.0\.1)(?:['\"])?(?=\s|$)",
|
||||
rf"\g<1>{db['host']}",
|
||||
content,
|
||||
)
|
||||
# port: 模板中 port=5432 无引号,匹配带/不带引号
|
||||
if db.get("port"):
|
||||
content = re.sub(
|
||||
r"(port\s*=\s*)(?:['\"])?5432(?:['\"])?(?=\s|$)",
|
||||
rf"\g<1>{str(db['port'])}",
|
||||
content,
|
||||
)
|
||||
if db.get("database"):
|
||||
content = re.sub(
|
||||
r"(dbname\s*=\s*['\"])([^'\"]+)(['\"])",
|
||||
rf"\g<1>{db['database']}\3",
|
||||
content,
|
||||
)
|
||||
# 移除 authcfg(QGIS 本地认证配置,子进程环境无效)
|
||||
content = re.sub(r'\s*authcfg\s*=\s*\S+', '', content)
|
||||
# 移除可能残留的 user=/password=(防止重复注入)
|
||||
if db.get("username"):
|
||||
content = re.sub(r"\s*user\s*=\s*'[^']*'", '', content)
|
||||
if db.get("password"):
|
||||
content = re.sub(r"\s*password\s*=\s*'[^']*'", '', content)
|
||||
# 在 port 后注入显式 user + password
|
||||
if db.get("username") and db.get("password"):
|
||||
content = re.sub(
|
||||
r'(port\s*=\s*\d+)',
|
||||
rf"\1 user='{db['username']}' password='{db['password']}'",
|
||||
content,
|
||||
)
|
||||
# schema 替换:统一指向目标 schema
|
||||
target_schema = (actual or {}).get("schema") or "qgis"
|
||||
for old_schema in SCHEMA_REPLACEMENTS:
|
||||
content = content.replace(
|
||||
f'table="{old_schema}".',
|
||||
f'table="{target_schema}".',
|
||||
)
|
||||
|
||||
# 表名映射(模板表名 ≠ 目标库表名)
|
||||
for old_name, new_name in TABLE_RENAMES.items():
|
||||
content = content.replace(
|
||||
f'table="{target_schema}"."{old_name}"',
|
||||
f'table="{target_schema}"."{new_name}"',
|
||||
)
|
||||
|
||||
# tid 列已通过 add_tid_column.py 添加到所有 qgis 动态表中
|
||||
# 模板中的 key='tid' 可正常工作,无需移除
|
||||
|
||||
# 修正 srid=0 → srid=4326(QGIS 无法从 srid=0 自动检测 SRID)
|
||||
content = content.replace(" srid=0 ", " srid=4326 ")
|
||||
|
||||
# ★ 修复 provider 标签:GPKG 文件路径的图层必须用 ogr provider
|
||||
# 否则 QGIS 会将 .gpkg 路径当作 PostgreSQL 连接字符串解析,导致图层无效
|
||||
if self._static_map:
|
||||
content = self._fix_provider_tags(content)
|
||||
|
||||
data = content.encode("utf-8")
|
||||
|
||||
zout.writestr(item, data)
|
||||
@@ -108,4 +176,48 @@ class TemplateModifier:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模板修改失败,将使用原始模板: {e}")
|
||||
return template_path
|
||||
return template_path
|
||||
|
||||
@staticmethod
|
||||
def _convert_ogr_to_postgres(content: str, db: dict, schema: str) -> str:
|
||||
"""
|
||||
将模板中引用本地文件的避难场所 OGR 图层转换为 PostgreSQL 图层。
|
||||
|
||||
设计师在本地用 JSON 文件制作了避难场所图层(公园/学校/文化馆等),
|
||||
这些数据已迁移到目标库 qgis schema 中。
|
||||
"""
|
||||
_ogr_shelter_map = OGR_TO_POSTGRES
|
||||
|
||||
maplayer_re = re.compile(r'(<maplayer[^>]*>.*?</maplayer>)', re.DOTALL)
|
||||
provider_re = re.compile(r'(<provider[^>]*>)ogr(</provider>)')
|
||||
datasource_re = re.compile(r'(<datasource>).*?(</datasource>)', re.DOTALL)
|
||||
file_ds_re = re.compile(r'<datasource>.*?\|layername=([^<]+)</datasource>', re.DOTALL)
|
||||
|
||||
def _convert_layer(m):
|
||||
layer_xml = m.group(1)
|
||||
ds_match = file_ds_re.search(layer_xml)
|
||||
if not ds_match:
|
||||
return layer_xml
|
||||
layer_name = ds_match.group(1)
|
||||
table = _ogr_shelter_map.get(layer_name)
|
||||
if not table:
|
||||
return layer_xml
|
||||
|
||||
# 构建 PostgreSQL URI
|
||||
pg_uri = (
|
||||
f"dbname='{db.get('database', 'xian_new')}' "
|
||||
f"host={db.get('host', '47.92.216.173')} "
|
||||
f"port={db.get('port', 7654)} "
|
||||
f"user='{db.get('username', 'postgres')}' "
|
||||
f"password='{db.get('password', '')}' "
|
||||
f"key='tid' srid=4326 type=Point "
|
||||
f"table=\"{schema}\".\"{table}\" (geometry)"
|
||||
)
|
||||
new_ds = f"<datasource>{pg_uri}</datasource>"
|
||||
|
||||
layer_xml = datasource_re.sub(new_ds, layer_xml)
|
||||
layer_xml = provider_re.sub(r'\1postgres\2', layer_xml)
|
||||
logger.info(f"避难场所图层已转换: {layer_name} → qgis.{table}")
|
||||
return layer_xml
|
||||
|
||||
return maplayer_re.sub(_convert_layer, content)
|
||||
Reference in New Issue
Block a user