From 50c15ddbf6ccd2c2e16d1f343030af3d2b159cac Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 23 Aug 2026 17:43:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=9A=E7=A7=9F=E6=88=B7=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9A=94=E7=A6=BBP1a+P1b=20=E2=80=94=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0/=E9=A2=84=E8=AD=A6/=E8=A1=8C=E5=8A=A8?= =?UTF-8?q?=E8=AE=A1=E5=88=92/=E5=9B=A0=E6=9E=9C=E9=93=BE=E6=8C=89?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1a(有entity表查询补齐): - kpis get/update/delete/restore 跨企业404校验 - kpis create 强制entity=token企业, update禁止改归属 P1b(无entity表join隔离): - alerts list/resolve join kpi_definitions 按企业过滤 - action_plans list join过滤 + create校验关联KPI归属 - kpi_causality full-network/list join过滤 - dashboard my_dashboard(用户发现) assigned/preset均按企业隔离 测试: 测试KPI种子entity对齐(2→1), pytest 451 passed 实证: 酣客token 6KPI(无博海id) vs 博海token 1KPI(414) 切换隔离正确 --- backend/app/api/action_plans.py | 11 +++++++++-- backend/app/api/alerts.py | 13 ++++++++----- backend/app/api/dashboard.py | 9 ++++++--- backend/app/api/kpi_causality.py | 12 +++++++----- backend/app/api/kpis.py | 26 ++++++++++++++++++++------ backend/tests/test_kpi_causality.py | 3 ++- 6 files changed, 52 insertions(+), 22 deletions(-) diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index 23f89030..ef5eae4d 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -7,6 +7,7 @@ import re import logging from calendar import monthrange from app.database import get_db +from app.deps import get_entity_id from app.auth_middleware import require_role, require_auth from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective @@ -88,9 +89,10 @@ def list_plans( alert_id: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(require_auth), + entity_id: int = Depends(get_entity_id), ): - """获取行动计划列表""" - query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()) + """获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)""" + query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc()) if status: query = query.filter(ActionPlan.status == status) @@ -123,12 +125,17 @@ def create_plan( data: dict, db: Session = Depends(get_db), current_user: User = Depends(require_auth), + entity_id: int = Depends(get_entity_id), ): """创建改善行动计划(也是OKR的KR)""" required = ["title", "kpi_id"] for field in required: if field not in data: raise HTTPException(400, f"缺少必填字段: {field}") + # 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b) + kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first() + if not kpi_ent or kpi_ent.entity_id != entity_id: + raise HTTPException(404, "关联KPI不存在") due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index 244ebc79..48eb5a28 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends, Query, 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_auth, require_role -from app.models import KPIAlert, OperationLog, ActionPlan +from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition import logging logger = logging.getLogger("cma.alerts") @@ -13,8 +14,9 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"], ) @router.get("") -def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)): - query = db.query(KPIAlert) +def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + # 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b) + query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id) if status: query = query.filter(KPIAlert.status == status) total = query.count() @@ -22,8 +24,9 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]} @router.post("/{alert_id}/resolve") -def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): - alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() +def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter( + KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first() if not alert: raise HTTPException(404, "预警不存在") alert.status = "resolved" diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index cd706394..cdbfdd7f 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -390,8 +390,9 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent def my_dashboard( current_user: User = Depends(require_auth), db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """个人工作台:返回我的KPI、改善行动、待办提醒""" + """个人工作台:返回我的KPI、改善行动、待办提醒(账套隔离: 按token企业过滤)""" username = current_user.username name = current_user.name role = current_user.role @@ -405,22 +406,24 @@ def my_dashboard( } preset_codes = ROLE_PRESET_KPIS.get(role, []) - # 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设 + # 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设(均按企业隔离) assigned_kpis = db.query(KPIDefinition).filter( or_( KPIDefinition.responsible_user == username, KPIDefinition.responsible_user == name, ), KPIDefinition.status == "active", + KPIDefinition.entity_id == entity_id, ).all() assigned_ids = {k.id for k in assigned_kpis} - # 补充角色预设KPI(去重) + # 补充角色预设KPI(去重,按企业隔离) preset_kpis = [] if preset_codes: q = db.query(KPIDefinition).filter( KPIDefinition.kpi_code.in_(preset_codes), KPIDefinition.status == "active", + KPIDefinition.entity_id == entity_id, ) if assigned_ids: q = q.filter(~KPIDefinition.id.in_(assigned_ids)) diff --git a/backend/app/api/kpi_causality.py b/backend/app/api/kpi_causality.py index 5b41cdc0..e0af8037 100644 --- a/backend/app/api/kpi_causality.py +++ b/backend/app/api/kpi_causality.py @@ -8,6 +8,7 @@ from typing import Optional import logging from app.database import get_db +from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog @@ -28,9 +29,9 @@ def _to_dict(obj): # ============================================================ @router.get("/full-network") -def get_full_network(db: Session = Depends(get_db)): - """获取全局因果网络数据(用于力导向图)""" - edges = db.query(KPICausality).all() +def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b)""" + edges = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id).all() node_ids = set() edge_list = [] for e in edges: @@ -212,9 +213,10 @@ def list_causalities( source_kpi_id: Optional[int] = None, target_kpi_id: Optional[int] = None, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """获取因果链列表""" - query = db.query(KPICausality) + """获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b)""" + query = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id) if source_kpi_id: query = query.filter(KPICausality.source_kpi_id == source_kpi_id) if target_kpi_id: diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index 2df7ad24..aae03b3b 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -475,10 +475,13 @@ def get_kpi_causality_chain( # ============================================================ @router.get("/{kpi_id}") -def get_kpi(kpi_id: int, db: Session = Depends(get_db)): +def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") + # 账套隔离: 禁止跨企业读取 (2026-08-23 P1a) + if kpi.entity_id != entity_id: + raise HTTPException(404, "KPI不存在") return kpi_to_dict(kpi) @@ -586,15 +589,16 @@ def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict: @router.post("") -def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): +def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): # 检查编码唯一性 - existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", "")).first() + existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", ""), KPIDefinition.entity_id == entity_id).first() if existing: raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在") # 数据治理校验(规则1强制拦截) errs = _validate_kpi_data(data, db=db, is_update=False) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) + data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a) data = apply_calc_type_inference(data) kpi = KPIDefinition(**data) db.add(kpi) @@ -605,14 +609,18 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): @router.put("/{kpi_id}") -def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): +def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") + # 账套隔离: 禁止跨企业修改 (2026-08-23 P1a) + if kpi.entity_id != entity_id: + raise HTTPException(404, "KPI不存在") # 数据治理校验(更新时只检查传了但为空的字段) errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) + data.pop("entity_id", None) # 禁止通过update改企业归属 data = apply_calc_type_inference(data, infer_missing=False) for k, v in data.items(): if hasattr(kpi, k) and v is not None: @@ -623,18 +631,24 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT @router.delete("/{kpi_id}") -def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES): +def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if kpi: + # 账套隔离: 禁止跨企业删除 (2026-08-23 P1a) + if kpi.entity_id != entity_id: + raise HTTPException(404, "KPI不存在") kpi.status = "disabled" db.commit() return {"message": "已删除"} @router.put("/{kpi_id}/restore") -def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES): +def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if kpi: + # 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a) + if kpi.entity_id != entity_id: + raise HTTPException(404, "KPI不存在") kpi.status = "active" db.commit() return {"message": "已恢复"} diff --git a/backend/tests/test_kpi_causality.py b/backend/tests/test_kpi_causality.py index e8baaf9e..8a828fde 100644 --- a/backend/tests/test_kpi_causality.py +++ b/backend/tests/test_kpi_causality.py @@ -17,7 +17,8 @@ BASE = "/api/cma/kpi-causality" def _seed_kpi(db: Session, code: str, name: str = None, dimension: str = "finance", - entity_id: int = 2) -> KPIDefinition: + entity_id: int = 1) -> KPIDefinition: + # entity_id=1 与测试token(账套entity=1)对齐 — 2026-08-23 多租户隔离后必须匹配 kpi = KPIDefinition( kpi_code=code, kpi_name=name or code,