Files
cma-management/backend/app/api/versions.py

237 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""战略地图版本管理 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,
}