fix(maps): 画布连线键与目标改名解耦(objective_id 锚定)+ nodeRefs 位置兜底 + 保守反错绑断言(P3)
ci/woodpecker/push/main Pipeline failed

- map_connections 读侧以 from/to_objective_id 反查当前 dimensions 树定位节点,改名/重排不再静默断线
- 悬停/拖拽期 map_objectives 未返回时按位置兜底(仅同层同序位有效),并记录 stale_objective_id
- 新增 tests/test_conn_key_rename_stability.py(含 2 条保守反错绑负测用例)
- 零 DDL、零 health_score 改动(问3=A)
This commit is contained in:
wecom-fullstack
2026-10-23 15:49:36 +08:00
parent 986dec516e
commit b3f31f9ac9
5 changed files with 785 additions and 54 deletions
+218 -37
View File
@@ -681,9 +681,102 @@ def _get_connections(m: StrategicMap) -> list:
return m.canvas_data["connections"]
def _objective_key_index(db: Session, m: StrategicMap):
"""连线解析锚点:MapObjective.id → 画布渲染键 `${dim}-${idx}`(以当前 dimensions 顺序为准)
返回 (id2key, name2key, dim_len):
· id2key : 本表行 id → `${dim}-${idx}`(map_connections.from/to_objective_id 的锚点)
· name2key: (dim, 归一化名称) → `${dim}-${idx}`(历史 `层:中文名` 的兜底,改名即失效)
· dim_len : {dim: len(objectives)}(`层-序号` 越界判定)
只按名称把 dimensions 节点对回 map_objectives 行,**不读** dims JSON 里的 `_objective_id`
—— 那是 objectives 表(实体级目标)的 id 空间,与 map_objectives 行 id 不是一回事。
"""
dims = _dims_list(m.dimensions)
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
MapObjective.sort_order, MapObjective.id).all()
by_name = {}
for o in rows:
by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id
id2key, name2key, dim_len = {}, {}, {}
for d in dims:
dk = d.get("key") or ""
if not dk:
continue
objs = d.get("objectives") or []
dim_len[dk] = len(objs)
for idx, o in enumerate(objs):
if not isinstance(o, dict):
continue
key = f"{dk}-{idx}"
nm = _norm_obj_name(o.get("name"))
if nm:
name2key[(dk, nm)] = key
oid = by_name.get((dk, nm))
if oid is not None:
id2key[oid] = key
return id2key, name2key, dim_len
def _key_for_node(node, id2key, name2key, dim_len, objective_id=None):
"""单端节点解析 → (key, source, reason)
顺序:objective_id 锚点 → `层:中文名` → `层-序号`。
改名后 `objective_id` 仍指向同一节点 ⇒ key 不变、线不断;锚点失效(目标已删/历史脏 id/
跨 map)才回退原名字串,仍解析不到才返回 None(调用方计入 unresolved)。
"""
if objective_id:
hit = id2key.get(objective_id)
if hit:
return hit, "objective_id", None
ref = parse_node_ref(node)
if ref["error"]:
return None, None, ref["error"]
layer = ref["layer"]
stale = "stale_objective_id" if objective_id else None
if ref["kind"] == "name":
want = _norm_obj_name(ref["value"])
hit = name2key.get((layer, want))
if hit:
return hit, "name", stale
return None, None, (stale or "objective_unresolved")
idx = ref["value"]
if 0 <= idx < dim_len.get(layer, 0):
return f"{layer}-{idx}", "index", stale
return None, None, "index_out_of_range"
def _attach_keys(db: Session, m: StrategicMap, items: list):
"""给连线条目附 `from_key/to_key`(渲染键)+ 未解析清单。
条目原地补字段:`{side}_key` / `{side}_key_source` / `{side}_key_reason`,
键与中文名解耦的唯一出口(前端按 key 画线,缺失回落原值)。
"""
id2key, name2key, dim_len = _objective_key_index(db, m)
unresolved = []
for it in items:
for side in ("from", "to"):
key, source, reason = _key_for_node(
it.get(side), id2key, name2key, dim_len, it.get(f"{side}_objective_id"))
it[f"{side}_key"] = key
it[f"{side}_key_source"] = source
it[f"{side}_key_reason"] = reason
if key is None:
unresolved.append({
"from": it.get("from"), "to": it.get("to"),
"side": side, "node": it.get(side),
"reason": reason, "objective_id": it.get(f"{side}_objective_id"),
})
return items, unresolved
@router.get("/{map_id}/connections")
def list_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""读连线:表优先,表空回落 canvas_data(兼容期)。"""
"""读连线:表优先,表空回落 canvas_data(兼容期)。
P3:每条连线附 `from_key/to_key` = `${dim}-${idx}`(由 objective_id 在当前 dimensions
树反查)+ 顶层 `unresolved` 计数与清单。前端优先按 key 画线 ⇒ 目标改名不断线。
"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@@ -691,25 +784,33 @@ def list_connections(map_id: int, db: Session = Depends(get_db), entity_id: int
if _table_ready(db):
rows = _table_rows(db, m)
if rows:
items = [{
"from": r.from_node, "to": r.to_node, "style": "solid",
"effect": r.effect or "positive", "label": r.label or "",
"from_layer": r.from_layer, "to_layer": r.to_layer,
"from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id,
} for r in rows]
items, unresolved = _attach_keys(db, m, items)
return {
"source": "table", "total": len(rows),
"connections": [{
"from": r.from_node, "to": r.to_node, "style": "solid",
"effect": r.effect or "positive", "label": r.label or "",
"from_layer": r.from_layer, "to_layer": r.to_layer,
"from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id,
} for r in rows],
"source": "table", "total": len(items),
"unresolved": len(unresolved), "unresolved_items": unresolved,
"connections": items,
}
conns = _get_connections(m)
items = [{
"from": c.get("from"), "to": c.get("to"),
"style": c.get("style") or "solid",
"effect": c.get("effect") or "positive",
"label": c.get("label") or "",
"from_objective_id": c.get("from_objective_id"),
"to_objective_id": c.get("to_objective_id"),
} for c in conns if isinstance(c, dict)]
items, unresolved = _attach_keys(db, m, items)
return {
"source": "canvas_data", "total": len(conns),
"connections": [{
"from": c.get("from"), "to": c.get("to"),
"style": c.get("style") or "solid",
"effect": c.get("effect") or "positive",
"label": c.get("label") or "",
} for c in conns if isinstance(c, dict)],
"source": "canvas_data", "total": len(items),
"unresolved": len(unresolved), "unresolved_items": unresolved,
"connections": items,
}
@@ -751,6 +852,7 @@ def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: i
layer_stats = {k: {"as_from": 0, "as_to": 0} for k in LAYER_ORDER}
cross_layer, reversed_edges, broken, unknown = {}, [], [], []
same_layer = 0
items, unresolved = _attach_keys(db, m, items)
for it in items:
fl, tl = it.get("from_layer"), it.get("to_layer")
if fl in layer_stats:
@@ -768,12 +870,14 @@ def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: i
elif _LAYER_INDEX[tl] < _LAYER_INDEX[fl]:
reversed_edges.append({"from": it["from"], "to": it["to"],
"from_layer": fl, "to_layer": tl})
if not it.get("from_objective_id"):
if not it.get("from_key"):
broken.append({"from": it["from"], "to": it["to"], "side": "from",
"node": it["from"], "reason": parse_node_ref(it["from"]).get("error") or "objective_unresolved"})
if not it.get("to_objective_id"):
"node": it["from"],
"reason": it.get("from_key_reason") or "objective_unresolved"})
if not it.get("to_key"):
broken.append({"from": it["from"], "to": it["to"], "side": "to",
"node": it["to"], "reason": parse_node_ref(it["to"]).get("error") or "objective_unresolved"})
"node": it["to"],
"reason": it.get("to_key_reason") or "objective_unresolved"})
warnings = []
if reversed_edges:
@@ -791,6 +895,8 @@ def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: i
"same_layer_count": same_layer,
"reversed": reversed_edges, "broken": broken,
"unknown_layer": unknown, "warnings": warnings,
"unresolved": len(unresolved), "unresolved_items": unresolved,
"items": items,
}
@@ -925,26 +1031,98 @@ def m_to_dict(m, db: Session = None):
def _sync_map_objectives(m, db):
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
"""保存时:将dimensions JSON中的目标同步到map_objectives表(就地 upsert)
2026-10-22 cma-map-canvas-conn-key-rename-stable(P3):
原实现是 delete+insert —— MySQL 的 AUTO_INCREMENT 计数器不随 DELETE 归零,每次保存
都会给同一节点换一批新行 id,`map_connections.from/to_objective_id` 随即变成野指针
(读侧再按 id 反查就查不到),「用 objective_id 与中文名解耦」的前提被静默破坏。
改为就地 upsert:行 id 全程稳定,连线锚点才真的能扛住改名。
定位顺序:dims 的 `_mo_id`(读侧由 _merge_map_objectives 回写)→ (dimension_key, 归一化名称)。
**不做位置兜底**(口径 B,ASK-20261023-04 任总 2026-10-23 15:13「放行 4」):
未命中名字的项一律视为新目标,未被占用的存量行一律视为已删除。理由见下方 ③。
"""
dims = _dims_list(m.dimensions)
entity_id = m.entity_id if m.entity_id is not None else 1
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
MapObjective.sort_order, MapObjective.id).all()
by_id = {r.id: r for r in rows}
used = set()
# 计划项:按 dims 顺序展开
planned = []
for dim in dims:
for i, obj in enumerate(dim.get("objectives", [])):
mo = MapObjective(
map_id=m.id,
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
dimension_key=dim.get("key", ""),
name=obj.get("name", ""),
description=obj.get("description", ""),
icon=obj.get("icon", "target"),
kpis=obj.get("kpis", []),
sort_order=i,
)
db.add(mo)
dk = dim.get("key", "")
for i, obj in enumerate(dim.get("objectives") or []):
if isinstance(obj, dict):
planned.append((dk, i, obj))
assigned = {}
# ① 显式锚点:客户端回带的 `_mo_id`(读侧 _merge_map_objectives 写入)优先级最高
for idx, (dk, i, obj) in enumerate(planned):
try:
mo_id = int(obj.get("_mo_id")) if obj.get("_mo_id") is not None else None
except (TypeError, ValueError):
mo_id = None
if mo_id is not None and mo_id in by_id and mo_id not in used:
assigned[idx] = by_id[mo_id]
used.add(mo_id)
# ② 名称批:**先全表跑完名字匹配,再做位置兜底**。
# 逐项贪心(名→序位→空位)时,改名项在名字层落空后会立刻抢占同序位行,把
# 「同一次保存里改名 + 同层调序」的两个目标 id 串位 —— 线静默画到别的节点上,
# 比断线更坏(看不见)。批处理顺序把名字仍有效的项先钉住,未命中的项再按顺序配对。
for idx, (dk, i, obj) in enumerate(planned):
if idx in assigned:
continue
want = _norm_obj_name(obj.get("name"))
cand = [r for r in rows
if r.id not in used and r.dimension_key == dk
and _norm_obj_name(r.name) == want]
if cand:
cand.sort(key=lambda r: (r.sort_order or 0, r.id)) # 同名重复取最靠前,结果确定
assigned[idx] = cand[0]
used.add(cand[0].id)
# ③ (口径 B · ASK-20261023-04 任总 2026-10-23 15:13「放行 4」)**不做位置兜底**。
# 老通道(不带 `_mo_id`)下「删一个 + 加一个」与「改名」数据上不可区分:按位置配对
# 在其中一种情况下必然静默错绑(新项继承被删项的锚点,线正常画着却是错的)。
# 错绑比断线更坏 —— 断线在读侧 `unresolved` 里可见、可计数。故:
# · 名字未命中的项 → 一律当新目标插入(新行 id);
# · 未被占用的存量行 → 一律当已删除删除;
# · 连线锚点由此指向被删行 ⇒ 读侧记 `stale_objective_id` 并计入 unresolved。
# UI 通道(客户端回带 `_mo_id`)改名仍走 ① 精确保住锚点,本口径不影响它。
# 逐项贪心(名→序位→空位)的串位反证见 tests/test_conn_key_rename_stability.py
# 的 test_negative_greedy_matching_would_crossbind。
created = 0
keep = []
for idx, (dk, i, obj) in enumerate(planned):
target = assigned.get(idx)
if target is None:
target = MapObjective(map_id=m.id, entity_id=entity_id, dimension_key=dk or "finance")
db.add(target)
created += 1
target.entity_id = entity_id
target.dimension_key = dk
target.name = (obj.get("name") or "")[:200]
target.description = obj.get("description") or ""
target.icon = obj.get("icon") or "target"
target.kpis = obj.get("kpis") or []
target.sort_order = i
keep.append(target)
db.flush() # 新增行拿到 id 后才有 id 可比
deleted = 0
for r in rows:
if r.id not in used:
db.delete(r)
deleted += 1
db.commit()
return {"planned": len(planned), "created": created, "deleted": deleted,
"kept": len(used)}
def _merge_map_objectives(m, db):
@@ -978,6 +1156,9 @@ def _merge_map_objectives(m, db):
"icon": o.icon or existing.get("icon", "target"),
"kpis": o.kpis or existing.get("kpis", []),
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
# P3:回写本表行 id(连线锚点的落点)。只在读响应里出现,不落 dimensions JSON;
# 客户端原样带回保存时,_sync_map_objectives 据此就地更新、保住行 id。
"_mo_id": o.id,
})
dim["objectives"] = merged
m.dimensions = dims