- 刷新讲解静态数据包并保留完整 outline 树 - 按后台顶层业务单元口径归并讲解点统计 - 更新讲解静态数据刷新操作文档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
562 lines
19 KiB
Python
562 lines
19 KiB
Python
#!/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():
|
||
data[file_stem] = fetch_table(connection, schema, table)
|
||
|
||
for table, file_stem in TABLES.items():
|
||
rows = data[file_stem]
|
||
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()
|