#!/usr/bin/env python3 """ Export the museum guide/explain static data package from Dameng DM8. The script reads the backend datasource config, connects read-only through ODBC, and writes JSON files consumed by frontend-miniapp. Passwords are only kept in memory and are never printed. """ from __future__ import annotations import argparse import datetime as dt import decimal import json import math import re from collections import Counter, defaultdict from pathlib import Path from typing import Any import pyodbc import yaml PROJECT_ROOT = Path(__file__).resolve().parents[1] DEFAULT_CONFIG = ( PROJECT_ROOT.parent.parent.parent / "智慧导览" / "smart-navigation-system" / "yudao-server" / "src" / "main" / "resources" / "application-local.yaml" ) DEFAULT_OUTPUT = PROJECT_ROOT / "static" / "guide-data" DEFAULT_NAV_ASSETS = ( PROJECT_ROOT / "static" / "nav-assets" / "codex_nav_20260621_175310_museum_default_centerline_finalxy" ) SCHEMA_VERSION = "sgs-guide-static/v1" BRIDGE_SCHEMA_VERSION = "sgs-guide-poi-bridge/v1" TABLES = { "SGS_EXHIBITION_HALL": "halls", "SGS_EXHIBIT_OUTLINE": "outlines", "SGS_GUIDE_STOP": "guide-stops", "SGS_EXHIBIT_ITEM": "exhibits", "SGS_GUIDE_CONTENT": "guide-contents", "SGS_POI": "pois", } FLOOR_Z_LEVELS = [ ("L-2", -13.447996), ("L-1", -8.941772), ("L1", 0.258651), ("L1.5", 5.329), ("L2", 10.706019), ("L3", 16.540436), ("L4", 22.92407), ("L5", 27.164251), ] def snake_to_camel(value: str) -> str: lowered = value.lower() parts = lowered.split("_") return parts[0] + "".join(part.capitalize() for part in parts[1:]) def normalize_bridge_name(value: Any) -> str: if value is None: return "" text = str(value).lower().replace("(", "(").replace(")", ")") text = re.sub(r"^(l-?\d(?:\.5)?[_\s-]*)+", "", text, flags=re.I) text = re.sub(r"展厅\s*_?\s*\d+", "", text) text = re.sub(r"展厅\s*\d+", "", text) text = re.sub(r"(?<=卫生间)0+(?=\d)", "", text) return re.sub(r"[\s_\-·、,,。::;;/\\()()\[\]【】\"“”']+", "", text) def floor_from_name(value: Any) -> str | None: if value is None: return None match = re.match(r"^(L-?\d+(?:\.5)?)_", str(value), re.I) return match.group(1).upper() if match else None def floor_from_z(value: Any) -> str | None: if value is None: return None try: z_value = float(value) except (TypeError, ValueError): return None return min(FLOOR_Z_LEVELS, key=lambda item: abs(item[1] - z_value))[0] def distance_sgs_to_nav(source: dict[str, Any], nav_poi: dict[str, Any]) -> float | None: position = nav_poi.get("positionGltf") if ( not isinstance(position, list) or len(position) < 3 or source.get("x") is None or source.get("y") is None or source.get("z") is None ): return None try: # SGS POI coordinates align to current nav positionGltf as [x, z, y]. return math.dist( [float(source["x"]), float(source["z"]), float(source["y"])], [float(position[0]), float(position[1]), float(position[2])], ) except (TypeError, ValueError): return None def bridge_entry( nav_poi: dict[str, Any], *, method: str, confidence: str, distance_meters: float | None = None, source_poi: dict[str, Any] | None = None, ) -> dict[str, Any]: entry = { "navPoiId": nav_poi.get("id"), "navPoiName": nav_poi.get("name"), "navFloorId": nav_poi.get("floorId"), "method": method, "confidence": confidence, } if distance_meters is not None: entry["distanceMeters"] = round(distance_meters, 6) if source_poi: entry["sourcePoiId"] = source_poi.get("id") entry["sourcePoiName"] = source_poi.get("name") return entry def is_id_column(column: str) -> bool: upper_column = column.upper() return upper_column == "ID" or upper_column.endswith("_ID") def json_value(column: str, value: Any) -> Any: if value is not None and is_id_column(column): if isinstance(value, decimal.Decimal) and value == value.to_integral_value(): return str(int(value)) if isinstance(value, int): return str(value) return str(value) if isinstance(value, (dt.datetime, dt.date, dt.time)): return value.isoformat() if isinstance(value, decimal.Decimal): if value == value.to_integral_value(): return int(value) return float(value) if isinstance(value, bytes): return value.hex() return value def normalize_record(columns: list[str], row: pyodbc.Row) -> dict[str, Any]: return { snake_to_camel(column): json_value(column, value) for column, value in zip(columns, row) } def read_master_datasource(config_path: Path) -> dict[str, str]: documents = yaml.safe_load_all(config_path.read_text(encoding="utf-8")) for document in documents: try: return document["spring"]["datasource"]["dynamic"]["datasource"]["master"] except Exception: continue raise RuntimeError(f"master datasource not found in {config_path}") def parse_dm_jdbc_url(url: str) -> tuple[str, str, str]: host_match = re.search(r"jdbc:dm://([^:/?]+)", url) port_match = re.search(r"jdbc:dm://[^:/?]+:(\d+)", url) schema_match = re.search(r"[?&]schema=([^&]+)", url) if not host_match or not port_match or not schema_match: raise RuntimeError(f"unsupported DM JDBC url: {url}") return host_match.group(1), port_match.group(1), schema_match.group(1).upper() def connect(config_path: Path) -> tuple[pyodbc.Connection, str, str, str]: master = read_master_datasource(config_path) host, port, schema = parse_dm_jdbc_url(str(master["url"])) username = str(master["username"]) password = str(master["password"]) connection_string = ( "DRIVER={DM8 ODBC DRIVER};" f"SERVER={host};" f"TCP_PORT={port};" f"UID={username};" f"PWD={password};" ) connection = pyodbc.connect(connection_string, timeout=15, autocommit=True) return connection, host, port, schema def fetch_table(connection: pyodbc.Connection, schema: str, table: str) -> list[dict[str, Any]]: cursor = connection.cursor() cursor.execute(f'SELECT * FROM "{schema}"."{table}" ORDER BY "ID"') columns = [description[0] for description in cursor.description] return [normalize_record(columns, row) for row in cursor.fetchall()] def append_index(index: dict[str, list[Any]], key: Any, value: Any) -> None: if key is None: return index[str(key)].append(value) def build_outline_hall_resolver(data: dict[str, list[dict[str, Any]]]): hall_ids = {str(hall.get("id")) for hall in data["halls"] if hall.get("id") is not None} outline_by_id = { str(outline.get("id")): outline for outline in data["outlines"] if outline.get("id") is not None } resolved_cache: dict[str, str | None] = {} def resolve(outline_id: Any, visiting: set[str] | None = None) -> str | None: if outline_id is None: return None key = str(outline_id) if key in resolved_cache: return resolved_cache[key] visiting = visiting or set() if key in visiting: resolved_cache[key] = None return None outline = outline_by_id.get(key) parent_id = outline.get("parentId") if outline else key if parent_id is None: resolved_cache[key] = None return None parent_key = str(parent_id) if parent_key in hall_ids: resolved_cache[key] = parent_key return parent_key visiting.add(key) resolved_cache[key] = resolve(parent_key, visiting) visiting.remove(key) return resolved_cache[key] return resolve def build_indexes(data: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: outlines_by_hall_id: dict[str, list[Any]] = defaultdict(list) stops_by_outline_id: dict[str, list[Any]] = defaultdict(list) exhibits_by_hall_id: dict[str, list[Any]] = defaultdict(list) exhibits_by_outline_id: dict[str, list[Any]] = defaultdict(list) exhibits_by_stop_id: dict[str, list[Any]] = defaultdict(list) guides_by_exhibit_id: dict[str, list[Any]] = defaultdict(list) guides_by_target: dict[str, list[Any]] = defaultdict(list) resolve_outline_hall_id = build_outline_hall_resolver(data) for outline in data["outlines"]: append_index(outlines_by_hall_id, resolve_outline_hall_id(outline.get("id")), outline.get("id")) for stop in data["guide-stops"]: append_index(stops_by_outline_id, stop.get("outlineId"), stop.get("id")) for exhibit in data["exhibits"]: exhibit_id = exhibit.get("id") append_index(exhibits_by_hall_id, exhibit.get("hallId"), exhibit_id) append_index(exhibits_by_outline_id, exhibit.get("outlineId"), exhibit_id) append_index(exhibits_by_stop_id, exhibit.get("stopId"), exhibit_id) for guide in data["guide-contents"]: guide_id = guide.get("id") target_type = guide.get("targetType") target_id = guide.get("targetId") if guide.get("exhibitId") is not None: append_index(guides_by_exhibit_id, guide.get("exhibitId"), guide_id) if target_type and target_id is not None: append_index(guides_by_target, f"{target_type}:{target_id}", guide_id) if str(target_type).upper() == "ITEM": append_index(guides_by_exhibit_id, target_id, guide_id) return { "schemaVersion": SCHEMA_VERSION, "outlinesByHallId": dict(outlines_by_hall_id), "stopsByOutlineId": dict(stops_by_outline_id), "exhibitsByHallId": dict(exhibits_by_hall_id), "exhibitsByOutlineId": dict(exhibits_by_outline_id), "exhibitsByStopId": dict(exhibits_by_stop_id), "guidesByExhibitId": dict(guides_by_exhibit_id), "guidesByTarget": dict(guides_by_target), "poiById": { str(poi.get("id")): poi for poi in data["pois"] if poi.get("id") is not None }, } def load_nav_pois(nav_assets_dir: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]: manifest_path = nav_assets_dir / "app_nav_manifest.json" poi_path = nav_assets_dir / "data" / "poi_all.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) poi_payload = json.loads(poi_path.read_text(encoding="utf-8")) return manifest, poi_payload.get("pois", []) def infer_sgs_floor_id_map(sgs_pois: list[dict[str, Any]]) -> dict[str, str]: votes: dict[str, Counter[str]] = defaultdict(Counter) for poi in sgs_pois: floor_id = poi.get("floorId") if floor_id is None: continue inferred_floor = floor_from_name(poi.get("name")) or floor_from_z(poi.get("z")) if inferred_floor: votes[str(floor_id)][inferred_floor] += 1 return { floor_id: counter.most_common(1)[0][0] for floor_id, counter in votes.items() if counter } def resolve_sgs_poi_floor(poi: dict[str, Any], floor_id_map: dict[str, str]) -> str | None: return ( floor_from_name(poi.get("name")) or floor_id_map.get(str(poi.get("floorId"))) or floor_from_z(poi.get("z")) ) def build_hall_bridge( halls: list[dict[str, Any]], nav_pois: list[dict[str, Any]], ) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} touring_pois = [ poi for poi in nav_pois if poi.get("primaryCategory") == "touring_poi" ] for hall in halls: hall_id = hall.get("id") hall_name = normalize_bridge_name(hall.get("name")) hall_floor = hall.get("floorCode") if hall_id is None or not hall_name: continue candidates = [] for nav_poi in touring_pois: nav_name = normalize_bridge_name(nav_poi.get("name")) if hall_name and (hall_name == nav_name or hall_name in nav_name or nav_name in hall_name): candidates.append(nav_poi) same_floor = [poi for poi in candidates if poi.get("floorId") == hall_floor] selected = (same_floor or candidates)[0] if candidates else None if selected: result[str(hall_id)] = bridge_entry( selected, method="hall_name_floor" if same_floor else "hall_name", confidence="high" if same_floor else "medium", ) return result def build_sgs_poi_bridge( sgs_pois: list[dict[str, Any]], nav_pois: list[dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, str]]: floor_id_map = infer_sgs_floor_id_map(sgs_pois) nav_by_key: dict[tuple[str | None, str], list[dict[str, Any]]] = defaultdict(list) for nav_poi in nav_pois: nav_by_key[(nav_poi.get("floorId"), normalize_bridge_name(nav_poi.get("name")))].append(nav_poi) confirmed: dict[str, dict[str, Any]] = {} candidates: dict[str, dict[str, Any]] = {} for source_poi in sgs_pois: source_poi_id = source_poi.get("id") if source_poi_id is None: continue floor_id = resolve_sgs_poi_floor(source_poi, floor_id_map) key = (floor_id, normalize_bridge_name(source_poi.get("name"))) nav_candidates = nav_by_key.get(key, []) if not nav_candidates: continue scored = [ (distance_sgs_to_nav(source_poi, nav_poi), nav_poi) for nav_poi in nav_candidates ] with_distance = [item for item in scored if item[0] is not None] if with_distance: distance, selected = min(with_distance, key=lambda item: item[0] or 0) if distance is not None and distance <= 5: confirmed[str(source_poi_id)] = bridge_entry( selected, method="name_floor_coordinate", confidence="high", distance_meters=distance, source_poi=source_poi, ) else: candidates[str(source_poi_id)] = bridge_entry( selected, method="name_floor_coordinate_candidate", confidence="low", distance_meters=distance, source_poi=source_poi, ) continue if len(nav_candidates) == 1: confirmed[str(source_poi_id)] = bridge_entry( nav_candidates[0], method="name_floor", confidence="medium", source_poi=source_poi, ) else: candidates[str(source_poi_id)] = bridge_entry( nav_candidates[0], method="name_floor_ambiguous", confidence="low", source_poi=source_poi, ) return confirmed, candidates, floor_id_map def build_poi_bridge( data: dict[str, list[dict[str, Any]]], nav_assets_dir: Path, ) -> dict[str, Any]: nav_manifest, nav_pois = load_nav_pois(nav_assets_dir) hall_bridge = build_hall_bridge(data["halls"], nav_pois) sgs_bridge, sgs_candidates, floor_id_map = build_sgs_poi_bridge(data["pois"], nav_pois) return { "schemaVersion": BRIDGE_SCHEMA_VERSION, "generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), "source": { "guideStaticSchemaVersion": SCHEMA_VERSION, "navAssetRunId": nav_manifest.get("runId"), "navAssetPath": str(nav_assets_dir), }, "stats": { "hallBridgeCount": len(hall_bridge), "sgsPoiBridgeCount": len(sgs_bridge), "sgsPoiCandidateCount": len(sgs_candidates), "navPoiCount": len(nav_pois), }, "sgsFloorIdToNavFloorId": floor_id_map, "hallToNavPoiId": hall_bridge, "sgsPoiToNavPoiId": sgs_bridge, "sgsPoiCandidates": sgs_candidates, } def write_json(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def export_package(config_path: Path, output_dir: Path, nav_assets_dir: Path) -> dict[str, Any]: connection, host, port, schema = connect(config_path) try: output_dir.mkdir(parents=True, exist_ok=True) data: dict[str, list[dict[str, Any]]] = {} for table, file_stem in TABLES.items(): rows = fetch_table(connection, schema, table) data[file_stem] = rows write_json( output_dir / f"{file_stem}.json", { "schemaVersion": SCHEMA_VERSION, "sourceTable": table, "rowCount": len(rows), "rows": rows, }, ) indexes = build_indexes(data) write_json(output_dir / "indexes.json", indexes) poi_bridge = build_poi_bridge(data, nav_assets_dir) write_json(output_dir / "poi-bridge.json", poi_bridge) manifest = { "schemaVersion": SCHEMA_VERSION, "generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), "source": { "type": "dameng", "host": host, "port": port, "schema": schema, "configPath": str(config_path), }, "files": { "halls": "halls.json", "outlines": "outlines.json", "guideStops": "guide-stops.json", "exhibits": "exhibits.json", "guideContents": "guide-contents.json", "pois": "pois.json", "indexes": "indexes.json", "poiBridge": "poi-bridge.json", }, "counts": {key: len(value) for key, value in data.items()}, "bridgeStats": poi_bridge["stats"], } write_json(output_dir / "manifest.json", manifest) return manifest finally: connection.close() def main() -> None: parser = argparse.ArgumentParser(description="Export frontend guide static data from DM8.") parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument("--nav-assets", type=Path, default=DEFAULT_NAV_ASSETS) args = parser.parse_args() manifest = export_package(args.config.resolve(), args.output.resolve(), args.nav_assets.resolve()) print(f"Exported {manifest['schemaVersion']} to {args.output.resolve()}") print("Counts:", json.dumps(manifest["counts"], ensure_ascii=False, sort_keys=True)) print("Bridge:", json.dumps(manifest["bridgeStats"], ensure_ascii=False, sort_keys=True)) if __name__ == "__main__": main()