diff --git a/app/data/cache/qgis_templates/19470628504c856d.qgz b/app/data/cache/qgis_templates/19470628504c856d.qgz new file mode 100644 index 0000000..e179088 Binary files /dev/null and b/app/data/cache/qgis_templates/19470628504c856d.qgz differ diff --git a/app/data/cache/qgis_templates/32dc5df99342a7da.qgz b/app/data/cache/qgis_templates/32dc5df99342a7da.qgz new file mode 100644 index 0000000..e1bb431 Binary files /dev/null and b/app/data/cache/qgis_templates/32dc5df99342a7da.qgz differ diff --git a/app/data/cache/qgis_templates/3bac2f2636b197e9.qgz b/app/data/cache/qgis_templates/3bac2f2636b197e9.qgz new file mode 100644 index 0000000..de40752 Binary files /dev/null and b/app/data/cache/qgis_templates/3bac2f2636b197e9.qgz differ diff --git a/app/data/cache/qgis_templates/41f47cbdd9c9c8d0.qgz b/app/data/cache/qgis_templates/41f47cbdd9c9c8d0.qgz new file mode 100644 index 0000000..74ce262 Binary files /dev/null and b/app/data/cache/qgis_templates/41f47cbdd9c9c8d0.qgz differ diff --git a/app/data/cache/qgis_templates/4c40d3feeddf4567.qgz b/app/data/cache/qgis_templates/4c40d3feeddf4567.qgz new file mode 100644 index 0000000..f7c87a2 Binary files /dev/null and b/app/data/cache/qgis_templates/4c40d3feeddf4567.qgz differ diff --git a/app/data/cache/qgis_templates/5a480fbeb8b2c66c.qgz b/app/data/cache/qgis_templates/5a480fbeb8b2c66c.qgz new file mode 100644 index 0000000..18f6158 Binary files /dev/null and b/app/data/cache/qgis_templates/5a480fbeb8b2c66c.qgz differ diff --git a/app/data/cache/qgis_templates/66a88abaa976d72a.qgz b/app/data/cache/qgis_templates/66a88abaa976d72a.qgz new file mode 100644 index 0000000..6a3e52d Binary files /dev/null and b/app/data/cache/qgis_templates/66a88abaa976d72a.qgz differ diff --git a/app/data/cache/qgis_templates/82ba30aacf8a53dd.qgz b/app/data/cache/qgis_templates/82ba30aacf8a53dd.qgz new file mode 100644 index 0000000..5c28b9d Binary files /dev/null and b/app/data/cache/qgis_templates/82ba30aacf8a53dd.qgz differ diff --git a/app/data/cache/qgis_templates/98c5467c5904eee5.qgz b/app/data/cache/qgis_templates/98c5467c5904eee5.qgz new file mode 100644 index 0000000..dea99cb Binary files /dev/null and b/app/data/cache/qgis_templates/98c5467c5904eee5.qgz differ diff --git a/app/data/cache/qgis_templates/bc46a5234f68e54e.qgz b/app/data/cache/qgis_templates/bc46a5234f68e54e.qgz new file mode 100644 index 0000000..aabb84f Binary files /dev/null and b/app/data/cache/qgis_templates/bc46a5234f68e54e.qgz differ diff --git a/app/data/cache/qgis_templates/d8c1e99a6640a0fa.qgz b/app/data/cache/qgis_templates/d8c1e99a6640a0fa.qgz new file mode 100644 index 0000000..1a45458 Binary files /dev/null and b/app/data/cache/qgis_templates/d8c1e99a6640a0fa.qgz differ diff --git a/app/data/cache/qgis_templates/d94f0d54dd607cbf.qgz b/app/data/cache/qgis_templates/d94f0d54dd607cbf.qgz new file mode 100644 index 0000000..4f2e411 Binary files /dev/null and b/app/data/cache/qgis_templates/d94f0d54dd607cbf.qgz differ diff --git a/app/data/cache/qgis_templates/dc2534d5788a805f.qgz b/app/data/cache/qgis_templates/dc2534d5788a805f.qgz new file mode 100644 index 0000000..6cbfe02 Binary files /dev/null and b/app/data/cache/qgis_templates/dc2534d5788a805f.qgz differ diff --git a/app/data/cache/qgis_templates/ef423854adc709b4.qgz b/app/data/cache/qgis_templates/ef423854adc709b4.qgz new file mode 100644 index 0000000..597e7aa Binary files /dev/null and b/app/data/cache/qgis_templates/ef423854adc709b4.qgz differ diff --git a/app/data/cache/qgis_templates/f0cf04a82b3bfb58.qgz b/app/data/cache/qgis_templates/f0cf04a82b3bfb58.qgz new file mode 100644 index 0000000..57ec410 Binary files /dev/null and b/app/data/cache/qgis_templates/f0cf04a82b3bfb58.qgz differ diff --git a/app/data/cache/qgis_templates/index.json b/app/data/cache/qgis_templates/index.json new file mode 100644 index 0000000..3434f92 --- /dev/null +++ b/app/data/cache/qgis_templates/index.json @@ -0,0 +1,244 @@ +{ + "templates": { + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨内涝潜在隐患点及人口分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\4c40d3feeddf4567.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨内涝潜在隐患点及农作物分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\19470628504c856d.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨地灾风险区分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\41f47cbdd9c9c8d0.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.87768948431949, + "ymin": 32.972841920667406, + "xmax": 108.96006631597123, + "ymax": 33.68287129318787 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨城市生命线工程分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\82ba30aacf8a53dd.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.46472706539424, + "ymin": 32.921385746843555, + "xmax": 108.51635924537791, + "ymax": 33.61124690706137 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨山洪潜在隐患点及人口分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\66a88abaa976d72a.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨山洪潜在隐患点及农作物分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\3bac2f2636b197e9.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨救援队伍分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\d8c1e99a6640a0fa.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨泥石流潜在隐患点及人口分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\5a480fbeb8b2c66c.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨泥石流潜在隐患点及农作物分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\32dc5df99342a7da.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨滑坡潜在隐患点及人口分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\98c5467c5904eee5.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨滑坡潜在隐患点及农作物分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\bc46a5234f68e54e.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨避难场所分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\d94f0d54dd607cbf.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.46472706539424, + "ymin": 32.921385746843555, + "xmax": 108.51635924537791, + "ymax": 33.61124690706137 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨防汛物资分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\f0cf04a82b3bfb58.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨附近医院分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\ef423854adc709b4.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + }, + "F:/project/xian/xian_algorithm_new/app/data/template/rainfall/暴雨附近水库分布图.qgz": { + "file": "F:\\project\\xian\\xian_algorithm_new\\app\\data\\cache\\qgis_templates\\dc2534d5788a805f.qgz", + "layout": "A3", + "texts": { + "mapTitle": "四川长宁6.0级地震震中附近乡镇距离分布图", + "mapTime": "制图时间:2020年11月", + "mapUint": "制图单位:四川省地震应急分服务中心", + "info": "时间:2019年06月17日22点55分\n震级:6.0级\n位置:四川省宜宾市长宁县双河镇" + }, + "extent": { + "xmin": 107.95707951802657, + "ymin": 32.9127480757085, + "xmax": 109.03945634967837, + "ymax": 33.622777448228966 + } + } + } +} \ No newline at end of file diff --git a/tools/add_tid_column.py b/app/script/add_tid_column.py similarity index 100% rename from tools/add_tid_column.py rename to app/script/add_tid_column.py diff --git a/tools/check_dynamic_layers.py b/app/script/check_dynamic_layers.py similarity index 100% rename from tools/check_dynamic_layers.py rename to app/script/check_dynamic_layers.py diff --git a/tools/check_layer_order.py b/app/script/check_layer_order.py similarity index 100% rename from tools/check_layer_order.py rename to app/script/check_layer_order.py diff --git a/tools/check_layout.py b/app/script/check_layout.py similarity index 100% rename from tools/check_layout.py rename to app/script/check_layout.py diff --git a/tools/check_shelter.py b/app/script/check_shelter.py similarity index 100% rename from tools/check_shelter.py rename to app/script/check_shelter.py diff --git a/tools/check_tables.py b/app/script/check_tables.py similarity index 100% rename from tools/check_tables.py rename to app/script/check_tables.py diff --git a/tools/inspect_layers.py b/app/script/inspect_layers.py similarity index 100% rename from tools/inspect_layers.py rename to app/script/inspect_layers.py diff --git a/tools/list_shelter_layers.py b/app/script/list_shelter_layers.py similarity index 100% rename from tools/list_shelter_layers.py rename to app/script/list_shelter_layers.py diff --git a/app/services/qgis/map_service.py b/app/services/qgis/map_service.py index e0d39c5..b9c8880 100644 --- a/app/services/qgis/map_service.py +++ b/app/services/qgis/map_service.py @@ -14,7 +14,6 @@ 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") @@ -29,43 +28,58 @@ class MapService: def generate(self, model: dict) -> str: """ 执行完整的地图生成流程。 - - Args: - model: 包含地图参数的字典 - - Returns: - 地图名称 """ - t_start = time.time() + _timing = {} # {step: seconds} + t_total = time.time() + template_path = model["path"] + template_name = os.path.basename(template_path) project = QgsProject.instance() is_cache_hit = template_cache.is_loaded(template_path) - # 加载/恢复模板 + # ── 步骤 1:加载/恢复模板 ── + t0 = time.time() if is_cache_hit: - logger.info(f"[缓存命中] {os.path.basename(template_path)}") + logger.info(f"[缓存命中] {template_name}") project, texts, extent = template_cache.restore_template(template_path) template_cache.reset_project_state(project, texts, extent) + # FlagDontResolveLayers 跳过了数据库连接,手动触发 PostgreSQL 图层解析 + self._resolve_postgres_layers(project) else: - logger.info(f"[首次加载] {os.path.basename(template_path)}") - # 直接读原始模板,不做文件级修改(避免 ZIP 兼容性问题) - project.read(template_path) + logger.info(f"[首次加载] {template_name}") + modifier = TemplateModifier(self.config) + actual_path = modifier.modify(template_path) + project.read(actual_path) + if actual_path != template_path: + try: + os.remove(actual_path) + except OSError: + pass + _timing["1.load"] = time.time() - t0 - # 更新图层连接 + GPKG/SRID/表名修正(仅首次加载) + # ── 步骤 2:图层连接修正 ── + t0 = time.time() if not is_cache_hit: - self._update_db_connections(project) + self._fix_invalid_layers(project) + _timing["2.connections"] = time.time() - t0 - # 图层过滤 + # ★ 首次加载完成后再保存缓存(确保图层已修正) + if not is_cache_hit: + template_cache.save_current(template_path, model.get("mapLayout", "A3")) + + # ── 步骤 3:图层过滤 ── + t0 = time.time() LayerFilter().apply(project, model) + _timing["3.filter"] = time.time() - t0 - # 地图缩放 + # ── 步骤 4:地图缩放 ── + t0 = time.time() 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"], { @@ -73,61 +87,69 @@ class MapService: "Y": model["centerY"], "value": model["zoomValue"], }) + _timing["4.zoom"] = time.time() - t0 - # 文本更新 + 比例尺 + 导出 + # ── 步骤 5:文本 + 比例尺 + 导出 ── + t0 = time.time() exporter = MapExporter(self.config, layout) exporter.update_texts(model) exporter.update_scale_bar() exporter.export(model["outFile"]) + _timing["5.export"] = time.time() - t0 - elapsed = time.time() - t_start + total = time.time() - t_total + steps = ", ".join(f"{k}={v:.1f}s" for k, v in _timing.items()) logger.info( f"{'[缓存命中]' if is_cache_hit else '[首次加载]'} " - f"导出完成: {model['name']},耗时 {elapsed:.1f}s" + f"{template_name} → {total:.1f}s ({steps})" ) return model["name"] - def _update_db_connections(self, project: QgsProject) -> None: - """更新图层连接 + SRID修正 + GPKG静态层替换""" + def _fix_invalid_layers(self, project: QgsProject) -> None: + """只修复 TemplateModifier 处理后仍无效的图层(TemplateModifier 已修正大部分连接)""" + t0 = time.time() db_config = self.config["db"] override = self.config.get("template_override", {}) actual_schema = override.get("actual", {}).get("schema", "qgis") - static_config = self.config.get("static_layers", {}) - static_enabled = static_config.get("enabled", False) - gpkg_dir = static_config.get("gpkg_dir", "") - static_layers_map = static_config.get("layers", {}) - - static_count = 0 + fixed = 0 + failed = 0 + total = 0 for layer in project.mapLayers().values(): - provider = layer.providerType() - - # GPKG 静态层替换:postgres 表 → 本地 GPKG 文件 - if provider == "postgres" and static_enabled: - uri_str = layer.dataProvider().uri().uri() - for name, info in static_layers_map.items(): - table_key = info["table"] - schema, table = table_key.split(".", 1) - if f'table="{schema}"."{table}"' in uri_str: - gpkg_path = os.path.join(gpkg_dir, info["file"]).replace("\\", "/") - layer.setDataSource(gpkg_path, name, "ogr") - static_count += 1 - logger.debug(f"静态图层 {name} → GPKG") - break - else: - # 没匹配到静态层,继续处理为 postgres - self._fix_postgres_layer(layer, db_config, actual_schema) + if layer.providerType() != "postgres": continue - - if provider == "ogr": - static_count += 1 - continue - - if provider == "postgres": + total += 1 + if layer.isValid(): + continue # TemplateModifier 已修正,跳过 + try: self._fix_postgres_layer(layer, db_config, actual_schema) + if layer.isValid(): + fixed += 1 + else: + failed += 1 + except Exception as e: + logger.error(f" 修复图层 {layer.name()} 失败: {e}") + failed += 1 - if static_count: - logger.info(f"静态底图已本地化: {static_count} 个图层") + elapsed = time.time() - t0 + if total: + logger.info( + f" 图层修正: 总计{total}个PG层, 跳过{total - fixed - failed}(已有效), " + f"修复{fixed}, 仍失败{failed}, 耗时{elapsed:.1f}s" + ) + + @staticmethod + def _resolve_postgres_layers(project: QgsProject) -> None: + """FlagDontResolveLayers 跳过了数据库连接,重新 setDataSource 触发""" + for layer in project.mapLayers().values(): + if layer.providerType() != "postgres": + continue + try: + source = layer.source() + if source: + layer.setDataSource(source, layer.name(), "postgres") + except Exception: + pass @staticmethod def _fix_postgres_layer(layer, db_config, actual_schema): @@ -168,9 +190,8 @@ class MapService: layer.setDataSource(uri.uri(), layer.name(), "postgres") if layer.isValid(): - fc = layer.featureCount() - logger.info(f"图层 {layer.name()} 连接更新成功 ({fc} features)") + logger.debug(f" 图层 {layer.name()} 连接更新成功") else: - logger.error(f"图层 {layer.name()} 更新后仍无效") + logger.error(f" 图层 {layer.name()} 更新后仍无效") except Exception as e: - logger.error(f"更新图层 {layer.name()} 连接失败: {e}") \ No newline at end of file + logger.error(f" 更新图层 {layer.name()} 连接失败: {e}") \ No newline at end of file diff --git a/app/services/qgis/qgis_runner.py b/app/services/qgis/qgis_runner.py index dcd9137..ae8b017 100644 --- a/app/services/qgis/qgis_runner.py +++ b/app/services/qgis/qgis_runner.py @@ -138,6 +138,12 @@ def main(): # 初始化 QgsApplication(只做一次) qgs_app = _init_qgis() + # 从磁盘恢复已缓存的模板(跨进程加速) + from app.services.qgis.map_service import template_cache + cached_count = template_cache.load_persistent_cache() + if cached_count > 0: + print(f"[qgis_runner] 磁盘缓存命中: {cached_count} 个模板", file=sys.stderr) + try: from app.services.qgis.map_service import MapService diff --git a/app/services/qgis/template_cache.py b/app/services/qgis/template_cache.py index f39c976..9725dd3 100644 --- a/app/services/qgis/template_cache.py +++ b/app/services/qgis/template_cache.py @@ -1,13 +1,15 @@ """ -模板缓存引擎。解决 QgsProject 单例问题。 +模板缓存引擎(支持持久化)。 流程: -1. 首次请求:project.read() 加载模板(慢,仅一次) -2. 加载后 project.write() 保存到临时文件 -3. 后续同模板请求:从临时文件恢复(快,连接复用) -4. 手动恢复文本/过滤/缩放(毫秒级) +1. 首次加载:TemplateModifier 修改模板 → project.read() → 保存到磁盘缓存 +2. 同进程内缓存命中:从内存恢复(~1s) +3. 跨进程/重启:从磁盘缓存恢复(首次 ~5s,后续 ~1s) """ +import hashlib +import json import os +import shutil import time import tempfile @@ -17,36 +19,37 @@ from app.config.paths import get_logger logger = get_logger("qgis.cache") +# 持久化缓存目录(项目根目录下) +_CACHE_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), + "app", "data", "cache", "qgis_templates", +) +_INDEX_FILE = os.path.join(_CACHE_DIR, "index.json") + 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() + def save_current(self, template_path: str, layout_name: str = "A4") -> None: + """将当前已加载的项目保存到内存缓存 + 磁盘缓存""" 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=tempfile.gettempdir(), - ) - tmp_path = tmp_file.name - tmp_file.close() + # ── 保存项目到持久化缓存目录 ── + os.makedirs(_CACHE_DIR, exist_ok=True) + key = self._path_key(template_path) + cache_file = os.path.join(_CACHE_DIR, f"{key}.qgz") t_save = time.time() - project.write(tmp_path) - logger.info(f"项目保存耗时: {time.time() - t_save:.1f}s") + project.write(cache_file) + logger.info(f" 缓存写入磁盘: {os.path.basename(cache_file)} ({time.time() - t_save:.1f}s)") - # 记录初始状态 + # ── 记录状态 ── texts = {} extent = None layout = project.layoutManager().layoutByName(layout_name) @@ -59,38 +62,41 @@ class TemplateCache: if map_item: extent = QgsRectangle(map_item.extent()) - self._cache[template_path] = { - "file": tmp_path, + entry = { + "file": cache_file, "texts": texts, "extent": extent, "layout": layout_name, } - logger.info(f"模板加载完成,总耗时: {time.time() - start:.1f}s") + self._cache[template_path] = entry + + # ── 更新索引文件 ── + self._update_index(template_path, entry) + logger.info(f" 模板已缓存: {os.path.basename(template_path)} ({len(self._cache)} 个)") 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() + t0 = time.time() - logger.info(f"恢复模板: {os.path.basename(template_path)}") - project.read(cached["file"]) - logger.info(f"project.read() 耗时: {time.time() - start:.1f}s") + # FlagDontResolveLayers: 跳过数据库连接验证,缓存文件已含正确连接 + flags = QgsProject.ReadFlags() + flags |= QgsProject.FlagDontResolveLayers + project.read(cached["file"], flags) + logger.debug(f" 恢复耗时: {time.time() - t0:.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") @@ -107,14 +113,82 @@ class TemplateCache: if map_item: map_item.zoomToExtent(extent) - logger.info(f"状态重置耗时: {time.time() - start:.3f}s") + def load_persistent_cache(self) -> int: + """从磁盘恢复所有缓存到内存(qgis_runner 启动时调用)""" + if not os.path.isfile(_INDEX_FILE): + logger.info(" 磁盘缓存为空,首次启动需要加载模板") + return 0 + + try: + with open(_INDEX_FILE, "r", encoding="utf-8") as f: + index = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f" 缓存索引损坏: {e}") + return 0 + + count = 0 + for template_path, entry in index.get("templates", {}).items(): + cache_file = entry.get("file", "") + if not cache_file or not os.path.isfile(cache_file): + logger.debug(f" 跳过无效缓存: {os.path.basename(template_path)}") + continue + texts = entry.get("texts", {}) + extent = entry.get("extent") + # 反序列化 extent + if extent and isinstance(extent, dict): + extent = QgsRectangle( + extent.get("xmin", 0), extent.get("ymin", 0), + extent.get("xmax", 0), extent.get("ymax", 0), + ) + else: + extent = None + self._cache[template_path] = { + "file": cache_file, + "texts": texts, + "extent": extent, + "layout": entry.get("layout", "A4"), + } + count += 1 + + logger.info(f" 磁盘缓存加载: {count} 个模板") + return count def cleanup(self) -> None: - """清理所有临时文件""" - for cached in self._cache.values(): - try: - os.remove(cached["file"]) - except OSError: - pass + """清理内存缓存(磁盘缓存保留)""" self._cache.clear() - logger.info("已清理所有缓存") + logger.info("已清理内存缓存") + + # ── 内部方法 ── + + @staticmethod + def _path_key(template_path: str) -> str: + """模板路径 → 缓存文件名 key""" + return hashlib.md5(template_path.encode()).hexdigest()[:16] + + def _update_index(self, template_path: str, entry: dict) -> None: + """更新磁盘索引文件""" + index = {} + if os.path.isfile(_INDEX_FILE): + try: + with open(_INDEX_FILE, "r", encoding="utf-8") as f: + index = json.load(f) + except (json.JSONDecodeError, OSError): + pass + + index.setdefault("templates", {})[template_path] = { + "file": entry["file"], + "layout": entry.get("layout", "A4"), + "texts": entry.get("texts", {}), + "extent": ( + { + "xmin": entry["extent"].xMinimum(), + "ymin": entry["extent"].yMinimum(), + "xmax": entry["extent"].xMaximum(), + "ymax": entry["extent"].yMaximum(), + } + if entry.get("extent") else None + ), + } + + with open(_INDEX_FILE, "w", encoding="utf-8") as f: + json.dump(index, f, ensure_ascii=False, indent=2) diff --git a/tools/test_single_map.py b/tools/test_single_map.py deleted file mode 100644 index 66183c2..0000000 --- a/tools/test_single_map.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -单张专题图测试脚本 - 独立版 -直接构建 model/config 并通过 subprocess 调用 qgis_runner.py -""" -import json, os, sys, tempfile, subprocess, re -from datetime import datetime - -# ============================================================ -# 配置(和 settings.toml 一致) -# ============================================================ -DB_CONFIG = { - "host": "47.92.216.173", - "port": 7654, - "username": "postgres", - "password": "zhangsan", - "database": "xian_new", -} -QGIS_ROOT = "D:/QGIS" -XIAN_CENTER = [108.948024, 34.263161] -GPKG_DIR = r"F:\project\xian\xian_algorithm_new\app\data\gpkg".replace("\\", "/") -TEMPLATE_BASE = r"F:\project\xian\xian_algorithm_new\app\data\template" - -# ============================================================ -# 模拟推理结果(inference_id=50) -# ============================================================ -# 从实际数据库查询得知: -# event_type='rainfall', condition={'region_code':'610116','rainfall':'120'}, -# occurred_time='2025-09-16 20:00:00' -inference = { - "id": 50, - "event_type": "rainfall", - "condition": {"region_code": "610116", "rainfall": "120"}, - "occurred_time": datetime(2025, 9, 16, 20, 0, 0), -} - -# ============================================================ -# 区域映射(从 settings.toml) -# ============================================================ -AREA_MAP = { - "610102": "新城区", "610103": "碑林区", "610104": "莲湖区", - "610111": "灞桥区", "610112": "未央区", "610113": "雁塔区", - "610114": "阎良区", "610115": "临潼区", "610116": "长安区", - "610117": "高陵区", "610118": "鄠邑区", "610122": "蓝田县", - "610124": "周至县", -} - -STATIC_LAYERS = { - "水库": ("base.rivers", "rivers.gpkg"), - "市州驻地": ("base.sx_capital", "sx_capital.gpkg"), - "河流": ("base.river", "river.gpkg"), - "active_fault": ("base.active_fault", "active_fault.gpkg"), - "陕西省": ("base.sx", "sx.gpkg"), - "乡镇驻地": ("base.sx_street", "sx_street.gpkg"), - "区县驻地": ("base.sx_xa_county", "sx_xa_county.gpkg"), - "县界": ("base.sx_xa_county_boundary", "sx_xa_county_boundary.gpkg"), - "周边区县": ("base.sx_zb_county_boundary", "sx_zb_county_boundary.gpkg"), - "周边市州": ("base.sx_zb_city", "sx_zb_city.gpkg"), - "周边县区": ("base.sx_zb_county", "sx_zb_county.gpkg"), - "traffic_expressway": ("base.traffic_expressway", "traffic_expressway.gpkg"), - "traffic_provincial": ("base.traffic_provincial", "traffic_provincial.gpkg"), - "traffic_railway": ("base.traffic_railway", "traffic_railway.gpkg"), - "traffic_township": ("base.traffic_township", "traffic_township.gpkg"), - "traffic_trunk_line": ("base.traffic_trunk_line", "traffic_trunk_line.gpkg"), -} - -# ============================================================ -# 辅助函数(从 qgis_map_export.py 直接复制) -# ============================================================ -def format_disaster_time(occurred_time): - if isinstance(occurred_time, datetime): - return occurred_time.strftime("%Y%m%d%H%M%S") - return str(occurred_time).replace("-", "").replace(":", "").replace(" ", "")[:14] - -def resolve_district(condition): - code = condition.get("region_code") - if code and str(code) in AREA_MAP: - return AREA_MAP[str(code)] - return "" - -def build_map_title(event_type, condition, template_name): - district = resolve_district(condition) - prefix = f"陕西西安{district}" if district else "陕西西安" - if 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 build_info_text(event_type, condition, occurred_time): - """构建信息面板文本(不显示灾害类型标签)""" - lines = [] - if isinstance(occurred_time, datetime): - time_str = f"{occurred_time.year}年{occurred_time.month:02d}月{occurred_time.day:02d}日{occurred_time.hour:02d}时{occurred_time.minute:02d}分" - elif occurred_time: - time_str = str(occurred_time) - else: - time_str = "" - lines.append(f"时间:{time_str}") - - if event_type == "rainfall": - rainfall = condition.get("rainfall") - if rainfall is not None and rainfall != "": - lines.append(f"累计降雨量:{float(rainfall)}mm") - duration = condition.get("duration") - if duration is not None and duration != "": - lines.append(f"已持续:{duration}") - elif event_type == "earthquake": - magnitude = condition.get("magnitude") - if magnitude is not None and magnitude != "": - lines.append(f"震级:{float(magnitude)}级") - lon = condition.get("epicenter_lon") - lat = condition.get("epicenter_lat") - if lon is not None and lat is not None: - lines.append(f"位置:经度{float(lon)}°, 纬度{float(lat)}°") - - return "\n".join(lines) - -def derive_model_params(inference, batch_folder, template_path): - event_type = inference["event_type"] - condition = inference["condition"] - occurred_time = inference["occurred_time"] - - template_name = os.path.splitext(os.path.basename(template_path))[0] - 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 isinstance(occurred_time, datetime): - map_time = occurred_time.strftime("%Y-%m-%d %H:%M") - else: - map_time = str(occurred_time) - - info_text = build_info_text(event_type, condition, occurred_time) - - center_x, center_y = XIAN_CENTER - if event_type == "earthquake": - lon = condition.get("epicenter_lon", XIAN_CENTER[0]) - lat = condition.get("epicenter_lat", XIAN_CENTER[1]) - center_x, center_y = float(lon), float(lat) - - return { - "name": f"test_{inference['id']}_{safe_name}", - "path": template_path, - "outFile": out_file, - "mapLayout": "A3", - "mapTitle": map_title, - "mapTime": map_time, - "mapUnit": "西安市应急管理局", - "info": info_text, - "centerX": center_x, - "centerY": center_y, - "event": str(inference["id"]), - "queueId": str(inference["id"]), - "zoomRule": "11", - "zoomValue": "50", - } - -def build_qgis_config(batch_folder): - static_layers_config = {} - for name, (table, gpkg_file) in STATIC_LAYERS.items(): - static_layers_config[name] = {"file": gpkg_file, "table": table} - - return { - "db": DB_CONFIG, - "qgis": {"exportDpi": 300}, - "template_override": { - "enabled": True, - "original": { - "host": "localhost", - "port": 5432, - "dbname": "yjzyk_xian", - "schema": "base", - }, - "actual": { - "host": DB_CONFIG["host"], - "port": DB_CONFIG["port"], - "dbname": DB_CONFIG["database"], - "schema": "qgis", - "username": DB_CONFIG["username"], - "password": DB_CONFIG["password"], - }, - }, - "static_layers": { - "enabled": True, - "gpkg_dir": GPKG_DIR, - "layers": static_layers_config, - }, - "batch_folder": batch_folder, - } - -# ============================================================ -# 主逻辑 -# ============================================================ -event_type = inference["event_type"] -disaster_time = format_disaster_time(inference["occurred_time"]) -batch_folder = os.path.join("G:/files", "xian/qgis/map", disaster_time).replace("\\", "/") -os.makedirs(batch_folder, exist_ok=True) - -template_dir = os.path.join(TEMPLATE_BASE, event_type) -template_path = os.path.join(template_dir, "暴雨内涝潜在隐患点及人口分布图.qgz").replace("\\", "/") - -model = derive_model_params(inference, batch_folder, template_path) -config = build_qgis_config(batch_folder) - -print("=" * 60) -print(f"模板: {template_path}") -print(f"输出: {model['outFile']}") -print(f"标题: {model['mapTitle']}") -print(f"时间: {model['mapTime']}") -print(f"单位: {model['mapUnit']}") -print(f"info:\n{model['info']}") -print(f"中心: ({model['centerX']}, {model['centerY']})") -print(f"event: {model['event']}, queueId: {model['queueId']}") -print("=" * 60) - -request_data = json.dumps( - {"config": config, "model": model}, - ensure_ascii=False, -) - -tmp_json = tempfile.NamedTemporaryFile( - suffix=".json", delete=False, mode="w", encoding="utf-8" -) -tmp_json.write(request_data) -tmp_json.close() - -runner_script = os.path.join( - os.path.dirname(__file__), - r"F:\project\xian\xian_algorithm_new\app\services\qgis\qgis_runner.py" -).replace("\\", "/") -# Fix: use absolute path -runner_script = r"F:\project\xian\xian_algorithm_new\app\services\qgis\qgis_runner.py" - -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_test.bat") - -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("/", "\\") -python_exe = os.path.join(python_dir, "python3.exe").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_exe}" "{runner_script}" "{tmp_json.name}" -""" - -with open(bat_path, "w", encoding="utf-8") as f: - f.write(bat_content) - -cmd = ["cmd.exe", "/c", bat_path] -print(f"执行: {' '.join(cmd[:3])} ...") -print() - -try: - result = subprocess.run(cmd, capture_output=True, timeout=300) - stderr_text = result.stderr.decode("utf-8", errors="replace") - stdout_text = result.stdout.decode("utf-8", errors="replace") - - print("=== stderr ===") - for line in stderr_text.split("\n"): - print(f" {line}") - print() - print("=== stdout ===") - print(stdout_text[:500] if stdout_text else "(empty)") - - if result.returncode != 0: - print(f"\n!!! FAIL: exit={result.returncode}") - else: - print(f"\n=== SUCCESS ===") - if os.path.isfile(model["outFile"]): - print(f"文件: {model['outFile']} ({os.path.getsize(model['outFile'])/1024:.1f} KB)") - else: - print(f"文件不存在: {model['outFile']}") -finally: - try: - os.remove(tmp_json.name) - except OSError: - pass