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 条(双格式解析、双写、回落、删除同步、分析统计与告警)
This commit is contained in:
committed by
Hermes CI Fix
parent
0a6525edab
commit
adcd118498
@@ -0,0 +1,524 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user