- 新表 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 条(双格式解析、双写、回落、删除同步、分析统计与告警)
525 lines
23 KiB
Python
525 lines
23 KiB
Python
#!/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())
|