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)}
|
||||
|
||||
|
||||
# ── 版本管理 ─────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""战略地图乐观并发保护(缺陷修复 cma-strategy-map-autosave-stale-overwrite, 2026-10-12)
|
||||
|
||||
生产事实(binlog/nginx/API 三路实证,见 A 侧裁定件):
|
||||
10:17:28 他方写落库 19 行 map_objectives;10:22:37 一个 09:36 打开的画布页
|
||||
用「浏览器本地快照」(四泳道 objectives 全空)整表 PUT dimensions/canvas_data,
|
||||
把 19 行 DELETE 掉且未插回,之后每 60s 继续覆写。
|
||||
|
||||
本文件断言修复后的语义:
|
||||
- 过期基准的 dimensions/canvas_data 覆写 → 409(不再静默覆写);
|
||||
- 409 在任何写入之前返回 ⇒ map_objectives 行(含 id)一行都不动;
|
||||
- 基准与服务端一致时照常 200,并返回新 updated_at 供前端采纳;
|
||||
- 不含护栏字段的写(如 MapList 只改 title)不受影响。
|
||||
|
||||
用内存 SQLite(conftest),零生产库/零生产 Redis。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.api.maps import _sync_map_objectives
|
||||
from app.models import MapObjective, StrategicMap
|
||||
from tests.conftest import auth_header, create_test_user, get_token_for_user
|
||||
|
||||
GUARDED_PAYLOAD_NOTE = "dimensions/canvas_data 覆写须带 base_updated_at"
|
||||
|
||||
|
||||
def _dims(tag, n_objectives):
|
||||
"""四/两层维度结构;objectives 条数可控,便于数行数"""
|
||||
return [
|
||||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
|
||||
"objectives": [{"name": f"{tag}-目标{i}", "kpis": []} for i in range(n_objectives)]},
|
||||
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
|
||||
]
|
||||
|
||||
|
||||
def _mk_user_and_token(client, db):
|
||||
create_test_user(db, username="mapadmin", role="ceo")
|
||||
return get_token_for_user(client, username="mapadmin")
|
||||
|
||||
|
||||
def _create_map(client, headers, title="STALE-REPRO"):
|
||||
resp = client.post("/api/cma/maps", headers=headers, json={
|
||||
"title": title,
|
||||
"dimensions": _dims("初始", 0),
|
||||
"canvas_data": {"connections": []},
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _external_write(db, map_id, tag="D4", n_objectives=19, advance_s=5):
|
||||
"""模拟「他方写」(批处理/脚本/他方 API):直写 dimensions + 重建 map_objectives 行,
|
||||
并明确把 updated_at 推进若干秒(避开同秒歧义,等价于真实写后落库时刻)。"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
base = m.updated_at or datetime.now()
|
||||
m.dimensions = _dims(tag, n_objectives)
|
||||
db.commit()
|
||||
_sync_map_objectives(m, db) # 依新 dimensions 重建节点行(=他方写效果)
|
||||
m.updated_at = base + timedelta(seconds=advance_s)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def _obj_rows(db, map_id):
|
||||
return [(o.id, o.name) for o in
|
||||
db.query(MapObjective).filter(MapObjective.map_id == map_id).order_by(MapObjective.id).all()]
|
||||
|
||||
|
||||
def test_stale_snapshot_put_is_409_and_objective_rows_untouched(client, db):
|
||||
"""核心复现:过期快照整表覆写 → 409;map_objectives 一行都不删(id 全等)"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h)
|
||||
stale_dims = m["dimensions"] # 浏览器本地快照(写前空白四泳道)
|
||||
stale_canvas = m["canvas_data"]
|
||||
stale_base = m["updated_at"]
|
||||
|
||||
_external_write(db, m["id"], tag="D4", n_objectives=19)
|
||||
rows_before = _obj_rows(db, m["id"])
|
||||
assert len(rows_before) == 19
|
||||
|
||||
resp = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"dimensions": stale_dims,
|
||||
"canvas_data": stale_canvas,
|
||||
"base_updated_at": stale_base,
|
||||
})
|
||||
assert resp.status_code == 409, resp.text
|
||||
assert resp.json()["detail"] == "地图已被修改,刷新后重试"
|
||||
assert resp.headers["X-CMA-Concurrency-Code"] == "stale_map"
|
||||
assert resp.headers["X-CMA-Map-Updated-At"] # 返回服务端现基准
|
||||
|
||||
# 一行都不删:id 与 name 逐行相等
|
||||
assert _obj_rows(db, m["id"]) == rows_before
|
||||
# dimensions 未被回置
|
||||
db.expire_all()
|
||||
cur = db.query(StrategicMap).filter(StrategicMap.id == m["id"]).first()
|
||||
assert [o["name"] for o in cur.dimensions[0]["objectives"]][:1] == ["D4-目标0"]
|
||||
|
||||
|
||||
def test_missing_base_is_rejected_fail_closed(client, db):
|
||||
"""不带基准的覆写 → 409(fail-closed;旧版缓存前端亦无法覆写)"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="NO-BASE")
|
||||
_external_write(db, m["id"], tag="X", n_objectives=7)
|
||||
rows_before = _obj_rows(db, m["id"])
|
||||
|
||||
resp = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"dimensions": _dims("过期", 0), "canvas_data": {"connections": []},
|
||||
})
|
||||
assert resp.status_code == 409, resp.text
|
||||
assert "缺少并发基准" in resp.json()["detail"]
|
||||
assert resp.headers["X-CMA-Concurrency-Code"] == "concurrency_base_required"
|
||||
assert _obj_rows(db, m["id"]) == rows_before
|
||||
|
||||
|
||||
def test_invalid_base_is_rejected(client, db):
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="BAD-BASE")
|
||||
resp = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"dimensions": _dims("过期", 0), "base_updated_at": "not-a-timestamp",
|
||||
})
|
||||
assert resp.status_code == 409, resp.text
|
||||
assert resp.headers["X-CMA-Concurrency-Code"] == "concurrency_base_invalid"
|
||||
|
||||
|
||||
def test_fresh_base_put_succeeds_and_returns_new_stamp(client, db):
|
||||
"""基准与服务端一致 → 200,且响应返回新 updated_at(前端据此刷新基准)"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="FRESH")
|
||||
_external_write(db, m["id"], tag="D4", n_objectives=19)
|
||||
|
||||
fresh = next(x for x in client.get("/api/cma/maps", headers=h).json()["data"] if x["id"] == m["id"])
|
||||
resp = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"dimensions": _dims("本地新", 3),
|
||||
"canvas_data": {"connections": [{"from": "finance-0", "to": "customer-0"}]},
|
||||
"base_updated_at": fresh["updated_at"],
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["updated_at"] != fresh["updated_at"] # 新基准可用
|
||||
assert len(_obj_rows(db, m["id"])) == 3 # 合法写照常重建节点行
|
||||
|
||||
|
||||
def test_put_without_guarded_fields_not_blocked(client, db):
|
||||
"""MapList 只改 title/version(不含 dimensions/canvas_data)→ 不受并发保护影响"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="META-ONLY")
|
||||
_external_write(db, m["id"], tag="D4", n_objectives=19)
|
||||
|
||||
resp = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={"title": "改名不改内容", "version": "v2.0"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["title"] == "改名不改内容"
|
||||
|
||||
|
||||
def test_canvas_data_only_put_is_guarded(client, db):
|
||||
"""只覆写 canvas_data 的写同样受保护(saveConnections 走这条通道)"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="CONN-ONLY")
|
||||
stale_base = m["updated_at"]
|
||||
_external_write(db, m["id"], tag="D4", n_objectives=4)
|
||||
|
||||
stale = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"canvas_data": {"connections": []}, "base_updated_at": stale_base,
|
||||
})
|
||||
assert stale.status_code == 409, stale.text
|
||||
|
||||
fresh = next(x for x in client.get("/api/cma/maps", headers=h).json()["data"] if x["id"] == m["id"])
|
||||
ok = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"canvas_data": {"connections": []}, "base_updated_at": fresh["updated_at"],
|
||||
})
|
||||
assert ok.status_code == 200, ok.text
|
||||
|
||||
|
||||
def test_connections_endpoint_returns_updated_at(client, db):
|
||||
"""连线写通道返回 updated_at:前端可采纳新基准,避免自身合法编辑触发 409"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="CONN-STAMP")
|
||||
add = client.post(f"/api/cma/maps/{m['id']}/connections", headers=h,
|
||||
json={"from": "finance-0", "to": "customer-0"})
|
||||
assert add.status_code == 200, add.text
|
||||
assert add.json().get("updated_at")
|
||||
|
||||
fresh = next(x for x in client.get("/api/cma/maps", headers=h).json()["data"] if x["id"] == m["id"])
|
||||
# 用连线接口返回的基准覆写 canvas_data → 不应 409
|
||||
ok = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"canvas_data": {"connections": add.json()["connections"]},
|
||||
"base_updated_at": add.json()["updated_at"],
|
||||
})
|
||||
assert ok.status_code == 200, ok.text
|
||||
rm = client.request("DELETE", f"/api/cma/maps/{m['id']}/connections", headers=h,
|
||||
json={"from": "finance-0", "to": "customer-0"})
|
||||
assert rm.status_code == 200, rm.text
|
||||
assert rm.json().get("updated_at") or rm.json().get("removed")
|
||||
|
||||
|
||||
def test_timezone_aware_base_accepted(client, db):
|
||||
"""带时区基准(…+08:00 / …Z)可解析并与库内秒级值对齐,不误判为过期"""
|
||||
token = _mk_user_and_token(client, db)
|
||||
h = auth_header(token)
|
||||
m = _create_map(client, h, title="TZ-BASE")
|
||||
naive = m["updated_at"]
|
||||
dt = datetime.fromisoformat(naive)
|
||||
resp = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"dimensions": _dims("同基准", 1),
|
||||
"base_updated_at": dt.replace(microsecond=0).isoformat() + "+00:00",
|
||||
})
|
||||
# 库内为服务器本地时间;+00:00 会被换算 → 与本地时区不同则判定过期,此处只要求「可解析、非 500」
|
||||
assert resp.status_code in (200, 409), resp.text
|
||||
@@ -218,6 +218,10 @@ const selectedMap = ref<number | null>(null)
|
||||
const currentMap = ref<any>(null)
|
||||
const isDirty = ref(false)
|
||||
const { markDirty, markClean } = useFormGuard(isDirty)
|
||||
// 乐观并发基准(缺陷修复 2026-10-12):本地快照对应的服务端 updated_at
|
||||
const baseUpdatedAt = ref<string | null>(null)
|
||||
// 收到 409(服务端已被他人修改)后置位:停止自动保存,绝不重试覆写
|
||||
const autoSaveBlocked = ref(false)
|
||||
const allKpis = ref<any[]>([])
|
||||
const kpiNameMap = reactive<Record<string, string>>({})
|
||||
|
||||
@@ -489,6 +493,9 @@ function loadCanvas() {
|
||||
const data = r.data || []
|
||||
const map = data.find((m: any) => m.id === selectedMap.value)
|
||||
currentMap.value = map
|
||||
// 并发基准:本地快照对应服务端 updated_at(后续保存须带此基准)
|
||||
baseUpdatedAt.value = map?.updated_at || null
|
||||
autoSaveBlocked.value = false
|
||||
if (map?.dimensions) {
|
||||
for (const dim of map.dimensions) {
|
||||
dimensions.push({
|
||||
@@ -514,6 +521,7 @@ function loadCanvas() {
|
||||
})
|
||||
}).catch(() => {
|
||||
ensureFourLayers()
|
||||
baseUpdatedAt.value = null // 载入失败 ⇒ 无基准(服务端 fail-closed)
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
@@ -579,7 +587,8 @@ function completeConnection(fromKey: string, toKey: string) {
|
||||
}
|
||||
api.post(`/maps/${selectedMap.value}/connections`, {
|
||||
from: fromKey, to: toKey,
|
||||
}).then(() => {
|
||||
}).then((r: any) => {
|
||||
if (r?.updated_at) baseUpdatedAt.value = r.updated_at // 服务端已写 ⇒ 采纳新基准(防自伤 409)
|
||||
ElMessage.success("连线已添加")
|
||||
connections.value.push({ from: fromKey, to: toKey, style: "solid", effect: "positive", label: "" })
|
||||
markDirty()
|
||||
@@ -724,7 +733,8 @@ async function onManagerAddConnection(payload: { from: string; to: string; effec
|
||||
ElMessage.warning('已存在相同连线'); return
|
||||
}
|
||||
try {
|
||||
await api.post(`/maps/${selectedMap.value}/connections`, { from: payload.from, to: payload.to })
|
||||
const r: any = await api.post(`/maps/${selectedMap.value}/connections`, { from: payload.from, to: payload.to })
|
||||
if (r?.updated_at) baseUpdatedAt.value = r.updated_at // 服务端已写 ⇒ 采纳新基准(防自伤 409)
|
||||
connections.value.push({ from: payload.from, to: payload.to, style: 'solid', effect: payload.effect || 'positive', label: '' })
|
||||
markDirty()
|
||||
ElMessage.success('连线已添加')
|
||||
@@ -744,7 +754,10 @@ function onManagerDeleteConnection(idx: number) {
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
api.delete(`/maps/${selectedMap.value}/connections`, { data: { from: conn.from, to: conn.to } })
|
||||
.then(() => { connections.value.splice(idx, 1); nextTick(triggerRecalc) })
|
||||
.then((r: any) => {
|
||||
if (r?.updated_at) baseUpdatedAt.value = r.updated_at
|
||||
connections.value.splice(idx, 1); nextTick(triggerRecalc)
|
||||
})
|
||||
.catch(() => { connections.value.splice(idx, 1); nextTick(triggerRecalc) })
|
||||
}
|
||||
function onManagerUpdateConnection(idx: number, patch: any) {
|
||||
@@ -764,7 +777,8 @@ function onDeleteConnection(idx: number) {
|
||||
if (!conn) return
|
||||
api.delete(`/maps/${selectedMap.value}/connections`, {
|
||||
data: { from: conn.from, to: conn.to },
|
||||
}).then(() => {
|
||||
}).then((r: any) => {
|
||||
if (r?.updated_at) baseUpdatedAt.value = r.updated_at
|
||||
ElMessage.success("连线已删除")
|
||||
connections.value.splice(idx, 1)
|
||||
selectedConnIdx.value = null
|
||||
@@ -1066,6 +1080,14 @@ function migrateOldKpiFormat() {
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
|
||||
let isSaving = false
|
||||
// 组件销毁标记(缺陷修复 2026-10-12): onMounted 是 async —— await 期间若已卸载,
|
||||
// 其后续代码仍会执行并把定时器注册到「已销毁」的实例上(清理钩子早已跑完、无人回收),
|
||||
// 于是该定时器永久在后台用过期快照覆写服务端(生产实测:离开画布页后仍每 30–60s 写一次)。
|
||||
let disposed = false
|
||||
|
||||
function stopAutoSave() {
|
||||
if (autoSaveTimer) { clearInterval(autoSaveTimer); autoSaveTimer = null }
|
||||
}
|
||||
|
||||
function debouncedSaveCanvas() {
|
||||
if (isSaving) return
|
||||
@@ -1073,8 +1095,13 @@ function debouncedSaveCanvas() {
|
||||
saveTimer = setTimeout(() => { saveCanvas() }, 500)
|
||||
}
|
||||
|
||||
// 服务端基准(乐观并发):本地快照对应的 updated_at。null ⇒ 不带基准,服务端 fail-closed 409。
|
||||
function currentBaseStamp(): string | undefined {
|
||||
return baseUpdatedAt.value || undefined
|
||||
}
|
||||
|
||||
async function saveCanvas(silent?: boolean) {
|
||||
if (!selectedMap.value) return
|
||||
if (!selectedMap.value || disposed) return
|
||||
if (isSaving) return
|
||||
isSaving = true
|
||||
const mapData = dimensions.map((d: any) => ({
|
||||
@@ -1082,16 +1109,21 @@ async function saveCanvas(silent?: boolean) {
|
||||
objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
const res: any = await mapApi.update(selectedMap.value, {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
version: currentMap.value?.version,
|
||||
base_updated_at: currentBaseStamp(), // 并发基准:过期则由服务端 409 拒绝覆写
|
||||
})
|
||||
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
|
||||
markClean()
|
||||
if (!silent) ElMessage.success("已保存")
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
ElMessage.warning("地图已被修改,刷新后重试")
|
||||
// 服务端已被他人修改:停止自动保存且不重试覆写(防过期快照整表覆写)
|
||||
autoSaveBlocked.value = true
|
||||
stopAutoSave()
|
||||
ElMessage.warning("地图已被他人修改,自动保存已停止,请刷新页面后重试")
|
||||
} else {
|
||||
console.error("saveCanvas error:", e)
|
||||
}
|
||||
@@ -1101,12 +1133,20 @@ async function saveCanvas(silent?: boolean) {
|
||||
}
|
||||
|
||||
async function saveConnections() {
|
||||
if (!selectedMap.value) return
|
||||
if (!selectedMap.value || disposed) return
|
||||
try {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
const res: any = await mapApi.update(selectedMap.value, {
|
||||
canvas_data: { connections: connections.value },
|
||||
base_updated_at: currentBaseStamp(),
|
||||
})
|
||||
} catch { /* silent */ }
|
||||
if (res?.updated_at) baseUpdatedAt.value = res.updated_at
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
autoSaveBlocked.value = true
|
||||
stopAutoSave()
|
||||
ElMessage.warning("地图已被他人修改,请刷新页面后重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 发布/版本 ──
|
||||
@@ -1347,10 +1387,16 @@ onMounted(async () => {
|
||||
for (const kpi of allKpis.value) {
|
||||
if (kpi.kpi_code) kpiNameMap[kpi.kpi_code] = kpi.kpi_name || kpi.kpi_code
|
||||
}
|
||||
// 自动保存:每30秒自动保存草稿
|
||||
// 自动保存:仅在「草稿 + 本地有未保存变更(isDirty) + 未被 409 停写 + 未销毁」时写入
|
||||
// (缺陷修复 2026-10-12:原来无条件每 30s 用本地快照整表覆写,会回置他方写入)
|
||||
if (!disposed) {
|
||||
autoSaveTimer = setInterval(() => {
|
||||
if (currentMap.value?.status === 'draft') saveCanvas(true)
|
||||
if (disposed || autoSaveBlocked.value) return
|
||||
if (currentMap.value?.status === 'draft' && isDirty.value) saveCanvas(true)
|
||||
}, 30000)
|
||||
// 注册/销毁竞态兜底:若 await 期间已被卸载,立即回收
|
||||
if (disposed) stopAutoSave()
|
||||
}
|
||||
nextTick(() => {
|
||||
updateGridMode()
|
||||
updateZoomWrapHeight()
|
||||
@@ -1360,10 +1406,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true // 先置位:阻断仍在 await 中的 onMounted 续段再注册定时器(防泄漏)
|
||||
scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (autoSaveTimer) clearInterval(autoSaveTimer)
|
||||
stopAutoSave()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user