feat(maps): 战略地图按 objective 粒度合并(granular 通道),不同 objective 并发不再互相 409
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
This commit is contained in:
+253
-23
@@ -9,6 +9,7 @@ from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
|
||||
@@ -151,16 +152,8 @@ def _norm_stamp(raw) -> Optional[datetime]:
|
||||
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
|
||||
def _require_base_stamp(m: StrategicMap, data: dict) -> datetime:
|
||||
"""并发基准唯一取值口:缺失 / 不可解析 → 409 fail-closed(调用方据此保证「一行都不改」)"""
|
||||
base_raw = None
|
||||
for k in CONCURRENCY_BASE_KEYS:
|
||||
if data.get(k) is not None:
|
||||
@@ -172,20 +165,244 @@ def assert_map_not_stale(m: StrategicMap, data: dict):
|
||||
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
|
||||
if base is not None:
|
||||
return base
|
||||
code = "concurrency_base_invalid"
|
||||
detail = "并发基准(base_updated_at)格式无法解析"
|
||||
raise HTTPException(409, detail, headers={
|
||||
"X-CMA-Concurrency-Code": code,
|
||||
"X-CMA-Map-Updated-At": current or "",
|
||||
})
|
||||
|
||||
|
||||
def assert_map_not_stale(m: StrategicMap, data: dict, fields=CONCURRENCY_GUARDED_FIELDS):
|
||||
"""覆写 <fields> 前的前置校验(A 兜底 —— 本单不得移除)
|
||||
|
||||
- 不涉及护栏字段(或值为 None ⇒ 不会被赋值)→ 直接放行(如 MapList 只改 title/version);
|
||||
- 缺基准 / 基准不可解析 / 基准与服务端现值不符 → 409(fail-closed:宁拒不覆写);
|
||||
- 409 响应体带 detail 文案,响应头带 X-CMA-Concurrency-Code 与 X-CMA-Map-Updated-At。
|
||||
- fields 参数供 P2 的按 objective 粒度通道复用(该通道只对 canvas_data 沿用本粒度)。
|
||||
"""
|
||||
overwriting = [k for k in fields if k in data and data.get(k) is not None]
|
||||
if not overwriting:
|
||||
return
|
||||
base = _require_base_stamp(m, data)
|
||||
if base != _norm_stamp(m.updated_at):
|
||||
raise HTTPException(409, "地图已被修改,刷新后重试", headers={
|
||||
"X-CMA-Concurrency-Code": "stale_map",
|
||||
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
|
||||
})
|
||||
|
||||
|
||||
# ── 按 objective 粒度合并(P2 cma-strategy-map-objective-merge, 2026-10-12)──────
|
||||
# 残留面(A 落地后):dimensions 仍是整表提交与覆写 ⇒ 两个会话编辑同一地图的**不同** objective 时,
|
||||
# 后到者带过期基准被 409 拒绝:安全,但用户被迫整页刷新重做,且 409 语义被稀释
|
||||
# (真冲突与「互不相干的并发」同码)。
|
||||
#
|
||||
# 本层把写通道从「整表覆写」收敛为「按 objective 粒度合并」:服务端保留未被本地改动的节点
|
||||
# (含他方新增/删除的节点),仅对本地脏项做增/改/删;校验仍在任何写之前完成。
|
||||
#
|
||||
# 请求契约(全部沿用既有列,不新增表/列):
|
||||
# dimensions 本地快照(结构/顺序;未标脏的节点内容服务端一律不采纳)
|
||||
# dirty_objectives [{"dim_key": "finance", "objective_id": 12|null, "name": "目标A",
|
||||
# "base": {...}(客户端载入时该目标的服务端原文)|null(本地新增),
|
||||
# "value": {...}(新值)|null(本地删除)}]
|
||||
# canvas_data 与库内现值一致 ⇒ 视为未改动(不覆写);有实质差异 ⇒ 退回 A 的 map 级校验
|
||||
# base_updated_at 仍必需(缺失/不可解析 → 409,与 A 同)
|
||||
#
|
||||
# 冲突判定:脏项 base 与服务端现值不一致 ⇒ **同一 objective** 被并发改 ⇒ 409
|
||||
# X-CMA-Concurrency-Code: stale_objective(内容冲突)/ stale_objective_missing(已被他人删除)
|
||||
DIRTY_OBJECTIVES_KEY = "dirty_objectives"
|
||||
|
||||
|
||||
def _is_granular_payload(data: dict) -> bool:
|
||||
"""按 objective 粒度通道的开关:请求携带 dirty_objectives 列表(旧调用方不带 ⇒ 旧整表语义)"""
|
||||
return isinstance(data.get(DIRTY_OBJECTIVES_KEY), list)
|
||||
|
||||
|
||||
def _dims_list(raw) -> list:
|
||||
"""dimensions 容错读:str / None / 非 list → [];只保留 dict 元素"""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except ValueError:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [d for d in raw if isinstance(d, dict)]
|
||||
|
||||
|
||||
def _obj_tag(o) -> Optional[int]:
|
||||
"""objective 的服务端行 id(_objective_id / objective_id),无则 None"""
|
||||
if not isinstance(o, dict):
|
||||
return None
|
||||
raw = o.get("_objective_id")
|
||||
if raw is None:
|
||||
raw = o.get("objective_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _obj_key(dim_key: str, o) -> tuple:
|
||||
"""objective 稳定标识:优先行 id,退化到 (维度, 名称)"""
|
||||
tag = _obj_tag(o)
|
||||
if tag is not None:
|
||||
return (dim_key, "id", tag)
|
||||
return (dim_key, "name", str((o or {}).get("name") or "").strip())
|
||||
|
||||
|
||||
def _obj_name_key(dim_key: str, o) -> tuple:
|
||||
return (dim_key, "name", str((o or {}).get("name") or "").strip())
|
||||
|
||||
|
||||
def _canon_obj(o) -> str:
|
||||
"""objective 内容归一化(并发冲突比对):丢 _ 前缀簿记键 / None / 空串,稳定序列化"""
|
||||
def clean(v):
|
||||
if isinstance(v, dict):
|
||||
return {k: clean(x) for k, x in v.items()
|
||||
if not str(k).startswith("_") and x is not None and x != ""}
|
||||
if isinstance(v, list):
|
||||
return [clean(x) for x in v]
|
||||
return v
|
||||
return json.dumps(clean(o or {}), ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def _canon_json(v) -> str:
|
||||
return json.dumps(v, ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _canon_canvas(v) -> str:
|
||||
"""canvas_data 归一化:None / {} / {'connections': []} 等价 ⇒ 视为「无改动」"""
|
||||
if not isinstance(v, dict):
|
||||
return _canon_json({"connections": []})
|
||||
conns = v.get("connections")
|
||||
return _canon_json({"connections": conns if isinstance(conns, list) else []})
|
||||
|
||||
|
||||
def _find_objective(dim: dict, probe, dim_key: str) -> Optional[int]:
|
||||
"""在 dim.objectives 中定位(先按行 id,退化按名称);返回下标,未命中 None"""
|
||||
objs = dim.get("objectives") or []
|
||||
key = _obj_key(dim_key, probe)
|
||||
name_key = _obj_name_key(dim_key, probe)
|
||||
for i, o in enumerate(objs):
|
||||
if _obj_key(dim_key, o) == key:
|
||||
return i
|
||||
for i, o in enumerate(objs):
|
||||
if _obj_key(dim_key, o) == name_key:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _raise_stale_objective(m: StrategicMap, name: str, why: str):
|
||||
raise HTTPException(409, f"目标「{name}」{why},刷新后重试", headers={
|
||||
"X-CMA-Concurrency-Code": "stale_objective",
|
||||
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
|
||||
})
|
||||
|
||||
|
||||
def _merge_dimensions_by_objective(m: StrategicMap, data: dict):
|
||||
"""按 objective 粒度合并 dimensions;与库内现值等价时返回 None(调用方不写该字段)
|
||||
|
||||
顺序:
|
||||
① 冲突校验(全部脏项,任何写之前):脏项 base(客户端载入时原文)≠ 服务端现值 ⇒ 409;
|
||||
② 合并:仅对脏项增/改/删,服务端其余节点(含他方新增)原样保留;
|
||||
③ 顺序:本地脏过的维度按提交快照顺序重排(服务端独有节点稳定排末尾);
|
||||
④ 维度层元数据不在本粒度内(无编辑入口)⇒ 服务端保留现值,新维度取提交元数据。
|
||||
"""
|
||||
dims_in = _dims_list(data.get("dimensions"))
|
||||
dirty = [e for e in (data.get(DIRTY_OBJECTIVES_KEY) or []) if isinstance(e, dict)]
|
||||
current = copy.deepcopy(_dims_list(m.dimensions))
|
||||
submitted_by_key = {d.get("key") or "": d for d in dims_in}
|
||||
server_by_key = {d.get("key") or "": d for d in current}
|
||||
|
||||
# ── ① 冲突校验(fail-closed:任一脏项冲突即整体 409,此处尚未动任何数据)──
|
||||
for e in dirty:
|
||||
base = e.get("base")
|
||||
if base is None:
|
||||
continue # 本地新增:无基准可比
|
||||
dk = e.get("dim_key") or ""
|
||||
srv_dim = server_by_key.get(dk)
|
||||
srv_obj = None
|
||||
if srv_dim is not None:
|
||||
pos = _find_objective(srv_dim, e, dk)
|
||||
if pos is not None:
|
||||
srv_obj = (srv_dim.get("objectives") or [])[pos]
|
||||
name = str(e.get("name") or (base or {}).get("name") or "")
|
||||
if srv_obj is None:
|
||||
_raise_stale_objective(m, name, "已被他人删除")
|
||||
if _canon_obj(srv_obj) != _canon_obj(base):
|
||||
_raise_stale_objective(m, name, "已被他人修改")
|
||||
|
||||
# ── ② 合并 ──
|
||||
merged = copy.deepcopy(current)
|
||||
merged_by_key = {d.get("key") or "": d for d in merged}
|
||||
touched = []
|
||||
for e in dirty:
|
||||
dk = e.get("dim_key") or ""
|
||||
dim = merged_by_key.get(dk)
|
||||
if dim is None:
|
||||
sub = submitted_by_key.get(dk) or {"key": dk}
|
||||
dim = {"key": dk, "name": sub.get("name") or sub.get("label") or dk,
|
||||
"icon": sub.get("icon"), "color": sub.get("color"), "objectives": []}
|
||||
merged.append(dim)
|
||||
merged_by_key[dk] = dim
|
||||
if dk not in touched:
|
||||
touched.append(dk)
|
||||
value = e.get("value")
|
||||
if value is None: # 本地删除:定位不到 ⇒ 已被他人删除(幂等)
|
||||
pos = _find_objective(dim, e, dk)
|
||||
if pos is not None:
|
||||
dim.setdefault("objectives", []).pop(pos)
|
||||
continue
|
||||
if e.get("base") is None: # 本地新增:追加(绝不覆盖服务端既有同名节点)
|
||||
dim.setdefault("objectives", []).append(copy.deepcopy(value))
|
||||
continue
|
||||
pos = _find_objective(dim, e, dk)
|
||||
if pos is None:
|
||||
dim.setdefault("objectives", []).append(copy.deepcopy(value))
|
||||
else:
|
||||
dim["objectives"][pos] = copy.deepcopy(value)
|
||||
|
||||
# ── ③ 顺序:本地脏过的维度按提交快照重排 ──
|
||||
for dk in touched:
|
||||
dim = merged_by_key[dk]
|
||||
sub_objs = (submitted_by_key.get(dk) or {}).get("objectives") or []
|
||||
ranks = []
|
||||
for o in sub_objs:
|
||||
ranks.append(_obj_key(dk, o))
|
||||
ranks.append(_obj_name_key(dk, o))
|
||||
|
||||
def _rank(o, ranks=ranks, dk=dk):
|
||||
for cand in (_obj_key(dk, o), _obj_name_key(dk, o)):
|
||||
if cand in ranks:
|
||||
return ranks.index(cand)
|
||||
return len(ranks)
|
||||
|
||||
dim["objectives"] = sorted(dim.get("objectives") or [], key=_rank)
|
||||
|
||||
if _canon_json(merged) == _canon_json(current):
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def _guard_canvas_data_granular(m: StrategicMap, data: dict):
|
||||
"""granular 通道下 canvas_data 的护栏:
|
||||
与库内现值一致(含 None / {} / {'connections': []} 等价)⇒ 视为未改动,剔除该字段不覆写;
|
||||
有实质差异 ⇒ 退回 A 的 map 级基准校验(过期 ⇒ 409 stale_map)。
|
||||
"""
|
||||
if data.get("canvas_data") is None:
|
||||
data.pop("canvas_data", None)
|
||||
return
|
||||
if _canon_canvas(data["canvas_data"]) == _canon_canvas(m.canvas_data):
|
||||
data.pop("canvas_data", None)
|
||||
return
|
||||
assert_map_not_stale(m, data, fields=("canvas_data",))
|
||||
|
||||
|
||||
@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()
|
||||
@@ -195,11 +412,24 @@ 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}
|
||||
# 并发保护(A 兜底 2026-10-12 + 按 objective 粒度合并 P2 2026-10-12):
|
||||
# - 旧调用方(不带 dirty_objectives)⇒ 整表语义不变:覆写 dimensions/canvas_data 前校验基准;
|
||||
# - 带 dirty_objectives ⇒ 按 objective 粒度合并:基准仍必需(fail-closed),但只有「同一 objective」
|
||||
# 被并发改才 409(stale_objective);他方在其它 objective 上的改动被保留而非拒绝。
|
||||
# 校验全部在任何 setattr/删行/同步之前完成 ⇒ 校验不过时一行都不改。
|
||||
if _is_granular_payload(data):
|
||||
_require_base_stamp(m, data)
|
||||
_guard_canvas_data_granular(m, data)
|
||||
merged_dims = _merge_dimensions_by_objective(m, data)
|
||||
if merged_dims is None:
|
||||
data.pop("dimensions", None) # 合并结果与库内现值等价 ⇒ 不写该字段
|
||||
else:
|
||||
data["dimensions"] = merged_dims
|
||||
else:
|
||||
assert_map_not_stale(m, data)
|
||||
# 基准字段/脏项明细不是业务字段,剔除后不参与赋值
|
||||
data = {k: v for k, v in data.items()
|
||||
if k not in CONCURRENCY_BASE_KEYS and k != DIRTY_OBJECTIVES_KEY}
|
||||
|
||||
old_status = m.status
|
||||
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""按 objective 粒度合并(P2 cma-strategy-map-objective-merge, 2026-10-12)
|
||||
|
||||
残留面(A 上线后):dimensions 仍是整表提交与覆写 ⇒ 两个会话编辑同一地图的**不同** objective 时,
|
||||
后到者带过期基准被 409 拒绝(安全,但体验有税;409 语义被稀释)。
|
||||
|
||||
本文件断言 P2 落地后的语义:
|
||||
- 不同 objective 并发改 ⇒ 两个会话都 200,双方改动都在(他方新增/删除的节点被保留,不被整表回置);
|
||||
- 同一 objective 并发改 ⇒ 后者 409(X-CMA-Concurrency-Code: stale_objective),且一行都不改;
|
||||
- 无脏项 ⇒ 不写(即使基准已过期也不 409、也不覆写);
|
||||
- canvas_data:与库内现值一致 ⇒ 不参与覆写;有实质差异 ⇒ 退回 A 的 map 级校验(过期 409 stale_map);
|
||||
- granular 通道仍要求基准(缺/不可解析 → 409);A 的整表兜底不得移除(对照用例逐条验证)。
|
||||
|
||||
时间戳说明:库内 updated_at 由 SQLite 的 func.now()(秒级)产生,用例内的连续写无法自然拉开时刻,
|
||||
故用 `_advance()` 显式推进 updated_at 若干秒,模拟「他方写已落库、本地基准已过期」的生产时刻差。
|
||||
它只改时间戳,不改任何业务列,也不改断言目标。
|
||||
|
||||
用内存 SQLite(conftest),零生产库/零生产 Redis。
|
||||
"""
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
|
||||
from app.models import MapObjective, StrategicMap
|
||||
from tests.conftest import auth_header, create_test_user, get_token_for_user
|
||||
|
||||
BASE = "2026-10-12T09:36:02"
|
||||
|
||||
|
||||
def _dims(tag, names=("目标A", "目标B")):
|
||||
"""两层维度结构:finance 带 objectives(名称可控),customer 空"""
|
||||
return [
|
||||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
|
||||
"objectives": [{"name": n, "description": f"{tag}-{n}"} for n in names]},
|
||||
{"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="MERGE-P2", names=("目标A", "目标B")):
|
||||
resp = client.post("/api/cma/maps", headers=headers, json={
|
||||
"title": title, "dimensions": _dims("初始", names), "canvas_data": {"connections": []},
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _snapshot(client, headers, map_id):
|
||||
"""会话载入:GET /maps 取该地图(含 updated_at 基准 + dimensions 原文)"""
|
||||
return next(x for x in client.get("/api/cma/maps", headers=headers).json()["data"] if x["id"] == map_id)
|
||||
|
||||
|
||||
def _advance(db, map_id, seconds=30):
|
||||
"""显式推进服务端 updated_at:模拟「他方写落库后本地基准已过期」的时刻差(只动时间戳)"""
|
||||
db.expire_all()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m.updated_at = (m.updated_at or m.created_at) + timedelta(seconds=seconds)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
|
||||
def _map_dims(db, map_id):
|
||||
db.expire_all()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
return copy.deepcopy(m.dimensions)
|
||||
|
||||
|
||||
def _fin_objs(db, map_id):
|
||||
"""finance 层 objectives(存库顺序)"""
|
||||
for d in _map_dims(db, map_id):
|
||||
if d.get("key") == "finance":
|
||||
return d.get("objectives") or []
|
||||
return []
|
||||
|
||||
|
||||
def _obj_rows(db, map_id):
|
||||
"""map_objectives 行(id, name, sort_order):用于证明 409 时一行都不改"""
|
||||
db.expire_all()
|
||||
return [(o.id, o.name, o.sort_order) for o in
|
||||
db.query(MapObjective).filter(MapObjective.map_id == map_id)
|
||||
.order_by(MapObjective.sort_order, MapObjective.id).all()]
|
||||
|
||||
|
||||
def _dirty(dim_key, name, base, value):
|
||||
return {"dim_key": dim_key, "objective_id": None, "name": name, "base": base, "value": value}
|
||||
|
||||
|
||||
def _put(client, headers, map_id, dims, dirty, base, canvas=None):
|
||||
payload = {"dimensions": dims, "dirty_objectives": dirty, "base_updated_at": base}
|
||||
if canvas is not None:
|
||||
payload["canvas_data"] = canvas
|
||||
return client.put(f"/api/cma/maps/{map_id}", headers=headers, json=payload)
|
||||
|
||||
|
||||
# ── ① 不同 objective 并发 ⇒ 均成功且双方改动都在 ────────────────────────
|
||||
|
||||
def test_two_sessions_edit_different_objectives_both_land(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap_a = _snapshot(client, h, m["id"])
|
||||
snap_b = _snapshot(client, h, m["id"])
|
||||
base = snap_a["updated_at"]
|
||||
|
||||
# 会话A:改 目标A
|
||||
dims_a = copy.deepcopy(snap_a["dimensions"])
|
||||
dims_a[0]["objectives"][0]["description"] = "A改"
|
||||
r1 = _put(client, h, m["id"], dims_a,
|
||||
[_dirty("finance", "目标A", snap_a["dimensions"][0]["objectives"][0],
|
||||
dims_a[0]["objectives"][0])],
|
||||
base, canvas=snap_a["canvas_data"])
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
_advance(db, m["id"], 30) # 时钟推进 ⇒ 会话B 的基准确定过期
|
||||
|
||||
# 会话B:改 目标B(改的是**不同** objective ⇒ 不应被 409 拒)
|
||||
dims_b = copy.deepcopy(snap_b["dimensions"])
|
||||
dims_b[0]["objectives"][1]["description"] = "B改"
|
||||
r2 = _put(client, h, m["id"], dims_b,
|
||||
[_dirty("finance", "目标B", snap_b["dimensions"][0]["objectives"][1],
|
||||
dims_b[0]["objectives"][1])],
|
||||
base, canvas=snap_b["canvas_data"])
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
fin = _fin_objs(db, m["id"])
|
||||
assert [o["name"] for o in fin] == ["目标A", "目标B"]
|
||||
assert fin[0]["description"] == "A改" # 会话A 的改动保留
|
||||
assert fin[1]["description"] == "B改" # 会话B 的改动落库
|
||||
assert len(_obj_rows(db, m["id"])) == 2
|
||||
|
||||
# 对照(A 的整表兜底不得移除):同一份过期快照走旧通道(去掉 dirty_objectives)⇒ 409
|
||||
# 注:服务端每次写都会把 updated_at 刷成真实 now(SQLite func.now(),秒级),
|
||||
# 故对照前需再推进一次时刻,才能真正落到「基准过期」的生产形态。
|
||||
_advance(db, m["id"], 90)
|
||||
legacy = client.put(f"/api/cma/maps/{m['id']}", headers=h, json={
|
||||
"dimensions": dims_b, "canvas_data": snap_b["canvas_data"], "base_updated_at": base,
|
||||
})
|
||||
assert legacy.status_code == 409, legacy.text
|
||||
assert legacy.headers["X-CMA-Concurrency-Code"] == "stale_map"
|
||||
|
||||
|
||||
# ── ② 同一 objective 并发 ⇒ 后者 409,且一行都不改 ───────────────────────
|
||||
|
||||
def test_same_objective_concurrent_second_is_409_and_nothing_changes(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap_a = _snapshot(client, h, m["id"])
|
||||
snap_b = _snapshot(client, h, m["id"])
|
||||
base = snap_a["updated_at"]
|
||||
|
||||
dims_a = copy.deepcopy(snap_a["dimensions"])
|
||||
dims_a[0]["objectives"][0]["description"] = "A改"
|
||||
assert _put(client, h, m["id"], dims_a,
|
||||
[_dirty("finance", "目标A", snap_a["dimensions"][0]["objectives"][0],
|
||||
dims_a[0]["objectives"][0])], base).status_code == 200
|
||||
|
||||
_advance(db, m["id"], 30)
|
||||
rows_before = _obj_rows(db, m["id"])
|
||||
|
||||
dims_b = copy.deepcopy(snap_b["dimensions"])
|
||||
dims_b[0]["objectives"][0]["description"] = "B改"
|
||||
r = _put(client, h, m["id"], dims_b,
|
||||
[_dirty("finance", "目标A", snap_b["dimensions"][0]["objectives"][0],
|
||||
dims_b[0]["objectives"][0])], base)
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.headers["X-CMA-Concurrency-Code"] == "stale_objective"
|
||||
assert "目标A" in r.json()["detail"]
|
||||
assert r.headers["X-CMA-Map-Updated-At"]
|
||||
|
||||
assert _obj_rows(db, m["id"]) == rows_before # 行一行不动(含 id)
|
||||
assert _fin_objs(db, m["id"])[0]["description"] == "A改" # 内容不被回置
|
||||
|
||||
|
||||
# ── ③ 他方新增的节点被保留(残留缺陷面:不再被整表覆写删掉)────────────
|
||||
|
||||
def test_granular_merge_preserves_objective_added_by_other_session(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap_a = _snapshot(client, h, m["id"])
|
||||
snap_b = _snapshot(client, h, m["id"])
|
||||
|
||||
# 会话A:新增 目标C(base=None ⇒ 本地新增)
|
||||
dims_a = copy.deepcopy(snap_a["dimensions"])
|
||||
dims_a[0]["objectives"].append({"name": "目标C", "description": "A新增"})
|
||||
r1 = _put(client, h, m["id"], dims_a,
|
||||
[_dirty("finance", "目标C", None, dims_a[0]["objectives"][2])],
|
||||
snap_a["updated_at"])
|
||||
assert r1.status_code == 200, r1.text
|
||||
assert [o["name"] for o in _fin_objs(db, m["id"])] == ["目标A", "目标B", "目标C"]
|
||||
|
||||
_advance(db, m["id"], 30)
|
||||
|
||||
# 会话B(快照里没有 目标C):改 目标B ⇒ 200,且 目标C 必须还在
|
||||
dims_b = copy.deepcopy(snap_b["dimensions"])
|
||||
dims_b[0]["objectives"][1]["description"] = "B改"
|
||||
r2 = _put(client, h, m["id"], dims_b,
|
||||
[_dirty("finance", "目标B", snap_b["dimensions"][0]["objectives"][1],
|
||||
dims_b[0]["objectives"][1])],
|
||||
snap_b["updated_at"])
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
fin = _fin_objs(db, m["id"])
|
||||
assert [o["name"] for o in fin] == ["目标A", "目标B", "目标C"]
|
||||
assert fin[1]["description"] == "B改"
|
||||
assert fin[2]["description"] == "A新增" # 他方新增节点未被本地过期快照删掉
|
||||
assert len(_obj_rows(db, m["id"])) == 3 # map_objectives 行同样未被删
|
||||
|
||||
|
||||
# ── ④ 本地删除只删本地目标;未标脏的本地快照不回置服务端 ────────────────
|
||||
|
||||
def test_granular_delete_removes_only_local_target(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap_a = _snapshot(client, h, m["id"])
|
||||
snap_b = _snapshot(client, h, m["id"])
|
||||
|
||||
# 会话A:删除 目标A(value=None)
|
||||
dims_a = copy.deepcopy(snap_a["dimensions"])
|
||||
dims_a[0]["objectives"] = [o for o in dims_a[0]["objectives"] if o["name"] != "目标A"]
|
||||
r1 = _put(client, h, m["id"], dims_a,
|
||||
[_dirty("finance", "目标A", snap_a["dimensions"][0]["objectives"][0], None)],
|
||||
snap_a["updated_at"])
|
||||
assert r1.status_code == 200, r1.text
|
||||
assert [o["name"] for o in _fin_objs(db, m["id"])] == ["目标B"]
|
||||
|
||||
_advance(db, m["id"], 30)
|
||||
|
||||
# 会话B(快照里仍有 目标A,但没有标脏它):改 目标B ⇒ 200,且不得把 目标A 复活
|
||||
dims_b = copy.deepcopy(snap_b["dimensions"])
|
||||
dims_b[0]["objectives"][1]["description"] = "B改"
|
||||
r2 = _put(client, h, m["id"], dims_b,
|
||||
[_dirty("finance", "目标B", snap_b["dimensions"][0]["objectives"][1],
|
||||
dims_b[0]["objectives"][1])],
|
||||
snap_b["updated_at"])
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
fin = _fin_objs(db, m["id"])
|
||||
assert [o["name"] for o in fin] == ["目标B"]
|
||||
assert fin[0]["description"] == "B改"
|
||||
|
||||
|
||||
# ── ⑤ 无脏项 ⇒ 不写(过期基准也不 409、也不覆写)──────────────────────
|
||||
|
||||
def test_granular_noop_payload_does_not_write_nor_409(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap = _snapshot(client, h, m["id"])
|
||||
|
||||
dims = copy.deepcopy(snap["dimensions"])
|
||||
dims[0]["objectives"][0]["description"] = "A改"
|
||||
assert _put(client, h, m["id"], dims,
|
||||
[_dirty("finance", "目标A", snap["dimensions"][0]["objectives"][0],
|
||||
dims[0]["objectives"][0])], snap["updated_at"]).status_code == 200
|
||||
after = _snapshot(client, h, m["id"])
|
||||
rows_after = _obj_rows(db, m["id"])
|
||||
|
||||
_advance(db, m["id"], 30)
|
||||
|
||||
# 会话B:全量快照仍是载入时的旧内容、无脏项、连线一致 ⇒ 未改动 ⇒ 不写、不 409
|
||||
r = _put(client, h, m["id"], snap["dimensions"], [], snap["updated_at"],
|
||||
canvas=snap["canvas_data"])
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
now = _snapshot(client, h, m["id"])
|
||||
assert now["updated_at"] == after["updated_at"] # 无写 ⇒ 基准未被推进
|
||||
assert _fin_objs(db, m["id"])[0]["description"] == "A改" # 且未被本地过期快照回置
|
||||
assert _obj_rows(db, m["id"]) == rows_after
|
||||
|
||||
|
||||
# ── ⑥ granular 通道仍 fail-closed:基准必需 ────────────────────────────
|
||||
|
||||
def test_granular_still_requires_base(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap = _snapshot(client, h, m["id"])
|
||||
dims = copy.deepcopy(snap["dimensions"])
|
||||
dirty = [_dirty("finance", "目标A", snap["dimensions"][0]["objectives"][0],
|
||||
dims[0]["objectives"][0])]
|
||||
|
||||
missing = client.put(f"/api/cma/maps/{m['id']}", headers=h,
|
||||
json={"dimensions": dims, "dirty_objectives": dirty})
|
||||
assert missing.status_code == 409, missing.text
|
||||
assert missing.headers["X-CMA-Concurrency-Code"] == "concurrency_base_required"
|
||||
|
||||
bad = client.put(f"/api/cma/maps/{m['id']}", headers=h,
|
||||
json={"dimensions": dims, "dirty_objectives": dirty,
|
||||
"base_updated_at": "not-a-timestamp"})
|
||||
assert bad.status_code == 409, bad.text
|
||||
assert bad.headers["X-CMA-Concurrency-Code"] == "concurrency_base_invalid"
|
||||
assert len(_obj_rows(db, m["id"])) == 2
|
||||
|
||||
|
||||
# ── ⑦ canvas_data:一致 ⇒ 不覆写;有差异 + 过期基准 ⇒ 409 stale_map ─────
|
||||
|
||||
def test_granular_canvas_data_rules(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap = _snapshot(client, h, m["id"])
|
||||
dims = copy.deepcopy(snap["dimensions"])
|
||||
dims[0]["objectives"][0]["description"] = "A改"
|
||||
assert _put(client, h, m["id"], dims,
|
||||
[_dirty("finance", "目标A", snap["dimensions"][0]["objectives"][0],
|
||||
dims[0]["objectives"][0])], snap["updated_at"]).status_code == 200
|
||||
|
||||
_advance(db, m["id"], 30)
|
||||
|
||||
dirty_b = [_dirty("finance", "目标B", snap["dimensions"][0]["objectives"][1],
|
||||
snap["dimensions"][0]["objectives"][1])]
|
||||
|
||||
# 连线有实质差异(新增一条)+ 过期基准 ⇒ 退回 A 的 map 级校验 → 409
|
||||
stale_canvas = _put(client, h, m["id"], snap["dimensions"], dirty_b, snap["updated_at"],
|
||||
canvas={"connections": [{"from": "finance-0", "to": "customer-0"}]})
|
||||
assert stale_canvas.status_code == 409, stale_canvas.text
|
||||
assert stale_canvas.headers["X-CMA-Concurrency-Code"] == "stale_map"
|
||||
|
||||
# 连线与库内现值一致 ⇒ 不参与覆写 ⇒ 只有脏 objective 走合并 → 200
|
||||
ok = _put(client, h, m["id"], snap["dimensions"], dirty_b, snap["updated_at"],
|
||||
canvas=snap["canvas_data"])
|
||||
assert ok.status_code == 200, ok.text
|
||||
db.expire_all()
|
||||
cur = db.query(StrategicMap).filter(StrategicMap.id == m["id"]).first()
|
||||
assert cur.canvas_data.get("connections") in ([], None) # 连线未被本地旧快照覆写
|
||||
assert [o["name"] for o in _fin_objs(db, m["id"])] == ["目标A", "目标B"]
|
||||
|
||||
|
||||
# ── ⑧ 本地重排(全部标脏、内容不变)⇒ 顺序被采纳,他方节点稳定排末尾 ────
|
||||
|
||||
def test_granular_reorder_adopted_and_foreign_node_kept_last(client, db):
|
||||
h = auth_header(_mk_user_and_token(client, db))
|
||||
m = _create_map(client, h)
|
||||
snap_a = _snapshot(client, h, m["id"])
|
||||
snap_b = _snapshot(client, h, m["id"])
|
||||
|
||||
dims_a = copy.deepcopy(snap_a["dimensions"])
|
||||
dims_a[0]["objectives"].append({"name": "目标C", "description": "A新增"})
|
||||
assert _put(client, h, m["id"], dims_a,
|
||||
[_dirty("finance", "目标C", None, dims_a[0]["objectives"][2])],
|
||||
snap_a["updated_at"]).status_code == 200
|
||||
|
||||
_advance(db, m["id"], 30)
|
||||
|
||||
# 会话B:本地重排 [目标B, 目标A](内容不变 ⇒ base 与服务端一致)
|
||||
dims_b = copy.deepcopy(snap_b["dimensions"])
|
||||
dims_b[0]["objectives"] = [dims_b[0]["objectives"][1], dims_b[0]["objectives"][0]]
|
||||
dirty_b = [
|
||||
_dirty("finance", "目标B", snap_b["dimensions"][0]["objectives"][1], dims_b[0]["objectives"][0]),
|
||||
_dirty("finance", "目标A", snap_b["dimensions"][0]["objectives"][0], dims_b[0]["objectives"][1]),
|
||||
]
|
||||
r = _put(client, h, m["id"], dims_b, dirty_b, snap_b["updated_at"])
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
fin = _fin_objs(db, m["id"])
|
||||
assert [o["name"] for o in fin] == ["目标B", "目标A", "目标C"] # 本地顺序采纳;他方节点排末尾
|
||||
assert [row[2] for row in _obj_rows(db, m["id"])] == [0, 1, 2]
|
||||
@@ -222,6 +222,15 @@ const { markDirty, markClean } = useFormGuard(isDirty)
|
||||
const baseUpdatedAt = ref<string | null>(null)
|
||||
// 收到 409(服务端已被他人修改)后置位:停止自动保存,绝不重试覆写
|
||||
const autoSaveBlocked = ref(false)
|
||||
// ── 按 objective 粒度合并的基线(P2 2026-10-12)────────────────────────
|
||||
// 写通道从「整表覆写」收敛为「只提交本地脏的 objective」:
|
||||
// baseServerDims = /maps 返回的 dimensions 原始副本(补层/迁移之前)⇒ 脏项的 base(并发基准)取自此处,
|
||||
// 与服务端库内现值同源,故「同一 objective 并发改」判据不会因本地修补而误报;
|
||||
// loadedDims = 载入 + 本地修补(ensureFourLayers / 旧格式迁移)后的副本 ⇒ 脏判据基线;
|
||||
// loadedCanvas = 载入时连线快照 ⇒ 连线是否本地改动的判据(未改则不提交 canvas_data)。
|
||||
let baseServerDims: any[] = []
|
||||
let loadedDims: any[] = []
|
||||
let loadedCanvas: any[] = []
|
||||
const allKpis = ref<any[]>([])
|
||||
const kpiNameMap = reactive<Record<string, string>>({})
|
||||
|
||||
@@ -496,6 +505,9 @@ function loadCanvas() {
|
||||
// 并发基准:本地快照对应服务端 updated_at(后续保存须带此基准)
|
||||
baseUpdatedAt.value = map?.updated_at || null
|
||||
autoSaveBlocked.value = false
|
||||
// 服务端原文快照(补层/迁移之前)⇒ 脏项的 base 与服务端库内现值同源(P2 按 objective 粒度合并)
|
||||
baseServerDims = deepClone(map?.dimensions || [])
|
||||
loadedCanvas = deepClone(map?.canvas_data?.connections || [])
|
||||
if (map?.dimensions) {
|
||||
for (const dim of map.dimensions) {
|
||||
dimensions.push({
|
||||
@@ -511,6 +523,8 @@ function loadCanvas() {
|
||||
ensureFourLayers()
|
||||
// 数据迁移:旧格式 kpis[] → krs[]
|
||||
migrateOldKpiFormat()
|
||||
// 载入基线(本地修补后)⇒ 脏判据基线(迁移新增的 krs 不算本地改动)
|
||||
loadedDims = deepClone(dimensions)
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
loading.value = false
|
||||
nextTick(() => {
|
||||
@@ -522,6 +536,9 @@ function loadCanvas() {
|
||||
}).catch(() => {
|
||||
ensureFourLayers()
|
||||
baseUpdatedAt.value = null // 载入失败 ⇒ 无基准(服务端 fail-closed)
|
||||
baseServerDims = []
|
||||
loadedDims = deepClone(dimensions)
|
||||
loadedCanvas = []
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
@@ -1076,6 +1093,131 @@ function migrateOldKpiFormat() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 按 objective 粒度提交(P2 2026-10-12)──────────────────────────────
|
||||
function deepClone<T>(v: T): T { return JSON.parse(JSON.stringify(v ?? null)) as T }
|
||||
|
||||
/** objective 的服务端行 id(回写件 _objective_id / objective_id);无则 null */
|
||||
function objIdOf(o: any): number | null {
|
||||
const raw = o?._objective_id ?? o?.objective_id
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
/** 归一化序列化:丢 _ 前缀簿记键 / null / 空串(与服务端 _canon_obj 同口径) */
|
||||
function canonJson(v: any): string {
|
||||
const clean = (x: any): any => {
|
||||
if (Array.isArray(x)) return x.map(clean)
|
||||
if (x && typeof x === 'object') {
|
||||
const out: Record<string, any> = {}
|
||||
for (const k of Object.keys(x).sort()) {
|
||||
if (k.startsWith('_') || x[k] === null || x[k] === undefined || x[k] === '') continue
|
||||
out[k] = clean(x[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
return x
|
||||
}
|
||||
return JSON.stringify(clean(v))
|
||||
}
|
||||
|
||||
/** 在目标列表里按行 id → 名称定位(与服务端 _find_objective 同序) */
|
||||
function findObjective(list: any[], probe: any): any | null {
|
||||
const objs = list || []
|
||||
const id = objIdOf(probe)
|
||||
if (id !== null) {
|
||||
const hit = objs.find((o: any) => objIdOf(o) === id)
|
||||
if (hit) return hit
|
||||
}
|
||||
const name = String(probe?.name ?? '')
|
||||
return objs.find((o: any) => String(o?.name ?? '') === name) || null
|
||||
}
|
||||
|
||||
/** 层内目标身份序列(顺序变化也须能识别 ⇒ 该层整层标脏以采纳重排) */
|
||||
function dimIdentitySeq(objs: any[]): string {
|
||||
return (objs || []).map((o: any) => String(objIdOf(o) ?? `name:${o?.name ?? ''}`)).join('|')
|
||||
}
|
||||
|
||||
function dimsIndex(dims: any[]): Record<string, any[]> {
|
||||
const out: Record<string, any[]> = {}
|
||||
for (const d of dims || []) out[d.key] = d.objectives || []
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地脏 objective 明细(服务端只采纳这些条目,其余节点保留服务端现值):
|
||||
* 内容与载入基线不同 ⇒ 增/改;载入基线有而当前没有 ⇒ 删(value=null);有行 id 但服务端基线无 ⇒ 本地新增(base=null)。
|
||||
* 仅顺序变化(内容不变)⇒ 该层全部条目入明细(base=服务端原文,服务端据提交顺序采纳重排)。
|
||||
*/
|
||||
function buildDirtyObjectives(): any[] {
|
||||
const out: any[] = []
|
||||
const loaded = dimsIndex(loadedDims)
|
||||
const serverRaw = dimsIndex(baseServerDims)
|
||||
for (const dim of dimensions) {
|
||||
const dk = dim.key
|
||||
const cur: any[] = dim.objectives || []
|
||||
const baseLoaded: any[] = loaded[dk] || []
|
||||
const baseSrv: any[] = serverRaw[dk] || []
|
||||
// 重排判据只看「增删之外仍然两边都在」的目标子序列:单纯新增/删除不算重排
|
||||
// (否则删一个目标会把同层其余目标一并标脏 ⇒ 他方对这些目标的并发改会误报 409)
|
||||
const sharedLocal = cur.filter((o: any) => findObjective(baseLoaded, o))
|
||||
const sharedLoaded = baseLoaded.filter((l: any) => findObjective(cur, l))
|
||||
const reordered = dimIdentitySeq(sharedLocal) !== dimIdentitySeq(sharedLoaded)
|
||||
for (const obj of cur) {
|
||||
const seen = findObjective(baseLoaded, obj)
|
||||
const changed = !seen || canonJson(obj) !== canonJson(seen)
|
||||
if (!changed && !reordered) continue
|
||||
const srv = findObjective(baseSrv, obj)
|
||||
out.push({
|
||||
dim_key: dk,
|
||||
objective_id: objIdOf(obj),
|
||||
name: obj?.name ?? '',
|
||||
base: srv ? deepClone(srv) : null, // null ⇒ 本地新增(服务端不应已有同 id 节点)
|
||||
value: deepClone(obj),
|
||||
})
|
||||
}
|
||||
for (const l of baseLoaded) {
|
||||
if (findObjective(cur, l)) continue
|
||||
const srv = findObjective(baseSrv, l)
|
||||
out.push({
|
||||
dim_key: dk, objective_id: objIdOf(l), name: l?.name ?? '',
|
||||
base: srv ? deepClone(srv) : null, value: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 连线是否本地改动(未改 ⇒ 不提交 canvas_data,避免整段覆写他方连线) */
|
||||
function connectionsDirty(): boolean {
|
||||
const norm = (v: any) => canonJson({ connections: Array.isArray(v) ? v : [] })
|
||||
return norm(connections.value) !== norm(loadedCanvas)
|
||||
}
|
||||
|
||||
/** 采纳服务端回写的行 id(本地新增节点落库后由服务端分配 ⇒ 下次编辑仍按 id 定位) */
|
||||
function adoptServerIds(serverDims: any[]) {
|
||||
if (!Array.isArray(serverDims)) return
|
||||
const byKey = dimsIndex(serverDims)
|
||||
for (const dim of dimensions) {
|
||||
const list: any[] = byKey[dim.key] || []
|
||||
for (const obj of dim.objectives || []) {
|
||||
const hit = objIdOf(obj) !== null
|
||||
? list.find((o: any) => objIdOf(o) === objIdOf(obj))
|
||||
: findObjective(list, obj)
|
||||
if (!hit) continue
|
||||
if (objIdOf(hit) !== null) obj._objective_id = objIdOf(hit)
|
||||
if (hit._kr_id) obj._kr_id = hit._kr_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存成功后刷新基线:脏判据回到本地现值;并发基准回到服务端现值(响应体 dimensions 为权威口径) */
|
||||
function refreshBaselines(serverDims: any[]) {
|
||||
adoptServerIds(serverDims)
|
||||
loadedDims = deepClone(dimensions)
|
||||
loadedCanvas = deepClone(connections.value)
|
||||
baseServerDims = deepClone(Array.isArray(serverDims) && serverDims.length ? serverDims : dimensions)
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -1109,21 +1251,29 @@ async function saveCanvas(silent?: boolean) {
|
||||
objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
const res: any = await mapApi.update(selectedMap.value, {
|
||||
// 只提交本地脏的 objective(服务端按 objective 粒度合并,他方未冲突改动一律保留);
|
||||
// 连线未改动则不提交 canvas_data(避免整段覆写他方连线)
|
||||
const payload: any = {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
dirty_objectives: buildDirtyObjectives(),
|
||||
version: currentMap.value?.version,
|
||||
base_updated_at: currentBaseStamp(), // 并发基准:过期则由服务端 409 拒绝覆写
|
||||
})
|
||||
base_updated_at: currentBaseStamp(), // 并发基准(缺 ⇒ 服务端 fail-closed 409)
|
||||
}
|
||||
if (connectionsDirty()) payload.canvas_data = { connections: connections.value }
|
||||
const res: any = await mapApi.update(selectedMap.value, payload)
|
||||
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
|
||||
refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威)
|
||||
markClean()
|
||||
if (!silent) ElMessage.success("已保存")
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
// 服务端已被他人修改:停止自动保存且不重试覆写(防过期快照整表覆写)
|
||||
// 服务端已被他人修改(同一 objective 并发改 / 整段覆写冲突):停止自动保存且不重试覆写
|
||||
autoSaveBlocked.value = true
|
||||
stopAutoSave()
|
||||
ElMessage.warning("地图已被他人修改,自动保存已停止,请刷新页面后重试")
|
||||
const detail = e?.response?.data?.detail
|
||||
ElMessage.warning(detail
|
||||
? `地图已被他人修改,自动保存已停止,请刷新页面后重试(${detail})`
|
||||
: "地图已被他人修改,自动保存已停止,请刷新页面后重试")
|
||||
} else {
|
||||
console.error("saveCanvas error:", e)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user