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:
+85
-2
@@ -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)}
|
||||
|
||||
|
||||
# ── 版本管理 ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user