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
+62 -15
View File
@@ -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秒自动保存草稿
autoSaveTimer = setInterval(() => {
if (currentMap.value?.status === 'draft') saveCanvas(true)
}, 30000)
// 自动保存:仅在「草稿 + 本地有未保存变更(isDirty) + 未被 409 停写 + 未销毁」时写入
// (缺陷修复 2026-10-12:原来无条件每 30s 用本地快照整表覆写,会回置他方写入)
if (!disposed) {
autoSaveTimer = setInterval(() => {
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>