Files
cma-management/backend/scripts/migrate_map_connections.py
T
wecom-fullstackandHermes CI Fix adcd118498 feat(maps): P1 四层因果结构化 map_connections 表 + 迁移脚本 + analysis 只读端点
- 新表 map_connections(entity_id/map_id/from_node/to_node/from_layer/to_layer/
  from_objective_id/to_objective_id/effect/label/sort_order),唯一键 (map_id,from,to) 保幂等
- 节点双格式兼容:层:中文目标名(生产存量 31 条)与 层-序号(前端现用)
- canvas_data 保留不动(双写期);POST/DELETE 写表 + 同步 JSON,失败仅告警不阻断
- GET /connections 表优先、表空回落 canvas_data;新增 GET /connections/analysis
  (层统计/跨层分布/断链/反向告警不拦截)
- scripts/migrate_map_connections.py 四态(check/apply/idem/rollback)+ canvas 快照回滚点
- tests: 9 条(双格式解析、双写、回落、删除同步、分析统计与告警)
2026-10-22 05:19:45 +08:00

525 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <apply-evidence.json>
退出码: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())