1271 lines
43 KiB
Python
1271 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CM93 -> MySQL Normalization 实施前只读预检
|
||
Version: v11.0
|
||
|
||
目标
|
||
====
|
||
V11 不再依赖把 V10.1 大包上传给 ChatGPT。
|
||
|
||
它在用户本机完成两个门控:
|
||
|
||
Gate A:V10.1 canonical validation 必须 PASS
|
||
------------------------------------------------
|
||
从以下任一输入读取:
|
||
- V10.1 完整输出目录
|
||
- V10.1 完整 ZIP
|
||
- v10.2 digest ZIP
|
||
|
||
要求至少能确认:
|
||
- overall_pass = true
|
||
- invalid_polygon_records = 55
|
||
- golden_vector_count = 55
|
||
- 55 条 golden 全 PASS
|
||
- valid_policy_changed_count = 0
|
||
- valid_hash_changed_count = 0
|
||
- input variant / face order / repeat / topology failures = 0
|
||
|
||
若不满足,V11 立即停止,不继续。
|
||
|
||
Gate B:当前 MySQL 数据必须仍与 V10.1 golden vectors 一致
|
||
-----------------------------------------------------------
|
||
V11 会重新从数据库读取 52 invalid Area + 3 invalid Coverage,
|
||
使用已经冻结的规则:
|
||
|
||
ODD_EVEN_FACE_SELECTION
|
||
+
|
||
GEOS_NORMALIZE
|
||
|
||
重新生成 candidate,然后逐条比较:
|
||
- canonical WKB SHA-256
|
||
- geometry type
|
||
- polygon part count
|
||
- candidate bbox
|
||
- candidate area
|
||
|
||
必须与 V10.1 golden vector 一致。
|
||
|
||
之后做实施前 schema 检查:
|
||
- chart_feature
|
||
- chart_area_geometry
|
||
- chart_coverage
|
||
- chart_import_job
|
||
|
||
检查:
|
||
- geometry 字段真实 SQL 类型
|
||
- 是否能存 MultiPolygon
|
||
- SRID / SRS 约束
|
||
- 主键、唯一键、外键、索引
|
||
- bbox 字段
|
||
- geometry_issue / geometry_repaired 字段
|
||
- import job geometry counters
|
||
- 4406 raw_geotype=16 当前状态
|
||
- 55 OGC-invalid 当前状态
|
||
- 未来若 normalized geometry 替换原 geometry,需要同步修改哪些 metadata
|
||
|
||
输出:
|
||
- implementation_precheck_summary_v11.txt
|
||
- implementation_precheck_report_v11.json
|
||
- golden_revalidation_v11.csv
|
||
- schema_columns_v11.csv
|
||
- schema_indexes_v11.csv
|
||
- schema_foreign_keys_v11.csv
|
||
- show_create_tables_v11.txt
|
||
- normalization_metadata_impact_v11.csv
|
||
- proposed_implementation_contract_v11.md
|
||
- proposed_migration_plan_v11.sql.txt
|
||
|
||
注意:
|
||
proposed_migration_plan_v11.sql.txt 只是文本计划。
|
||
V11 绝不会执行这些 SQL。
|
||
|
||
禁止:
|
||
- 不 UPDATE
|
||
- 不 INSERT
|
||
- 不 DELETE
|
||
- 不 ALTER TABLE
|
||
- 不修改 importer
|
||
- 不写 normalized geometry
|
||
- 不修 4406
|
||
- 不 PBF
|
||
|
||
依赖:
|
||
pip install pymysql shapely
|
||
|
||
执行例 1:直接使用 V10.1 输出目录
|
||
python cm93_normalization_implementation_precheck_v11.py ^
|
||
--v10-input "D:\\React\\cm93-mysql-importer-starter\\cm93_normalization_canonical_v10_1_dataset_1_20260831_215602" ^
|
||
--host 127.0.0.1 ^
|
||
--port 3306 ^
|
||
--user root ^
|
||
--database cm93_chart ^
|
||
--dataset-id 1
|
||
|
||
执行例 2:使用 v10.2 digest ZIP
|
||
python cm93_normalization_implementation_precheck_v11.py ^
|
||
--v10-input "D:\\...\\cm93_v10_1_result_digest_v10_2.zip" ^
|
||
--host 127.0.0.1 ^
|
||
--port 3306 ^
|
||
--user root ^
|
||
--database cm93_chart ^
|
||
--dataset-id 1
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import getpass
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import zipfile
|
||
from collections import Counter
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||
|
||
try:
|
||
import pymysql
|
||
from pymysql.cursors import DictCursor
|
||
except ImportError:
|
||
print("缺少 pymysql:pip install pymysql shapely")
|
||
sys.exit(2)
|
||
|
||
try:
|
||
import shapely
|
||
|
||
try:
|
||
from shapely import from_wkb as _from_wkb
|
||
def load_wkb(data: bytes):
|
||
return _from_wkb(data)
|
||
except ImportError:
|
||
from shapely import wkb as _wkb
|
||
def load_wkb(data: bytes):
|
||
return _wkb.loads(data)
|
||
|
||
try:
|
||
from shapely import to_wkb as _to_wkb
|
||
def dump_wkb(g) -> bytes:
|
||
return bytes(_to_wkb(g, hex=False, byte_order=1))
|
||
except ImportError:
|
||
from shapely import wkb as _wkb2
|
||
def dump_wkb(g) -> bytes:
|
||
return _wkb2.dumps(g, hex=False, big_endian=False)
|
||
|
||
try:
|
||
from shapely import normalize as _normalize
|
||
def canonicalize(g):
|
||
return _normalize(g)
|
||
except ImportError:
|
||
raise ImportError("V11 需要 Shapely 2.x 的 shapely.normalize()")
|
||
|
||
from shapely.ops import unary_union, polygonize_full
|
||
|
||
except ImportError as e:
|
||
print(f"Shapely 初始化失败:{e}")
|
||
print("请执行:pip install -U shapely")
|
||
sys.exit(2)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# General helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def scalar(cur, sql: str, params: Sequence[Any] = ()) -> Any:
|
||
cur.execute(sql, params)
|
||
row = cur.fetchone()
|
||
return next(iter(row.values())) if row else None
|
||
|
||
|
||
def fetch_all(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
|
||
cur.execute(sql, params)
|
||
return list(cur.fetchall())
|
||
|
||
|
||
def begin_read_only(cur, warnings: List[str]):
|
||
try:
|
||
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||
except Exception as e:
|
||
warnings.append(f"SET SESSION TRANSACTION READ ONLY failed: {e}")
|
||
|
||
try:
|
||
cur.execute("START TRANSACTION READ ONLY")
|
||
except Exception as e:
|
||
warnings.append(f"START TRANSACTION READ ONLY failed: {e}")
|
||
cur.execute("START TRANSACTION")
|
||
|
||
|
||
def to_bytes(v: Any) -> Optional[bytes]:
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, bytes):
|
||
return v
|
||
if isinstance(v, bytearray):
|
||
return bytes(v)
|
||
if isinstance(v, memoryview):
|
||
return v.tobytes()
|
||
try:
|
||
return bytes(v)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def write_csv(
|
||
path: Path,
|
||
rows: Iterable[Dict[str, Any]],
|
||
fieldnames: Optional[List[str]] = None,
|
||
):
|
||
rows = list(rows)
|
||
|
||
if fieldnames is None:
|
||
seen = set()
|
||
fieldnames = []
|
||
for row in rows:
|
||
for k in row.keys():
|
||
if k not in seen:
|
||
seen.add(k)
|
||
fieldnames.append(k)
|
||
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||
if not fieldnames:
|
||
return
|
||
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||
w.writeheader()
|
||
for row in rows:
|
||
out = {}
|
||
for k in fieldnames:
|
||
v = row.get(k)
|
||
if isinstance(v, (dict, list, tuple)):
|
||
v = json.dumps(v, ensure_ascii=False, default=str)
|
||
out[k] = "" if v is None else v
|
||
w.writerow(out)
|
||
|
||
|
||
def intv(v: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(v)
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def boolv(v: Any) -> bool:
|
||
if isinstance(v, bool):
|
||
return v
|
||
if v is None:
|
||
return False
|
||
return str(v).strip().lower() in {"1", "true", "yes", "y", "pass"}
|
||
|
||
|
||
def floatv(v: Any, default: Optional[float] = None) -> Optional[float]:
|
||
try:
|
||
x = float(v)
|
||
return x if math.isfinite(x) else default
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def geometry_hash(g) -> Optional[str]:
|
||
if g is None:
|
||
return None
|
||
try:
|
||
return hashlib.sha256(dump_wkb(g)).hexdigest()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def flatten_components(g) -> Dict[str, List[Any]]:
|
||
result = {"polygons": [], "lines": [], "points": [], "others": []}
|
||
|
||
def rec(x):
|
||
if x is None:
|
||
return
|
||
t = x.geom_type
|
||
if t == "Polygon":
|
||
result["polygons"].append(x)
|
||
elif t == "MultiPolygon":
|
||
for y in x.geoms:
|
||
rec(y)
|
||
elif t in ("LineString", "LinearRing"):
|
||
result["lines"].append(x)
|
||
elif t == "MultiLineString":
|
||
for y in x.geoms:
|
||
rec(y)
|
||
elif t == "Point":
|
||
result["points"].append(x)
|
||
elif t == "MultiPoint":
|
||
for y in x.geoms:
|
||
rec(y)
|
||
elif hasattr(x, "geoms"):
|
||
for y in x.geoms:
|
||
rec(y)
|
||
else:
|
||
result["others"].append(x)
|
||
|
||
rec(g)
|
||
return result
|
||
|
||
|
||
def polygon_part_count(g) -> int:
|
||
if g is None:
|
||
return 0
|
||
if g.geom_type == "Polygon":
|
||
return 1
|
||
if g.geom_type == "MultiPolygon":
|
||
return len(g.geoms)
|
||
return len(flatten_components(g)["polygons"])
|
||
|
||
|
||
def probe_wkb_fn(cur) -> str:
|
||
for fn in ("ST_AsBinary", "ST_AsWKB", "AsBinary", "AsWKB"):
|
||
try:
|
||
cur.execute(f"SELECT {fn}(geom) AS x FROM chart_area_geometry LIMIT 1")
|
||
cur.fetchone()
|
||
return fn
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError("找不到 geometry -> WKB 函数")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V10 input / gate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def find_file(root: Path, filename: str) -> Optional[Path]:
|
||
direct = root / filename
|
||
if direct.exists():
|
||
return direct
|
||
hits = list(root.rglob(filename))
|
||
return hits[0] if hits else None
|
||
|
||
|
||
def read_csv(path: Path) -> List[Dict[str, str]]:
|
||
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
||
return list(csv.DictReader(f))
|
||
|
||
|
||
def open_v10_input(src: Path) -> Tuple[Path, Optional[Path]]:
|
||
if src.is_dir():
|
||
return src, None
|
||
|
||
if src.is_file() and src.suffix.lower() == ".zip":
|
||
temp = Path(tempfile.mkdtemp(prefix="cm93_v11_v10_input_"))
|
||
with zipfile.ZipFile(src, "r") as z:
|
||
z.extractall(temp)
|
||
return temp, temp
|
||
|
||
raise ValueError("v10-input 必须是目录或 ZIP")
|
||
|
||
|
||
def load_v10_gate(root: Path) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]:
|
||
digest_json = find_file(root, "V10_1_结果摘要_v10_2.json")
|
||
report_json = find_file(root, "normalization_canonical_report_v10_1.json")
|
||
golden_csv = find_file(root, "normalization_golden_vectors_v10_1.csv")
|
||
|
||
gate: Dict[str, Any] = {
|
||
"source": None,
|
||
"overall_pass": False,
|
||
"invalid_polygon_records": None,
|
||
"valid_policy_changed_count": None,
|
||
"valid_hash_changed_count": None,
|
||
"golden_vector_count": None,
|
||
"golden_pass_count": None,
|
||
"golden_fail_count": None,
|
||
"face_order_hash_mismatch_count": None,
|
||
"canonical_topology_failure_count": None,
|
||
"repeat_hash_failure_count": None,
|
||
"input_variant_failure_record_count": None,
|
||
"errors": [],
|
||
}
|
||
|
||
golden_map: Dict[str, Dict[str, Any]] = {}
|
||
|
||
if digest_json:
|
||
digest = json.loads(digest_json.read_text(encoding="utf-8"))
|
||
gate["source"] = "v10.2-digest"
|
||
gate["digest_overall"] = digest.get("overall")
|
||
if digest.get("overall") == "PASS":
|
||
gate["overall_pass"] = True
|
||
checks = digest.get("checks", {})
|
||
rc = checks.get("report", {})
|
||
gc = checks.get("golden", {})
|
||
gate["invalid_polygon_records"] = rc.get("invalid_polygon_records")
|
||
gate["valid_policy_changed_count"] = rc.get("valid_policy_changed_count")
|
||
gate["valid_hash_changed_count"] = rc.get("valid_hash_changed_count")
|
||
gate["golden_vector_count"] = rc.get("golden_vector_count")
|
||
gate["golden_pass_count"] = (gc.get("status_counts") or {}).get("PASS")
|
||
gate["golden_fail_count"] = (gc.get("status_counts") or {}).get("FAIL", 0)
|
||
gate["face_order_hash_mismatch_count"] = rc.get("face_order_hash_mismatch_count")
|
||
gate["canonical_topology_failure_count"] = rc.get("canonical_topology_failure_count")
|
||
gate["repeat_hash_failure_count"] = rc.get("repeat_hash_failure_count")
|
||
gate["input_variant_failure_record_count"] = rc.get("input_variant_failure_record_count")
|
||
|
||
if report_json:
|
||
report = json.loads(report_json.read_text(encoding="utf-8"))
|
||
s = report.get("summary", {})
|
||
gate["source"] = (
|
||
gate["source"] + "+v10.1-report"
|
||
if gate["source"]
|
||
else "v10.1-report"
|
||
)
|
||
gate["overall_pass"] = boolv(s.get("overall_pass"))
|
||
gate["invalid_polygon_records"] = s.get("invalid_polygon_records")
|
||
gate["valid_policy_changed_count"] = s.get("valid_policy_changed_count")
|
||
gate["valid_hash_changed_count"] = s.get("valid_hash_changed_count")
|
||
gate["golden_vector_count"] = s.get("golden_vector_count")
|
||
gate["golden_pass_count"] = (s.get("golden_record_status_counts") or {}).get("PASS")
|
||
gate["golden_fail_count"] = (s.get("golden_record_status_counts") or {}).get("FAIL", 0)
|
||
gate["face_order_hash_mismatch_count"] = s.get("face_order_hash_mismatch_count")
|
||
gate["canonical_topology_failure_count"] = s.get("canonical_topology_failure_count")
|
||
gate["repeat_hash_failure_count"] = s.get("repeat_hash_failure_count")
|
||
gate["input_variant_failure_record_count"] = s.get("input_variant_failure_record_count")
|
||
|
||
if golden_csv:
|
||
rows = read_csv(golden_csv)
|
||
for r in rows:
|
||
kind = r.get("kind")
|
||
if kind == "AREA":
|
||
key = f"AREA:{r.get('feature_id')}"
|
||
else:
|
||
key = f"COVERAGE:{r.get('coverage_geom_id')}"
|
||
golden_map[key] = r
|
||
|
||
statuses = Counter(str(r.get("record_status", "")).strip() for r in rows)
|
||
gate["golden_vector_count"] = len(rows)
|
||
gate["golden_pass_count"] = statuses.get("PASS", 0)
|
||
gate["golden_fail_count"] = statuses.get("FAIL", 0)
|
||
|
||
pass_conditions = [
|
||
gate["overall_pass"] is True,
|
||
intv(gate["invalid_polygon_records"]) == 55,
|
||
intv(gate["valid_policy_changed_count"]) == 0,
|
||
intv(gate["valid_hash_changed_count"]) == 0,
|
||
intv(gate["golden_vector_count"]) == 55,
|
||
intv(gate["golden_pass_count"]) == 55,
|
||
intv(gate["golden_fail_count"]) == 0,
|
||
intv(gate["face_order_hash_mismatch_count"]) == 0,
|
||
intv(gate["canonical_topology_failure_count"]) == 0,
|
||
intv(gate["repeat_hash_failure_count"]) == 0,
|
||
intv(gate["input_variant_failure_record_count"]) == 0,
|
||
]
|
||
|
||
gate["pass_conditions"] = pass_conditions
|
||
gate["gate_pass"] = all(pass_conditions)
|
||
|
||
if len(golden_map) != 55:
|
||
gate["errors"].append(
|
||
f"Golden vector 明细不足:{len(golden_map)} / 55。"
|
||
)
|
||
gate["gate_pass"] = False
|
||
|
||
return gate, golden_map
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Odd-even normalization
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def original_rings(geom) -> List[List[Tuple[float, float]]]:
|
||
rings = []
|
||
|
||
def add_poly(poly):
|
||
rings.append([(float(c[0]), float(c[1])) for c in poly.exterior.coords])
|
||
for ring in poly.interiors:
|
||
rings.append([(float(c[0]), float(c[1])) for c in ring.coords])
|
||
|
||
if geom.geom_type == "Polygon":
|
||
add_poly(geom)
|
||
elif geom.geom_type == "MultiPolygon":
|
||
for p in geom.geoms:
|
||
add_poly(p)
|
||
|
||
return rings
|
||
|
||
|
||
def point_on_segment(px, py, ax, ay, bx, by, eps=1e-12) -> bool:
|
||
vx, vy = bx - ax, by - ay
|
||
wx, wy = px - ax, py - ay
|
||
c = abs(vx * wy - vy * wx)
|
||
if c > eps * max(1.0, abs(vx), abs(vy)):
|
||
return False
|
||
d = wx * vx + wy * vy
|
||
if d < -eps:
|
||
return False
|
||
vv = vx * vx + vy * vy
|
||
if d - vv > eps:
|
||
return False
|
||
return True
|
||
|
||
|
||
def even_odd_inside(
|
||
point: Tuple[float, float],
|
||
rings: List[List[Tuple[float, float]]]
|
||
) -> bool:
|
||
x, y = point
|
||
crossings = 0
|
||
|
||
for ring in rings:
|
||
for i in range(len(ring) - 1):
|
||
x1, y1 = ring[i]
|
||
x2, y2 = ring[i + 1]
|
||
|
||
if point_on_segment(x, y, x1, y1, x2, y2):
|
||
return True
|
||
|
||
if (y1 > y) != (y2 > y):
|
||
xinters = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
|
||
if xinters > x:
|
||
crossings += 1
|
||
|
||
return crossings % 2 == 1
|
||
|
||
|
||
def normalize_invalid(geom):
|
||
noded = unary_union(geom.boundary)
|
||
polygons_geom, cuts, dangles, invalid_rings = polygonize_full(noded)
|
||
faces = flatten_components(polygons_geom)["polygons"]
|
||
rings = original_rings(geom)
|
||
|
||
selected = []
|
||
for face in faces:
|
||
rp = face.representative_point()
|
||
if even_odd_inside((float(rp.x), float(rp.y)), rings):
|
||
selected.append(face)
|
||
|
||
if not selected:
|
||
return None
|
||
|
||
return canonicalize(unary_union(selected))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB loaders / schema
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_invalid_db_records(cur, dataset_id: int, wkb_fn: str) -> List[Dict[str, Any]]:
|
||
out = []
|
||
|
||
cur.execute(f"""
|
||
SELECT
|
||
'AREA' AS kind,
|
||
f.id AS feature_id,
|
||
NULL AS coverage_geom_id,
|
||
f.object_class,
|
||
f.cell_id,
|
||
c.cell_name,
|
||
f.source_record_index,
|
||
f.coverage_id,
|
||
f.raw_geotype,
|
||
f.bbox_min_lon,
|
||
f.bbox_min_lat,
|
||
f.bbox_max_lon,
|
||
f.bbox_max_lat,
|
||
{wkb_fn}(g.geom) AS geom_wkb
|
||
FROM chart_feature f
|
||
JOIN chart_area_geometry g ON g.feature_id=f.id
|
||
JOIN chart_cell c ON c.id=f.cell_id
|
||
WHERE f.dataset_id=%s AND f.geometry_type='AREA'
|
||
ORDER BY f.id
|
||
""", (dataset_id,))
|
||
|
||
for r in cur:
|
||
try:
|
||
geom = load_wkb(to_bytes(r["geom_wkb"]))
|
||
if not geom.is_valid:
|
||
x = dict(r)
|
||
x["_geom"] = geom
|
||
out.append(x)
|
||
except Exception:
|
||
continue
|
||
|
||
cur.execute(f"""
|
||
SELECT
|
||
'COVERAGE' AS kind,
|
||
NULL AS feature_id,
|
||
v.id AS coverage_geom_id,
|
||
'COVERAGE' AS object_class,
|
||
v.cell_id,
|
||
c.cell_name,
|
||
v.source_record_index,
|
||
NULL AS coverage_id,
|
||
NULL AS raw_geotype,
|
||
NULL AS bbox_min_lon,
|
||
NULL AS bbox_min_lat,
|
||
NULL AS bbox_max_lon,
|
||
NULL AS bbox_max_lat,
|
||
{wkb_fn}(v.geom) AS geom_wkb
|
||
FROM chart_coverage v
|
||
JOIN chart_cell c ON c.id=v.cell_id
|
||
WHERE v.dataset_id=%s
|
||
ORDER BY v.id
|
||
""", (dataset_id,))
|
||
|
||
for r in cur:
|
||
try:
|
||
geom = load_wkb(to_bytes(r["geom_wkb"]))
|
||
if not geom.is_valid:
|
||
x = dict(r)
|
||
x["_geom"] = geom
|
||
out.append(x)
|
||
except Exception:
|
||
continue
|
||
|
||
return out
|
||
|
||
|
||
def schema_columns(cur, database: str, tables: Sequence[str]) -> List[Dict[str, Any]]:
|
||
ph = ",".join(["%s"] * len(tables))
|
||
sql = f"""
|
||
SELECT
|
||
TABLE_NAME,
|
||
ORDINAL_POSITION,
|
||
COLUMN_NAME,
|
||
DATA_TYPE,
|
||
COLUMN_TYPE,
|
||
IS_NULLABLE,
|
||
COLUMN_DEFAULT,
|
||
COLUMN_KEY,
|
||
EXTRA,
|
||
COLUMN_COMMENT
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA=%s
|
||
AND TABLE_NAME IN ({ph})
|
||
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
||
"""
|
||
return fetch_all(cur, sql, [database] + list(tables))
|
||
|
||
|
||
def schema_indexes(cur, database: str, tables: Sequence[str]) -> List[Dict[str, Any]]:
|
||
ph = ",".join(["%s"] * len(tables))
|
||
sql = f"""
|
||
SELECT
|
||
TABLE_NAME,
|
||
INDEX_NAME,
|
||
NON_UNIQUE,
|
||
SEQ_IN_INDEX,
|
||
COLUMN_NAME,
|
||
INDEX_TYPE
|
||
FROM information_schema.STATISTICS
|
||
WHERE TABLE_SCHEMA=%s
|
||
AND TABLE_NAME IN ({ph})
|
||
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
||
"""
|
||
return fetch_all(cur, sql, [database] + list(tables))
|
||
|
||
|
||
def schema_foreign_keys(cur, database: str, tables: Sequence[str]) -> List[Dict[str, Any]]:
|
||
ph = ",".join(["%s"] * len(tables))
|
||
sql = f"""
|
||
SELECT
|
||
TABLE_NAME,
|
||
COLUMN_NAME,
|
||
CONSTRAINT_NAME,
|
||
REFERENCED_TABLE_NAME,
|
||
REFERENCED_COLUMN_NAME
|
||
FROM information_schema.KEY_COLUMN_USAGE
|
||
WHERE TABLE_SCHEMA=%s
|
||
AND TABLE_NAME IN ({ph})
|
||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||
ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION
|
||
"""
|
||
return fetch_all(cur, sql, [database] + list(tables))
|
||
|
||
|
||
def show_create(cur, tables: Sequence[str]) -> str:
|
||
chunks = []
|
||
for table in tables:
|
||
try:
|
||
cur.execute(f"SHOW CREATE TABLE `{table}`")
|
||
row = cur.fetchone()
|
||
chunks.append(f"-- {table}\n{list(row.values())[-1]};\n")
|
||
except Exception as e:
|
||
chunks.append(f"-- {table}: ERROR {e}\n")
|
||
return "\n".join(chunks)
|
||
|
||
|
||
def get_column_type(columns: List[Dict[str, Any]], table: str, col: str) -> Optional[str]:
|
||
for r in columns:
|
||
if r["TABLE_NAME"] == table and r["COLUMN_NAME"] == col:
|
||
return str(r["COLUMN_TYPE"])
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(
|
||
description="CM93 normalization implementation precheck v11"
|
||
)
|
||
p.add_argument("--v10-input", required=True)
|
||
p.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
||
p.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
||
p.add_argument("--user", default=os.getenv("MYSQL_USER", "root"))
|
||
p.add_argument("--password", default=os.getenv("MYSQL_PASSWORD"))
|
||
p.add_argument("--database", default=os.getenv("MYSQL_DATABASE", "cm93_chart"))
|
||
p.add_argument("--dataset-id", type=int, default=1)
|
||
p.add_argument("--bbox-tolerance", type=float, default=5e-6)
|
||
p.add_argument("--area-relative-tolerance", type=float, default=1e-9)
|
||
p.add_argument("--output", default=None)
|
||
return p.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
|
||
v10_src = Path(args.v10_input).expanduser().resolve()
|
||
if not v10_src.exists():
|
||
print(f"V10 输入不存在:{v10_src}")
|
||
sys.exit(2)
|
||
|
||
print("[1/7] V10.1 PASS gate ...")
|
||
v10_root = None
|
||
temp_v10 = None
|
||
|
||
try:
|
||
v10_root, temp_v10 = open_v10_input(v10_src)
|
||
gate, golden_map = load_v10_gate(v10_root)
|
||
|
||
print(json.dumps(gate, ensure_ascii=False, indent=2))
|
||
|
||
if not gate["gate_pass"]:
|
||
print("")
|
||
print("STOP:V10.1 gate 未通过。")
|
||
print("V11 不连接数据库、不继续实施预检。")
|
||
sys.exit(3)
|
||
|
||
print(" V10.1 gate: PASS")
|
||
|
||
if not args.password:
|
||
args.password = getpass.getpass("MySQL password: ")
|
||
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
outdir = Path(
|
||
args.output
|
||
or f"cm93_normalization_implementation_precheck_v11_dataset_{args.dataset_id}_{stamp}"
|
||
)
|
||
outdir.mkdir(parents=True, exist_ok=True)
|
||
|
||
report: Dict[str, Any] = {
|
||
"version": "v11.0",
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"database": args.database,
|
||
"dataset_id": args.dataset_id,
|
||
"read_only": True,
|
||
"pbf": "OUT_OF_SCOPE",
|
||
"v10_gate": gate,
|
||
"warnings": [],
|
||
}
|
||
|
||
conn = pymysql.connect(
|
||
host=args.host,
|
||
port=args.port,
|
||
user=args.user,
|
||
password=args.password,
|
||
database=args.database,
|
||
charset="utf8mb4",
|
||
cursorclass=DictCursor,
|
||
autocommit=False,
|
||
connect_timeout=20,
|
||
read_timeout=600,
|
||
write_timeout=30,
|
||
)
|
||
|
||
try:
|
||
with conn.cursor() as cur:
|
||
begin_read_only(cur, report["warnings"])
|
||
report["mysql_version"] = scalar(cur, "SELECT VERSION()")
|
||
wkb_fn = probe_wkb_fn(cur)
|
||
|
||
print("[2/7] 当前 55 条 invalid 与 golden vectors 重新对账 ...")
|
||
db_invalid = load_invalid_db_records(cur, args.dataset_id, wkb_fn)
|
||
|
||
revalidation = []
|
||
metadata_impact = []
|
||
|
||
for i, r in enumerate(db_invalid, start=1):
|
||
key = (
|
||
f"AREA:{r['feature_id']}"
|
||
if r["kind"] == "AREA"
|
||
else f"COVERAGE:{r['coverage_geom_id']}"
|
||
)
|
||
golden = golden_map.get(key)
|
||
|
||
if golden is None:
|
||
row = {
|
||
"key": key,
|
||
"status": "FAIL_MISSING_GOLDEN",
|
||
}
|
||
revalidation.append(row)
|
||
continue
|
||
|
||
candidate = normalize_invalid(r["_geom"])
|
||
|
||
if candidate is None:
|
||
revalidation.append({
|
||
"key": key,
|
||
"status": "FAIL_NO_CANDIDATE",
|
||
})
|
||
continue
|
||
|
||
h = geometry_hash(candidate)
|
||
expected_hash = golden.get("canonical_wkb_sha256")
|
||
type_match = candidate.geom_type == golden.get("candidate_type")
|
||
part_match = (
|
||
polygon_part_count(candidate)
|
||
== intv(golden.get("candidate_polygon_part_count"))
|
||
)
|
||
|
||
expected_area = floatv(golden.get("candidate_area_deg2"))
|
||
area = float(candidate.area)
|
||
if expected_area is None:
|
||
area_match = False
|
||
else:
|
||
scale = max(abs(area), abs(expected_area), 1e-30)
|
||
area_match = (
|
||
abs(area - expected_area)
|
||
<= args.area_relative_tolerance * scale
|
||
)
|
||
|
||
try:
|
||
expected_bbox_raw = golden.get("candidate_bbox")
|
||
expected_bbox = (
|
||
json.loads(expected_bbox_raw)
|
||
if isinstance(expected_bbox_raw, str)
|
||
else expected_bbox_raw
|
||
)
|
||
if isinstance(expected_bbox, str):
|
||
# CSV 里 tuple 可能不是合法 JSON
|
||
import ast
|
||
expected_bbox = ast.literal_eval(expected_bbox)
|
||
expected_bbox = tuple(float(v) for v in expected_bbox)
|
||
except Exception:
|
||
expected_bbox = None
|
||
|
||
bbox = tuple(float(v) for v in candidate.bounds)
|
||
bbox_match = (
|
||
expected_bbox is not None
|
||
and len(expected_bbox) == 4
|
||
and all(
|
||
abs(a - b) <= args.bbox_tolerance
|
||
for a, b in zip(bbox, expected_bbox)
|
||
)
|
||
)
|
||
|
||
status = (
|
||
"PASS"
|
||
if (
|
||
candidate.is_valid
|
||
and candidate.geom_type in {"Polygon", "MultiPolygon"}
|
||
and h == expected_hash
|
||
and type_match
|
||
and part_match
|
||
and area_match
|
||
and bbox_match
|
||
)
|
||
else "FAIL"
|
||
)
|
||
|
||
revalidation.append({
|
||
"key": key,
|
||
"kind": r["kind"],
|
||
"feature_id": r.get("feature_id"),
|
||
"coverage_geom_id": r.get("coverage_geom_id"),
|
||
"object_class": r.get("object_class"),
|
||
"cell_name": r.get("cell_name"),
|
||
"status": status,
|
||
"candidate_valid": int(bool(candidate.is_valid)),
|
||
"candidate_type": candidate.geom_type,
|
||
"expected_type": golden.get("candidate_type"),
|
||
"type_match": int(type_match),
|
||
"candidate_part_count": polygon_part_count(candidate),
|
||
"expected_part_count": intv(golden.get("candidate_polygon_part_count")),
|
||
"part_match": int(part_match),
|
||
"candidate_hash": h,
|
||
"expected_hash": expected_hash,
|
||
"hash_match": int(h == expected_hash),
|
||
"candidate_area_deg2": area,
|
||
"expected_area_deg2": expected_area,
|
||
"area_match": int(area_match),
|
||
"candidate_bbox": bbox,
|
||
"expected_bbox": expected_bbox,
|
||
"bbox_match": int(bbox_match),
|
||
})
|
||
|
||
if r["kind"] == "AREA":
|
||
old_bbox = (
|
||
floatv(r.get("bbox_min_lon")),
|
||
floatv(r.get("bbox_min_lat")),
|
||
floatv(r.get("bbox_max_lon")),
|
||
floatv(r.get("bbox_max_lat")),
|
||
)
|
||
old_bbox_valid = all(v is not None for v in old_bbox)
|
||
feature_bbox_matches = (
|
||
old_bbox_valid
|
||
and all(
|
||
abs(float(a) - float(b)) <= args.bbox_tolerance
|
||
for a, b in zip(old_bbox, bbox)
|
||
)
|
||
)
|
||
else:
|
||
old_bbox = None
|
||
feature_bbox_matches = None
|
||
|
||
metadata_impact.append({
|
||
"key": key,
|
||
"kind": r["kind"],
|
||
"feature_id": r.get("feature_id"),
|
||
"coverage_geom_id": r.get("coverage_geom_id"),
|
||
"object_class": r.get("object_class"),
|
||
"cell_name": r.get("cell_name"),
|
||
"old_feature_bbox": old_bbox,
|
||
"candidate_bbox": bbox,
|
||
"feature_bbox_matches_candidate":
|
||
(
|
||
int(feature_bbox_matches)
|
||
if feature_bbox_matches is not None
|
||
else None
|
||
),
|
||
"future_bbox_update_needed":
|
||
(
|
||
int(not feature_bbox_matches)
|
||
if feature_bbox_matches is not None
|
||
else None
|
||
),
|
||
})
|
||
|
||
print(
|
||
f" [{i:02d}/{len(db_invalid)}] {key} -> {status}"
|
||
)
|
||
|
||
rv_status = Counter(r.get("status") for r in revalidation)
|
||
|
||
print("[3/7] Schema / index / FK 只读调查 ...")
|
||
tables = [
|
||
"chart_feature",
|
||
"chart_area_geometry",
|
||
"chart_coverage",
|
||
"chart_import_job",
|
||
]
|
||
columns = schema_columns(cur, args.database, tables)
|
||
indexes = schema_indexes(cur, args.database, tables)
|
||
fks = schema_foreign_keys(cur, args.database, tables)
|
||
creates = show_create(cur, tables)
|
||
|
||
area_geom_type = get_column_type(
|
||
columns, "chart_area_geometry", "geom"
|
||
)
|
||
coverage_geom_type = get_column_type(
|
||
columns, "chart_coverage", "geom"
|
||
)
|
||
|
||
print("[4/7] 4406 parent-no-geometry 与 counters 当前状态 ...")
|
||
parent_4406 = int(scalar(cur, """
|
||
SELECT COUNT(*)
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
AND geometry_type='META'
|
||
AND raw_geotype=16
|
||
""", (args.dataset_id,)) or 0)
|
||
|
||
parent_issues = int(scalar(cur, """
|
||
SELECT COUNT(*)
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
AND geometry_type='META'
|
||
AND raw_geotype=16
|
||
AND geometry_issue IS NOT NULL
|
||
""", (args.dataset_id,)) or 0)
|
||
|
||
total_feature_issues = int(scalar(cur, """
|
||
SELECT COUNT(*)
|
||
FROM chart_feature
|
||
WHERE dataset_id=%s
|
||
AND geometry_issue IS NOT NULL
|
||
""", (args.dataset_id,)) or 0)
|
||
|
||
import_invalid_sum = int(scalar(cur, """
|
||
SELECT COALESCE(SUM(geometry_invalid_count),0)
|
||
FROM chart_import_job
|
||
WHERE dataset_id=%s
|
||
""", (args.dataset_id,)) or 0)
|
||
|
||
bbox_update_count = sum(
|
||
int(r.get("future_bbox_update_needed") or 0)
|
||
for r in metadata_impact
|
||
)
|
||
|
||
schema_can_store_multipolygon = (
|
||
area_geom_type is not None
|
||
and area_geom_type.lower().startswith("geometry")
|
||
)
|
||
coverage_can_store_multipolygon = (
|
||
coverage_geom_type is not None
|
||
and coverage_geom_type.lower().startswith("geometry")
|
||
)
|
||
|
||
report["summary"] = {
|
||
"v10_gate_pass": True,
|
||
"db_invalid_count": len(db_invalid),
|
||
"golden_revalidation_status_counts": dict(rv_status),
|
||
"golden_revalidation_pass_count": rv_status.get("PASS", 0),
|
||
"golden_revalidation_fail_count":
|
||
len(revalidation) - rv_status.get("PASS", 0),
|
||
"area_geometry_column_type": area_geom_type,
|
||
"coverage_geometry_column_type": coverage_geom_type,
|
||
"area_column_can_store_multipolygon":
|
||
schema_can_store_multipolygon,
|
||
"coverage_column_can_store_multipolygon":
|
||
coverage_can_store_multipolygon,
|
||
"future_area_bbox_update_needed_count": bbox_update_count,
|
||
"raw_geotype16_meta_count": parent_4406,
|
||
"raw_geotype16_with_issue_count": parent_issues,
|
||
"total_feature_geometry_issue_count": total_feature_issues,
|
||
"import_job_geometry_invalid_sum": import_invalid_sum,
|
||
}
|
||
|
||
implementation_ready = (
|
||
len(db_invalid) == 55
|
||
and rv_status.get("PASS", 0) == 55
|
||
and len(revalidation) == 55
|
||
and schema_can_store_multipolygon
|
||
and coverage_can_store_multipolygon
|
||
)
|
||
|
||
report["summary"]["implementation_precheck_pass"] = (
|
||
implementation_ready
|
||
)
|
||
|
||
print("[5/7] 生成实施契约与 SQL 计划(仅文本) ...")
|
||
|
||
contract = f"""# CM93 OGC Normalization 实施契约 v11
|
||
|
||
## 当前门控
|
||
|
||
- V10.1 canonical gate:PASS
|
||
- 当前数据库 55 条 golden revalidation:{rv_status.get("PASS", 0)} / 55 PASS
|
||
- Area geometry SQL type:`{area_geom_type}`
|
||
- Coverage geometry SQL type:`{coverage_geom_type}`
|
||
- Area 可直接容纳 MultiPolygon:{schema_can_store_multipolygon}
|
||
- Coverage 可直接容纳 MultiPolygon:{coverage_can_store_multipolygon}
|
||
- 未来需要同步更新 Area feature bbox:{bbox_update_count}
|
||
|
||
## 冻结 normalization 规则
|
||
|
||
仅当 Polygon/Area geometry 在 OGC/GEOS 意义下 invalid 时执行:
|
||
|
||
1. 读取原始 polygon boundary。
|
||
2. `unary_union(boundary)` 完成 noding。
|
||
3. `polygonize_full()` 生成 planar faces。
|
||
4. 对每个 face 的 representative point 使用原始 ring 的 **odd-even parity**。
|
||
5. 只保留 odd-even inside faces。
|
||
6. union selected faces。
|
||
7. 使用 GEOS/Shapely `normalize()` canonicalize。
|
||
8. 最终结果必须是 `Polygon` 或 `MultiPolygon`。
|
||
9. 必须 `is_valid == true`。
|
||
10. 现有 valid geometry 必须原样 PASS-THROUGH,不做 canonical rewrite。
|
||
|
||
## retraced connector
|
||
|
||
已确认的 retraced connector 是零面积拓扑桥接线:
|
||
- 不作为 normalized Area 面积保存;
|
||
- raw source / raw feature blob 继续保留;
|
||
- 不从原始 CM93 记录中删除。
|
||
|
||
## bbox
|
||
|
||
如果 normalized Area bbox 与当前 `chart_feature.bbox_*` 不同:
|
||
- normalized geometry 保存时必须同步更新 feature bbox;
|
||
- 不得继续保留 connector 扩大的旧 bbox 作为 normalized geometry bbox。
|
||
|
||
## raw_geotype=16
|
||
|
||
`raw_geotype=16 && geometry_type=META`:
|
||
- 不要求 spatial geometry;
|
||
- 不设置 `geometry_issue='geometry missing'`;
|
||
- 不增加 `geometry_invalid_count`;
|
||
- 不制造 EMPTY geometry。
|
||
|
||
## 原始数据保留
|
||
|
||
实施 normalization 时不得删除:
|
||
- `raw_feature_blob`
|
||
- `chart_raw_record`
|
||
- raw geotype
|
||
- source_record_index
|
||
- object attributes
|
||
|
||
## PBF
|
||
|
||
本实施契约仍不启动 PBF。
|
||
"""
|
||
|
||
sql_plan = f"""-- CM93 normalization migration plan v11
|
||
-- !!! 仅计划文本,V11 没有执行任何 SQL !!!
|
||
--
|
||
-- 当前 Area geom 类型: {area_geom_type}
|
||
-- 当前 Coverage geom 类型: {coverage_geom_type}
|
||
-- 未来 Area bbox 更新数量(当前 Dataset 1): {bbox_update_count}
|
||
--
|
||
-- 实施建议顺序:
|
||
--
|
||
-- STEP 1
|
||
-- 先修改 importer:
|
||
-- raw_geotype=16 / META 不再写 geometry missing / invalid counter。
|
||
--
|
||
-- STEP 2
|
||
-- importer 在 Area/Coverage geometry 写入前:
|
||
-- valid -> 原样写入
|
||
-- invalid -> odd-even normalization -> GEOS normalize -> valid Polygon/MultiPolygon
|
||
--
|
||
-- STEP 3
|
||
-- normalized Area 写入时重新计算:
|
||
-- bbox_min_lon
|
||
-- bbox_min_lat
|
||
-- bbox_max_lon
|
||
-- bbox_max_lat
|
||
--
|
||
-- STEP 4
|
||
-- 对现有 Dataset ID={args.dataset_id} 的历史回填应单独执行,
|
||
-- 不应与 importer 代码修改混在一次不可回退操作中。
|
||
--
|
||
-- STEP 5
|
||
-- 历史回填前必须备份/快照,并再次用 V10 golden vectors 校验。
|
||
--
|
||
-- 本文件故意不生成可直接执行的 UPDATE SQL。
|
||
-- 原因:当前阶段只冻结规则和 schema 影响,不执行数据修改。
|
||
"""
|
||
|
||
(outdir / "proposed_implementation_contract_v11.md").write_text(
|
||
contract, encoding="utf-8"
|
||
)
|
||
(outdir / "proposed_migration_plan_v11.sql.txt").write_text(
|
||
sql_plan, encoding="utf-8"
|
||
)
|
||
|
||
print("[6/7] 写报告 ...")
|
||
write_csv(outdir / "golden_revalidation_v11.csv", revalidation)
|
||
write_csv(outdir / "schema_columns_v11.csv", columns)
|
||
write_csv(outdir / "schema_indexes_v11.csv", indexes)
|
||
write_csv(outdir / "schema_foreign_keys_v11.csv", fks)
|
||
write_csv(
|
||
outdir / "normalization_metadata_impact_v11.csv",
|
||
metadata_impact
|
||
)
|
||
(outdir / "show_create_tables_v11.txt").write_text(
|
||
creates, encoding="utf-8"
|
||
)
|
||
(outdir / "implementation_precheck_report_v11.json").write_text(
|
||
json.dumps(report, ensure_ascii=False, indent=2, default=str),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
lines = []
|
||
lines.append("CM93 Normalization 实施前只读预检 v11")
|
||
lines.append("=" * 88)
|
||
lines.append(f"Generated: {report['generated_at']}")
|
||
lines.append("")
|
||
lines.append("V10 GATE")
|
||
lines.append(" PASS")
|
||
lines.append("")
|
||
lines.append("GOLDEN REVALIDATION")
|
||
lines.append(
|
||
f" DB invalid: {len(db_invalid)}"
|
||
)
|
||
lines.append(
|
||
f" PASS: {rv_status.get('PASS', 0)}"
|
||
)
|
||
lines.append(
|
||
f" FAIL: {len(revalidation) - rv_status.get('PASS', 0)}"
|
||
)
|
||
lines.append("")
|
||
lines.append("SCHEMA")
|
||
lines.append(
|
||
f" chart_area_geometry.geom: {area_geom_type}"
|
||
)
|
||
lines.append(
|
||
f" chart_coverage.geom: {coverage_geom_type}"
|
||
)
|
||
lines.append(
|
||
f" Area supports MultiPolygon directly: "
|
||
f"{schema_can_store_multipolygon}"
|
||
)
|
||
lines.append(
|
||
f" Coverage supports MultiPolygon directly: "
|
||
f"{coverage_can_store_multipolygon}"
|
||
)
|
||
lines.append("")
|
||
lines.append("METADATA")
|
||
lines.append(
|
||
f" future Area bbox updates needed: {bbox_update_count}"
|
||
)
|
||
lines.append("")
|
||
lines.append("GEOTYPE16")
|
||
lines.append(
|
||
f" META raw_geotype=16: {parent_4406}"
|
||
)
|
||
lines.append(
|
||
f" still has geometry_issue: {parent_issues}"
|
||
)
|
||
lines.append(
|
||
f" total feature issues: {total_feature_issues}"
|
||
)
|
||
lines.append(
|
||
f" import invalid counter sum:{import_invalid_sum}"
|
||
)
|
||
lines.append("")
|
||
lines.append("OVERALL")
|
||
lines.append(
|
||
f" IMPLEMENTATION PRECHECK PASS: "
|
||
f"{'YES' if implementation_ready else 'NO'}"
|
||
)
|
||
lines.append("")
|
||
lines.append(
|
||
"NO DATABASE WRITE / NO ALTER TABLE / NO PBF"
|
||
)
|
||
|
||
(outdir / "implementation_precheck_summary_v11.txt").write_text(
|
||
"\n".join(lines), encoding="utf-8"
|
||
)
|
||
|
||
conn.rollback()
|
||
|
||
print("[7/7] 压缩 ...")
|
||
zpath = outdir.with_suffix(".zip")
|
||
with zipfile.ZipFile(
|
||
zpath, "w", compression=zipfile.ZIP_DEFLATED
|
||
) as z:
|
||
for p in sorted(outdir.rglob("*")):
|
||
if p.is_file():
|
||
z.write(p, p.relative_to(outdir.parent))
|
||
|
||
print("")
|
||
print(
|
||
"IMPLEMENTATION PRECHECK PASS:",
|
||
"YES" if report["summary"]["implementation_precheck_pass"] else "NO"
|
||
)
|
||
print(f"ZIP: {zpath.resolve()}")
|
||
print("把这个 V11 ZIP 上传给 ChatGPT。")
|
||
|
||
finally:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
finally:
|
||
if temp_v10 and temp_v10.exists():
|
||
shutil.rmtree(temp_v10, ignore_errors=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|