#!/usr/bin/env python3 """canvas_data.connections → map_connections 迁移(P1 四层因果结构化) 任务:cma-map-connections-table-p1 / project-20261021-cma-map-connections-table-p1-fullstack 设计要点 -------- 1. **只搬不删**:canvas_data 原样保留(双写期,前端渲染仍读 JSON)。本脚本对 strategic_maps 只做 SELECT,任何情况下不 UPDATE。 2. **幂等**:目标表唯一键 (map_id, from_node, to_node)。重跑 `--apply` 只补缺, 已存在的行 SKIP;`--idem` 为只读等价复跑(断言全部已在位),零写。 3. **双格式兼容**(生产实况): `dim:中文目标名` → 按 (dimension_key, 目标名) 解析 map_objectives.id `dim-序号` → 按 dimensions[dim].objectives 顺序解析(缺 dimensions 时退 sort_order) 原始串原样落 from_node/to_node,解析结果落 *_layer / *_objective_id。 4. **四态**:--check(默认拒绝无标志调用)/ --apply / --idem / --rollback 写库通道:生产 apply/rollback = root over unix socket(cma_api 无新表 DML 授权); --check 走环境变量只读账号(cma_reader/cma_api 的 SELECT)。 用法 ---- python3 migrate_map_connections.py --check [--json-out PATH] python3 migrate_map_connections.py --show-create python3 migrate_map_connections.py --apply [--backup-dir DIR] [--evidence PATH] python3 migrate_map_connections.py --idem python3 migrate_map_connections.py --rollback 退出码:0 成功 | 2 用法/缺标志 | 3 表不存在(先走 db_migrate.py --apply)| 4 画布 JSON 不可解析(fail-closed)| 5 自检不一致 """ import argparse import hashlib import json import os import re import sys from datetime import datetime HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(HERE)) LAYERS = ("learning", "process", "customer", "finance") LAYER_ORDER = {k: i for i, k in enumerate(LAYERS)} NODE_COLON = re.compile(r"^([a-z_]+):(.+)$") NODE_INDEX = re.compile(r"^([a-z_]+)-(\d+)$") # ── 连接(凭据只从 env 读,不回显)──────────────────────────────── def _mask(url: str) -> str: return re.sub(r"://([^:/@]+):[^@]*@", r"://\1:***@", url) def build_engine(db_url: str | None, mode: str): from sqlalchemy import create_engine if db_url: url = db_url elif mode == "check" or mode == "show-create": user = os.getenv("CMA_DB_USER", "cma_reader") pwd = os.getenv("CMA_DB_PASS", "") host = os.getenv("CMA_DB_HOST", "127.0.0.1") port = os.getenv("CMA_DB_PORT", "3306") name = os.getenv("CMA_DB_NAME", "cma") if not pwd: print("[fail] 缺少 CMA_DB_PASS(只读预检需要 SELECT 账号凭据)", file=sys.stderr) sys.exit(2) url = f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{name}?charset=utf8mb4" else: name = os.getenv("CMA_DB_NAME", "cma") url = f"mysql+pymysql://root@localhost/{name}?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4" return create_engine(url, future=True), url def load_dotenv(): try: from dotenv import load_dotenv as _ld _ld(os.path.join(os.path.dirname(HERE), ".env")) except Exception: pass # ── JSON 规范化 ──────────────────────────────────────────────── def as_json(v): """DB JSON 列可能是 dict/list 或 str(SQLite/部分驱动)。""" if isinstance(v, (bytes, bytearray)): v = v.decode("utf-8") if isinstance(v, str): return json.loads(v) return v def canvas_md5(canvas) -> str: raw = json.dumps(canvas, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.md5(raw.encode("utf-8")).hexdigest() def norm_name(s: str) -> str: return re.sub(r"\s+", " ", (s or "").strip()) # ── 节点解析 ─────────────────────────────────────────────────── def parse_node(node): """→ (layer|None, ref|None, err|None) ref = {"kind": "name", "name": ...} | {"kind": "index", "index": n} """ if not isinstance(node, str) or not node.strip(): return None, None, "node_empty" s = node.strip() m = NODE_COLON.match(s) if m: layer = m.group(1) name = m.group(2).strip() if layer not in LAYERS: return None, None, f"unknown_layer:{layer}" if not name: return None, None, "node_name_empty" return layer, {"kind": "name", "name": name}, None m = NODE_INDEX.match(s) if m: layer = m.group(1) if layer not in LAYERS: return None, None, f"unknown_layer:{layer}" return layer, {"kind": "index", "index": int(m.group(2))}, None return None, None, "node_format_unrecognized" class ObjectiveIndex: """按 map 建索引:名字 → objective_id;层 + 序号 → objective_id。""" def __init__(self, dims_by_map, objs_by_map): self.by_name = {} self.by_order = {} self.dims = dims_by_map for map_id, objs in objs_by_map.items(): name_idx, order_idx = {}, {} for o in objs: name_idx[(o["dimension_key"], norm_name(o["name"]))] = o["id"] order_idx.setdefault(o["dimension_key"], []).append(o) for k in order_idx: order_idx[k].sort(key=lambda o: (o.get("sort_order") or 0, o["id"])) self.by_name[map_id] = name_idx self.by_order[map_id] = order_idx def resolve(self, map_id, layer, ref): """→ (objective_id|None, note|None)""" if ref["kind"] == "name": hit = self.by_name.get(map_id, {}).get((layer, norm_name(ref["name"]))) if hit: return hit, None # 仅大小写/空白差异的近似命中:报告但不静默 want = norm_name(ref["name"]).replace(" ", "") for (lk, ln), oid in self.by_name.get(map_id, {}).items(): if lk == layer and ln.replace(" ", "") == want: return oid, "normalized_match" return None, "objective_unresolved" # index:优先按 dimensions[dim].objectives 顺序(前端 key 就是该数组下标) idx = ref["index"] dims = self.dims.get(map_id) or [] for d in dims if isinstance(dims, list) else []: if d.get("key") == layer: objs = d.get("objectives") or [] if 0 <= idx < len(objs): name = norm_name(objs[idx].get("name")) hit = self.by_name.get(map_id, {}).get((layer, name)) if hit: return hit, None return None, "objective_unresolved" return None, "index_out_of_range" # dimensions 缺失 → 退 sort_order cand = self.by_order.get(map_id, {}).get(layer) or [] for o in cand: if (o.get("sort_order") or 0) == idx: return o["id"], "resolved_by_sort_order" if 0 <= idx < len(cand): return cand[idx]["id"], "resolved_by_position" return None, "index_out_of_range" # ── 计划构建(纯读)──────────────────────────────────────────── def load_state(session): from sqlalchemy import inspect from app.models import StrategicMap, MapObjective, MapConnection insp = inspect(session.get_bind()) has_table = insp.has_table(MapConnection.__tablename__) maps = [] for m in session.query(StrategicMap).order_by(StrategicMap.id).all(): maps.append({ "id": m.id, "entity_id": m.entity_id, "title": m.title, "status": m.status, "canvas": as_json(m.canvas_data), "dimensions": as_json(m.dimensions), }) objs = {} for o in session.query(MapObjective).order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id).all(): objs.setdefault(o.map_id, []).append({ "id": o.id, "dimension_key": o.dimension_key, "name": o.name, "sort_order": o.sort_order, }) existing = {} if has_table: for c in session.query(MapConnection).all(): existing[(c.map_id, c.from_node, c.to_node)] = c.id return maps, objs, existing, has_table def build_plan(maps, objs): oi = ObjectiveIndex({m["id"]: m["dimensions"] for m in maps}, objs) plan, per_map, anomalies = [], [], [] def note(map_id, idx, kind, node, detail=""): anomalies.append({"map_id": map_id, "canvas_index": idx, "kind": kind, "node": node, "detail": detail}) for m in maps: canvas = m["canvas"] conns = [] if canvas is None: canvas = {} if not isinstance(canvas, dict): note(m["id"], None, "canvas_not_object", None, f"type={type(canvas).__name__}") per_map.append({"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"], "canvas_connections": None, "plan_rows": 0, "anomalies": 1}) continue conns = canvas.get("connections") or [] if not isinstance(conns, list): note(m["id"], None, "connections_not_list", None, f"type={type(conns).__name__}") conns = [] rows, seen = [], {} for i, c in enumerate(conns): if not isinstance(c, dict): note(m["id"], i, "connection_not_object", None, f"type={type(c).__name__}") continue frm, to = c.get("from"), c.get("to") if not isinstance(frm, str) or not isinstance(to, str) or not frm or not to: note(m["id"], i, "endpoint_missing", f"{frm!r}->{to!r}", "from/to 缺失或非字符串") continue key = (frm, to) if key in seen: note(m["id"], i, "duplicate_edge", f"{frm} -> {to}", f"与 index={seen[key]} 重复(唯一键会合并)") continue seen[key] = i fl, fref, ferr = parse_node(frm) tl, tref, terr = parse_node(to) if ferr: note(m["id"], i, ferr, frm, "from 解析失败") if terr: note(m["id"], i, terr, to, "to 解析失败") if frm == to: note(m["id"], i, "self_loop", frm, "自连") if fl and tl and LAYER_ORDER[tl] < LAYER_ORDER[fl]: note(m["id"], i, "reverse_direction", f"{frm} -> {to}", f"{fl}→{tl} 与 learning→process→customer→finance 相反(告警不拦截)") f_obj = f_note = t_obj = t_note = None if fref: f_obj, f_note = oi.resolve(m["id"], fl, fref) if f_note and f_note != "normalized_match": note(m["id"], i, f_note, frm, "from 目标未解析") if tref: t_obj, t_note = oi.resolve(m["id"], tl, tref) if t_note and t_note != "normalized_match": note(m["id"], i, t_note, to, "to 目标未解析") rows.append({ "entity_id": m["entity_id"] if m["entity_id"] is not None else 1, "map_id": m["id"], "from_node": frm, "to_node": to, "from_layer": fl, "to_layer": tl, "from_objective_id": f_obj, "to_objective_id": t_obj, "effect": c.get("effect") or "positive", "label": c.get("label") or None, "sort_order": i, }) plan.extend(rows) per_map.append({ "map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"], "status": m["status"], "canvas_connections": len(conns), "plan_rows": len(rows), "anomalies": sum(1 for a in anomalies if a["map_id"] == m["id"]), }) return plan, per_map, anomalies def summarize(plan, per_map, anomalies, existing=None): by_pair, by_layer = {}, {} for r in plan: pair = f"{r['from_layer'] or '?'}->{r['to_layer'] or '?'}" by_pair[pair] = by_pair.get(pair, 0) + 1 for lay in (r["from_layer"], r["to_layer"]): if lay: by_layer[lay] = by_layer.get(lay, 0) + 1 existing = existing or {} to_insert = sum(1 for r in plan if (r["map_id"], r["from_node"], r["to_node"]) not in existing) return { "canvas_connections": sum(p["canvas_connections"] or 0 for p in per_map), "plan_rows": len(plan), "already_in_table": len([r for r in plan if (r["map_id"], r["from_node"], r["to_node"]) in existing]), "to_insert": to_insert, "unresolved_layer": sum(1 for r in plan if not r["from_layer"] or not r["to_layer"]), "unresolved_objective": sum(1 for r in plan if not r["from_objective_id"] or not r["to_objective_id"]), "anomalies": len(anomalies), "layer_pair_distribution": by_pair, "layer_node_refs": by_layer, } # ── 三种模式的实现 ───────────────────────────────────────────── def cmd_check(engine, args): from sqlalchemy.orm import Session with Session(engine) as s: maps, objs, existing, has_table = load_state(s) plan, per_map, anomalies = build_plan(maps, objs) report = { "generated_at": datetime.now().isoformat(timespec="seconds"), "mode": "check", "read_only": True, "db": _mask(engine.url.render_as_string(hide_password=True).replace("***", "***")), "engine_dialect": engine.dialect.name, "table_exists": has_table, "totals": summarize(plan, per_map, anomalies, existing), "maps": per_map, "anomalies": anomalies, "sample_rows": plan[:5], } if args.json_out: os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True) with open(args.json_out, "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) print(f"[check] 库={report['db']} 方言={report['engine_dialect']} 目标表存在={has_table}") t = report["totals"] print(f"[check] 画布连线={t['canvas_connections']} 计划行={t['plan_rows']} " f"已在表={t['already_in_table']} 待插入={t['to_insert']}") print(f"[check] 层未解析={t['unresolved_layer']} 目标未解析={t['unresolved_objective']} 异常条目={t['anomalies']}") print(f"[check] 层对分布={t['layer_pair_distribution']}") for p in per_map: print(f"[check] map {p['map_id']} (entity={p['entity_id']}, {p['status']}): " f"canvas={p['canvas_connections']} → plan={p['plan_rows']} 异常={p['anomalies']}") for a in anomalies[:50]: print(f"[check] ✗ map={a['map_id']} idx={a['canvas_index']} {a['kind']} {a['node']} {a['detail']}") if not has_table: print("[check] ⚠ 目标表不存在:建表须先走 db_migrate.py --apply(受批面)") return 0 def cmd_show_create(): from sqlalchemy.schema import CreateTable from sqlalchemy.dialects import mysql from app.models import MapConnection print(CreateTable(MapConnection.__table__).compile(dialect=mysql.dialect())) return 0 def cmd_apply(engine, args): from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from app.models import MapConnection with Session(engine) as s: maps, objs, existing, has_table = load_state(s) if not has_table: print("[fail] map_connections 表不存在 → 先执行(受批)db_migrate.py --apply", file=sys.stderr) return 3 plan, per_map, anomalies = build_plan(maps, objs) hard = [a for a in anomalies if a["kind"] in ("canvas_not_object", "connections_not_list", "connection_not_object")] if hard: print(f"[fail] 画布结构不可解析 {len(hard)} 处 → fail-closed,未写任何行:" f"{hard[:3]}", file=sys.stderr) return 4 # 回滚点:canvas_data 全量快照 + md5(只读 SELECT 得到) ts = datetime.now().strftime("%Y%m%d-%H%M%S") backup_dir = os.path.abspath(args.backup_dir or f"/srv/hermes/backups/map_connections_p1/{ts}") os.makedirs(backup_dir, exist_ok=True) backup_path = os.path.join(backup_dir, "canvas_data_snapshot.json") with open(backup_path, "w", encoding="utf-8") as f: json.dump([{"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"], "canvas_data": m["canvas"], "md5": canvas_md5(m["canvas"])} for m in maps], f, ensure_ascii=False, indent=2) pre_md5 = {m["id"]: canvas_md5(m["canvas"]) for m in maps} inserted, skipped, errors = [], 0, [] with Session(engine) as s: try: for r in plan: key = (r["map_id"], r["from_node"], r["to_node"]) if key in existing: skipped += 1 continue obj = MapConnection(**r) s.add(obj) try: s.flush() except IntegrityError: s.rollback() skipped += 1 continue inserted.append({"id": obj.id, "map_id": r["map_id"], "from_node": r["from_node"], "to_node": r["to_node"]}) s.commit() except Exception as exc: s.rollback() print(f"[fail] 写入失败已回滚:{type(exc).__name__}: {exc}", file=sys.stderr) return 1 # 写后自检:行数 + canvas 未变 with Session(engine) as s: maps2, _, _, _ = load_state(s) table_rows = s.query(MapConnection).filter( MapConnection.map_id.in_([m["id"] for m in maps])).count() if maps else 0 post_md5 = {m["id"]: canvas_md5(m["canvas"]) for m in maps2} canvas_unchanged = pre_md5 == post_md5 expected = len({(r["map_id"], r["from_node"], r["to_node"]) for r in plan}) evidence = { "action": "apply", "status": "ok", "started_at": ts, "finished_at": datetime.now().isoformat(timespec="seconds"), "db_engine": engine.dialect.name, "backup_path": backup_path, "backup_pre_md5": pre_md5, "post_md5": post_md5, "canvas_unchanged": canvas_unchanged, "plan_rows": len(plan), "inserted": len(inserted), "skipped_existing": skipped, "table_rows_for_scope": table_rows, "expected_distinct_edges": expected, "anomalies": anomalies, "maps": per_map, "inserted_ids": inserted, } ok = canvas_unchanged and table_rows == expected and not errors evidence["verdict"] = "PASS" if ok else "FAIL" ev_path = os.path.abspath(args.evidence or os.path.join(backup_dir, "apply_evidence.json")) os.makedirs(os.path.dirname(ev_path), exist_ok=True) with open(ev_path, "w", encoding="utf-8") as f: json.dump(evidence, f, ensure_ascii=False, indent=2) print(f"[apply] 计划 {len(plan)} 行;新插入 {len(inserted)},已存在跳过 {skipped}") print(f"[apply] 表内本范围行数 {table_rows} / 期望 {expected};canvas 未改={canvas_unchanged}") print(f"[apply] 备份 {backup_path}") print(f"[apply] 证据 {ev_path}") print(f"[apply] verdict={evidence['verdict']}") return 0 if ok else 5 def cmd_idem(engine, args): from sqlalchemy.orm import Session with Session(engine) as s: maps, objs, existing, has_table = load_state(s) if not has_table: print("[fail] 表不存在", file=sys.stderr) return 3 plan, per_map, anomalies = build_plan(maps, objs) mismatches = [] for r in plan: key = (r["map_id"], r["from_node"], r["to_node"]) cid = existing.get(key) if not cid: mismatches.append({"kind": "missing", "key": list(key)}) continue row = s.get(__import__("app.models", fromlist=["MapConnection"]).MapConnection, cid) for f in ("from_layer", "to_layer", "from_objective_id", "to_objective_id"): if (getattr(row, f) or None) != (r[f] or None): mismatches.append({"kind": "value_diff", "key": list(key), "field": f, "db": getattr(row, f), "plan": r[f]}) print(f"[idem] 计划 {len(plan)} 行,已在位 {len(plan) - len([m for m in mismatches if m['kind'] == 'missing'])};" f"差异 {len(mismatches)}") for m in mismatches[:20]: print(f"[idem] ✗ {m}") print(f"[idem] verdict={'PASS' if not mismatches else 'FAIL'}") return 0 if not mismatches else 5 def cmd_rollback(engine, args): from sqlalchemy.orm import Session from app.models import MapConnection path = args.rollback if not path or not os.path.exists(path): print(f"[fail] 回滚证据不存在:{path}", file=sys.stderr) return 2 with open(path, encoding="utf-8") as f: ev = json.load(f) ids = [x["id"] for x in ev.get("inserted_ids", [])] if not ids: print("[rollback] 证据里没有 inserted_ids,无需回滚") return 0 with Session(engine) as s: n = s.query(MapConnection).filter(MapConnection.id.in_(ids)).delete(synchronize_session=False) s.commit() left = s.query(MapConnection).filter(MapConnection.id.in_(ids)).count() print(f"[rollback] 删除 {n} 行,残留 {left}(证据 {path});canvas_data 本脚本从未写入,无需还原") return 0 if left == 0 else 5 def main(): ap = argparse.ArgumentParser(description="canvas_data.connections → map_connections(四态幂等迁移)") g = ap.add_mutually_exclusive_group() g.add_argument("--check", action="store_true", help="只读预检(dry-run):行数/异常/待插入") g.add_argument("--apply", action="store_true", help="唯一写:幂等插入(需表已存在)") g.add_argument("--idem", action="store_true", help="只读幂等复跑:断言全部已在位") g.add_argument("--rollback", metavar="EVIDENCE_JSON", help="按 apply 证据删除本批插入行") g.add_argument("--show-create", action="store_true", help="打印模型编译出的 CREATE TABLE") ap.add_argument("--db-url", help="覆盖连接串(演练副本/SQLite 用)") ap.add_argument("--json-out", help="--check 的机器可读报告路径") ap.add_argument("--backup-dir", help="--apply 回滚点目录") ap.add_argument("--evidence", help="--apply 证据 JSON 路径") args = ap.parse_args() load_dotenv() if args.show_create: return cmd_show_create() if not any([args.check, args.apply, args.idem, args.rollback]): print("拒绝无标志调用:请显式给 --check / --apply / --idem / --rollback", file=sys.stderr) return 2 mode = "check" if args.check else "apply" if args.apply else "idem" if args.idem else "rollback" engine, url = build_engine(args.db_url, mode) print(f"[{mode}] 通道 {_mask(url)}") if args.check: return cmd_check(engine, args) if args.apply: return cmd_apply(engine, args) if args.idem: return cmd_idem(engine, args) return cmd_rollback(engine, args) if __name__ == "__main__": sys.exit(main())