feat(maps): 战略地图按 objective 粒度合并(granular 通道),不同 objective 并发不再互相 409
ci/woodpecker/push/main Pipeline failed

This commit is contained in:
Hermes CI Fix
2026-10-13 11:37:24 +08:00
parent f786ff9ae3
commit 501322a8b9
3 changed files with 764 additions and 29 deletions
+253 -23
View File
@@ -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)