"""战略地图版本管理 API(2026-09-08 完整实现) 功能闭环(任总报告\"版本连接点击打不开\"→ 方案B): - GET /{map_id}/versions 版本列表(含维度/目标/KPI/连线摘要 + is_current) - GET /{map_id}/versions/{vid} 单版本详情(dimensions/canvas_data 全量,供\"查看快照画布\") - POST /{map_id}/versions/snapshot 手动创建快照(保留,历史兼容) - POST /{map_id}/versions/{vid}/rollback 回滚到指定版本 —— 回滚前自动备份当前内容为新版本快照,防覆盖丢数据 - PUT /{map_id}/versions/{vid} 更新版本备注 comment 安全: - 全部端点按 map_id + entity_id 隔离(地图归属 404;版本行再校验 entity_id,防跨账套回滚) - 回滚 = 先备份后覆盖;备份版本号由当前地图 version 派生(vX.Y → vX.(Y+1)),撞号自动 +1 - 不改 _auto_snapshot / 发布逻辑(maps.py 自动快照保持原样) """ import json import re from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_role from app.models import StrategicMap, StrategicMapVersion router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"], dependencies=[Depends(require_role("ceo", "finance"))], ) def _get_map(db: Session, map_id: int, entity_id: int) -> StrategicMap: m = db.query(StrategicMap).filter( StrategicMap.id == map_id, StrategicMap.entity_id == entity_id ).first() if not m: raise HTTPException(404, "战略地图不存在") return m def _get_version(db: Session, map_id: int, vid: int, entity_id: int) -> StrategicMapVersion: v = db.query(StrategicMapVersion).filter( StrategicMapVersion.id == vid, StrategicMapVersion.map_id == map_id, StrategicMapVersion.entity_id == entity_id, ).first() if not v: raise HTTPException(404, "版本不存在") return v def _parse_json(raw, default): if raw is None: return default if isinstance(raw, (dict, list)): return raw try: return json.loads(raw) except Exception: return default def _next_minor_version(s: str) -> str: """把 'v1.0' → 'v1.1';解析失败返回 None""" m = re.search(r"v(\d+)\.(\d+)", s or "") if m: return f"v{m.group(1)}.{int(m.group(2)) + 1}" return None def _summary_dict(v: StrategicMapVersion, m: StrategicMap = None) -> dict: dims = _parse_json(v.dimensions, []) canvas = _parse_json(v.canvas_data, {"connections": []}) codes = set() objective_count = 0 for dim in dims: for obj in dim.get("objectives", []): objective_count += 1 for c in (obj.get("kpis") or []): if c: codes.add(str(c)) for kr in (obj.get("krs") or []): if isinstance(kr, dict) and kr.get("kpi_code"): codes.add(str(kr["kpi_code"])) conns = canvas.get("connections", []) if isinstance(canvas, dict) else [] return { "id": v.id, "map_id": v.map_id, "entity_id": v.entity_id, "version": v.version, "comment": v.comment, "created_by": v.created_by, "created_at": v.created_at.isoformat() if v.created_at else None, "dimension_count": len(dims), "objective_count": objective_count, "kpi_count": len(codes), "connection_count": len(conns), "is_current": bool(m and m.version == v.version), } def _full_dict(v: StrategicMapVersion, m: StrategicMap = None) -> dict: d = {c.name: getattr(v, c.name) for c in v.__table__.columns} if d.get("created_at") is not None: d["created_at"] = d["created_at"].isoformat() d.update(_summary_dict(v, m)) return d @router.get("/{map_id}/versions") def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """查看版本历史(账套隔离;地图归属校验 404)""" m = _get_map(db, map_id, entity_id) versions = db.query(StrategicMapVersion).filter( StrategicMapVersion.map_id == map_id, StrategicMapVersion.entity_id == entity_id, ).order_by(StrategicMapVersion.created_at.desc(), StrategicMapVersion.id.desc()).all() return {"data": [_summary_dict(v, m) for v in versions]} @router.get("/{map_id}/versions/{vid}") def get_version(map_id: int, vid: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """单版本详情:dimensions/canvas_data 全量(供\"查看该版本画布\")""" m = _get_map(db, map_id, entity_id) v = _get_version(db, map_id, vid, entity_id) return _full_dict(v, m) @router.put("/{map_id}/versions/{vid}") def update_version_comment(map_id: int, vid: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """更新版本备注 comment""" _get_map(db, map_id, entity_id) if "comment" not in data: raise HTTPException(400, "缺少 comment 字段") v = _get_version(db, map_id, vid, entity_id) v.comment = data.get("comment") db.commit() db.refresh(v) return _summary_dict(v, _get_map(db, map_id, entity_id)) def _next_snapshot_version(db: Session, map_id: int) -> str: """该地图下一个快照版本号:最新快照行 minor+1;无快照 → v1.0(与 _auto_snapshot 语义一致)""" latest = db.query(StrategicMapVersion).filter( StrategicMapVersion.map_id == map_id ).order_by(StrategicMapVersion.id.desc()).first() if latest: nxt = _next_minor_version(latest.version) if nxt: return nxt return "v1.0" @router.post("/{map_id}/versions/snapshot") def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """手动创建快照(账套隔离)""" m = _get_map(db, map_id, entity_id) dims = _parse_json(m.dimensions, []) canvas = _parse_json(m.canvas_data, {"connections": []}) # 自动版本号: 与 _auto_snapshot 一致 —— 最新快照行 minor+1;无快照 → v1.0 new_ver = _next_snapshot_version(db, map_id) snapshot = StrategicMapVersion( map_id=map_id, entity_id=entity_id, # 继承地图企业 version=new_ver, dimensions=dims, canvas_data=canvas, comment=data.get("comment", f"手动快照 {new_ver}"), ) db.add(snapshot) db.commit() db.refresh(snapshot) return _full_dict(snapshot, m) @router.post("/{map_id}/versions/{vid}/rollback") def rollback_version(map_id: int, vid: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """回滚到指定版本。 安全设计(2026-09-08 完整实现,修复旧版无备份覆盖事故): 1. 回滚前把地图【当前内容】备份为新版本快照(版本号由当前 map.version 派生 vX.(Y+1),撞号自动 +1) —— 任何情况下不因回滚丢失当前画布内容 2. 用目标版本 dimensions/canvas_data 覆盖 strategic_maps 当前行,version 设为该版本号 3. 地图与版本行双重 entity 校验(跨账套一律 404) """ m = _get_map(db, map_id, entity_id) v = _get_version(db, map_id, vid, entity_id) # 1) 先备份当前内容 → 新版本快照 cur_dims = _parse_json(m.dimensions, []) cur_canvas = _parse_json(m.canvas_data, {"connections": []}) backup_ver = _next_snapshot_version(db, map_id) # 防撞号: 极端场景最新快照版本号不可解析回落 v1.0 时循环 +1(正常递增天然唯一) exists = db.query(StrategicMapVersion).filter( StrategicMapVersion.map_id == map_id, StrategicMapVersion.version == backup_ver, ).first() while exists: nxt = _next_minor_version(backup_ver) backup_ver = nxt if nxt else backup_ver + ".1" exists = db.query(StrategicMapVersion).filter( StrategicMapVersion.map_id == map_id, StrategicMapVersion.version == backup_ver, ).first() backup = StrategicMapVersion( map_id=map_id, entity_id=entity_id, version=backup_ver, dimensions=cur_dims, canvas_data=cur_canvas, comment="回滚前自动备份", ) db.add(backup) # 2) 覆盖当前行内容 + 版本号 m.dimensions = _parse_json(v.dimensions, []) m.canvas_data = _parse_json(v.canvas_data, {"connections": []}) m.version = v.version db.commit() # 3) 同步目标表(与 update_map 保存路径一致,保证回顾会/KR 取数一致) try: from app.api.maps import _sync_map_objectives, _sync_objectives_krs _sync_map_objectives(m, db) _sync_objectives_krs(m, db) except Exception as e: # 同步失败不回滚已完成的版本覆盖(主数据已安全) import logging logging.getLogger("maps_versions").warning("rollback 后目标表同步失败: %s", e) return { "message": f"已回滚到 {v.version}(当前内容已自动备份为 {backup_ver})", "version": m.version, "backup_version": backup_ver, }