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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user