237 lines
9.1 KiB
Python
237 lines
9.1 KiB
Python
"""战略地图版本管理 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,
|
||
}
|