diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index 9b17a302..fefe1a45 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -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: - ratio = actual / 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) diff --git a/backend/app/api/objectives.py b/backend/app/api/objectives.py index 8f30aeb5..f69d0324 100644 --- a/backend/app/api/objectives.py +++ b/backend/app/api/objectives.py @@ -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( diff --git a/backend/app/api/versions.py b/backend/app/api/versions.py index 5e682b64..9686e2a0 100644 --- a/backend/app/api/versions.py +++ b/backend/app/api/versions.py @@ -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, "战略地图不存在") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a12ef01e..8e2693f5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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="维度数据快照") diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index e2fcc3eb..1462b993 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -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) })