fix: OpenCode审查16条修复 — 战略地图隔离/安全/逻辑漏洞
安全: - ⑦ update_map 字段白名单(禁entity_id篡改, status限值域) - ⑧ objectives 全端点补entity校验(原括号错位导致隔离失效) + update白名单 - ⑨ strategic_map_versions 加entity_id列+回填, list/snapshot/rollback过滤 - ⑩ get_map_review KPI查询加entity(同编码跨企业混入) - ⑪ _sync_map_objectives 强制继承地图entity 逻辑: - ③ MapCanvas 自动保存 currentMap.value 解包(30s自动保存修复) - ④ 乐观锁字段 version_num→version 对齐 - ⑤ 红黄绿灯: 越低越好型反向 + 负值修正 + 月目标优先 - ⑥ review 补 action_plan_summary 字段 验证: 跨企业写目标404✓ entity_id注入被拒✓ pytest 531 passed✓
This commit is contained in:
+28
-5
@@ -115,8 +115,13 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
old_status = m.status
|
||||
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
||||
# status 允许但仅限合法值域(draft/published), 防止任意发布
|
||||
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
|
||||
for k, v in data.items():
|
||||
if hasattr(m, k) and v is not None:
|
||||
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
|
||||
if k == "status" and v not in ("draft", "published"):
|
||||
continue
|
||||
setattr(m, k, v)
|
||||
|
||||
db.commit()
|
||||
@@ -307,7 +312,7 @@ def _sync_map_objectives(m, db):
|
||||
for i, obj in enumerate(dim.get("objectives", [])):
|
||||
mo = MapObjective(
|
||||
map_id=m.id,
|
||||
entity_id=getattr(m, "entity_id", 1) or 1, # 账套隔离: 继承地图企业
|
||||
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
|
||||
dimension_key=dim.get("key", ""),
|
||||
name=obj.get("name", ""),
|
||||
description=obj.get("description", ""),
|
||||
@@ -378,9 +383,10 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
for code in obj.get("kpis", []):
|
||||
all_kpi_codes.add(code)
|
||||
|
||||
# 查询KPI定义
|
||||
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10)
|
||||
kpi_defs = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False
|
||||
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all() if all_kpi_codes else []
|
||||
kpi_map = {k.kpi_code: k for k in kpi_defs}
|
||||
|
||||
@@ -446,11 +452,21 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
continue
|
||||
lv = latest_values.get(kpi_def.id, {})
|
||||
actual = lv.get("actual_value")
|
||||
target = kpi_def.target_value
|
||||
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
|
||||
target = kpi_def.target_monthly or kpi_def.target_value
|
||||
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||||
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||||
level = "gray"
|
||||
if actual is not None and target:
|
||||
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
||||
lower_better = any(k in name for k in
|
||||
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
|
||||
if lower_better:
|
||||
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
|
||||
else:
|
||||
ratio = actual / target
|
||||
if ratio < 0:
|
||||
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||||
if ratio >= 0.9:
|
||||
level = "green"
|
||||
elif ratio >= 0.6:
|
||||
@@ -481,6 +497,13 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
"kpis": obj_kpis,
|
||||
"level": worst_level,
|
||||
"has_data": len(obj_kpis) > 0,
|
||||
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
|
||||
"action_plan_summary": {
|
||||
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
|
||||
"pending": len([p for p in action_plans_data
|
||||
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
|
||||
and p["status"] not in ("done", "completed")]),
|
||||
} if obj_kpis else {"total": 0, "pending": 0},
|
||||
}
|
||||
objectives.append(obj_item)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int =
|
||||
@router.post("/{map_id}/objectives")
|
||||
def create_objective(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), StrategicMap.entity_id == entity_id.first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = MapObjective(
|
||||
@@ -42,14 +42,18 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), ent
|
||||
|
||||
@router.put("/{map_id}/objectives/{obj_id}")
|
||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""修改目标"""
|
||||
"""修改目标(账套隔离 + 字段白名单, OpenCode审查#8)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = db.query(MapObjective).filter(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
ALLOWED_OBJ_FIELDS = {"name", "description", "icon", "sort_order", "kpis", "dimension_key"}
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k) and v is not None:
|
||||
if k in ALLOWED_OBJ_FIELDS and hasattr(obj, k) and v is not None:
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
return _obj_to_dict(obj)
|
||||
@@ -57,7 +61,10 @@ def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends
|
||||
|
||||
@router.delete("/{map_id}/objectives/{obj_id}")
|
||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除目标"""
|
||||
"""删除目标(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = db.query(MapObjective).filter(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
@@ -70,7 +77,10 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), en
|
||||
|
||||
@router.put("/{map_id}/objectives/sort")
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量排序: {"ids": [3, 1, 2]}"""
|
||||
"""批量排序: {"ids": [3, 1, 2]}(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
ids = data.get("ids", [])
|
||||
for idx, obj_id in enumerate(ids):
|
||||
db.query(MapObjective).filter(
|
||||
|
||||
@@ -13,17 +13,18 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
|
||||
|
||||
@router.get("/{map_id}/versions")
|
||||
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""查看版本历史"""
|
||||
"""查看版本历史(账套隔离, OpenCode审查#9)"""
|
||||
versions = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.entity_id == entity_id,
|
||||
).order_by(StrategicMapVersion.id.desc()).all()
|
||||
return {"data": [v_to_dict(v) for v in versions]}
|
||||
|
||||
|
||||
@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 = db.query(StrategicMap).filter(StrategicMap.id == map_id), StrategicMap.entity_id == entity_id.first()
|
||||
"""手动创建快照(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -50,6 +51,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), enti
|
||||
|
||||
snapshot = StrategicMapVersion(
|
||||
map_id=map_id,
|
||||
entity_id=entity_id, # 继承地图企业
|
||||
version=new_ver,
|
||||
dimensions=dims,
|
||||
canvas_data=canvas,
|
||||
@@ -64,7 +66,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), enti
|
||||
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""回滚到指定版本"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
|
||||
@@ -262,6 +262,7 @@ class StrategicMapVersion(Base):
|
||||
"""战略地图版本快照"""
|
||||
__tablename__ = "strategic_map_versions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (OpenCode审查#9 2026-08-26)")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
|
||||
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
|
||||
|
||||
@@ -784,7 +784,7 @@ async function saveCanvas(silent?: boolean) {
|
||||
await mapApi.update(selectedMap.value, {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
version_num: currentMap.value?.version_num,
|
||||
version: currentMap.value?.version,
|
||||
})
|
||||
markClean()
|
||||
if (!silent) ElMessage.success("已保存")
|
||||
@@ -1017,7 +1017,7 @@ onMounted(async () => {
|
||||
}
|
||||
// 自动保存:每30秒自动保存草稿
|
||||
autoSaveTimer = setInterval(() => {
|
||||
if (currentMap?.status === 'draft') saveCanvas(true)
|
||||
if (currentMap.value?.status === 'draft') saveCanvas(true)
|
||||
}, 30000)
|
||||
window.addEventListener('resize', triggerRecalc)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user