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:
Hermes CI Fix
2026-08-26 23:25:44 +08:00
parent 95b73c9b90
commit 9b61c48051
5 changed files with 54 additions and 18 deletions
+29 -6
View File
@@ -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)
+15 -5
View File
@@ -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(
+7 -5
View File
@@ -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, "战略地图不存在")
+1
View File
@@ -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="维度数据快照")