fix(maps): 战略地图写通道乐观并发 + 画布自动保存仅脏时写

生产缺陷 cma-strategy-map-autosave-stale-overwrite(D4 批次 19 行 map_objectives 被过期快照覆写):
- 后端 update_map: 覆写 dimensions/canvas_data 须带 base_updated_at,与服务端 updated_at 不符 → 409;
  校验在任何写入之前完成(校验不过时一行都不删/不改)
- 连线写通道(POST/DELETE connections)响应补 updated_at,供前端采纳新基准、避免自伤 409
- 前端 MapCanvas: 自动保存仅在 draft + isDirty 时触发;请求体带 base_updated_at;收到 409 停自动保存不重试
- 修复 async onMounted 竞态:await 期间被卸载后其续段仍注册定时器(清理钩子先跑完、无人回收)
  → 泄漏写者永久在后台用过期快照覆写(生产 referer=/maps 仍每 30–60s 写)。加 disposed 守卫 + stopAutoSave
- 测试: backend/tests/test_map_concurrency_20261012.py(8 例,改前 5 败);前端行为台架 11/11(改前 3/11)

任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
This commit is contained in:
wecom-fullstack
2026-10-12 11:42:56 +08:00
parent 1613f7f961
commit 03254bd4c7
3 changed files with 360 additions and 17 deletions
+85 -2
View File
@@ -111,6 +111,81 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_i
return m_to_dict(m, db)
# ── 乐观并发保护(缺陷修复 cma-strategy-map-autosave-stale-overwrite, 2026-10-12)──
# 背景(生产实测,非推测):在线画布页把「浏览器本地快照」每 30s 整表 PUT 一次
# (dimensions + canvas_data)。若期间服务端已被他方写入(批处理/脚本/他方 API/他人编辑),
# 过期快照会静默回置服务端数据,并驱动 _sync_map_objectives 依快照重建节点行
# —— 实测把 19 行 map_objectives 删掉且未插回。
# 规则:凡请求体要覆写 dimensions/canvas_data 的写,必须携带客户端基准
# base_updated_at(或 updated_at;取库内既有列,不新增表/列);与库内现值不符 → 409。
# 校验在**任何写入之前**完成 ⇒ 校验不过时一行都不删/不改。
CONCURRENCY_GUARDED_FIELDS = ("dimensions", "canvas_data")
CONCURRENCY_BASE_KEYS = ("base_updated_at", "updated_at")
def _stamp(dt) -> Optional[str]:
"""库内时间戳 → 秒级 ISO 字符串(响应体/409 头统一口径)"""
if isinstance(dt, datetime):
return dt.replace(microsecond=0).isoformat()
return str(dt) if dt is not None else None
def _norm_stamp(raw) -> Optional[datetime]:
"""客户端基准 → 秒级 datetime(带时区先转服务器本地时区);不可解析返回 None"""
if raw is None:
return None
if isinstance(raw, datetime):
dt = raw
else:
s = str(raw).strip()
if not s:
return None
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt.replace(microsecond=0)
def assert_map_not_stale(m: StrategicMap, data: dict):
"""覆写 dimensions/canvas_data 前的前置校验
- 不涉及护栏字段(或值为 None ⇒ 不会被赋值)→ 直接放行(如 MapList 只改 title/version);
- 缺基准 / 基准不可解析 / 基准与服务端现值不符 → 409(fail-closed:宁拒不覆写);
- 409 响应体带 detail 文案,响应头带 X-CMA-Concurrency-Code 与 X-CMA-Map-Updated-At。
"""
overwriting = [k for k in CONCURRENCY_GUARDED_FIELDS if k in data and data.get(k) is not None]
if not overwriting:
return
base_raw = None
for k in CONCURRENCY_BASE_KEYS:
if data.get(k) is not None:
base_raw = data[k]
break
current = _stamp(m.updated_at)
if base_raw is None:
code = "concurrency_base_required"
detail = "缺少并发基准(base_updated_at):覆写战略地图内容须携带载入时的 updated_at"
else:
base = _norm_stamp(base_raw)
if base is None:
code = "concurrency_base_invalid"
detail = "并发基准(base_updated_at)格式无法解析"
elif base != _norm_stamp(m.updated_at):
code = "stale_map"
detail = "地图已被修改,刷新后重试"
else:
return
raise HTTPException(409, detail, headers={
"X-CMA-Concurrency-Code": code,
"X-CMA-Map-Updated-At": current or "",
})
@router.put("/{map_id}")
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
@@ -120,6 +195,12 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
# 并发保护(2026-10-12): 覆写 dimensions/canvas_data 前校验客户端基准,过期快照 → 409,
# 不得静默覆写;校验不过时后续任何 setattr/删行/同步都不执行。
assert_map_not_stale(m, data)
# 基准字段不是业务字段,剔除后不参与赋值
data = {k: v for k, v in data.items() if k not in CONCURRENCY_BASE_KEYS}
old_status = m.status
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
# status 允许但仅限合法值域(draft/published), 防止任意发布
@@ -227,7 +308,8 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
})
m.canvas_data["connections"] = conns
db.commit()
return {"connections": conns}
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
@router.delete("/{map_id}/connections")
@@ -248,7 +330,8 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
m.canvas_data["connections"] = new_conns
db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}}
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
"updated_at": _stamp(m.updated_at)}
# ── 版本管理 ─────────────────────────────────