fix(security): ai_analysis多租户隔离缺口 + dashboard预警计数 + bot_kpis归属 + cost账套过滤 + kpis create白名单 (OpenCode审查20260907)
- ai_analysis: generate_rule_suggestions pending预警按entity过滤; kpi_analysis归属校验404; dashboard-analysis-stream/ask/review-plans 补 get_entity_id + KPI/ActionPlan/KPIAlert 全按账套过滤; stream cache key 含 entity_id (原跨企业串缓存) - dashboard: summary alert_count 补 KPIAlert.entity_id 过滤 (kpi_total 已有) - bot_kpis: update_bot_kpi_value 归属校验 404 (原任意登录可改他企 bot KPI) - cost: ActualCost/StandardCost 确认多企业共用 → cost_overview/variance/breakdown/dashboard 补 entity 过滤(engine 函数加 entity_id 透传); dashboard total_cost 既有 KeyError 修复 - kpis: create_kpi 字段白名单 (仿 UPDATE_KPI_WHITELIST+kpi_code), 拦截 status/important_flag 注入 - tests: 新增 test_multitenant_ai_20260907.py 11条越权用例 (跨entity 404/空返回断言) - 全量 pytest: 745 passed / 10 failed(与基线 HEAD 一致, 存量问题非本次引入) / 1 xfailed
This commit is contained in:
@@ -156,10 +156,16 @@ def generate_rule_suggestions(db: Session, entity_id: int,
|
||||
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
|
||||
})
|
||||
|
||||
# 4. pending预警 → 建行动方案
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").all()
|
||||
# 4. pending预警 → 建行动方案(多租户隔离 2026-09-07: 只取本账套预警)
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).all()
|
||||
for a in alerts:
|
||||
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
|
||||
k = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == a.kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
|
||||
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
|
||||
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
|
||||
@@ -291,8 +297,11 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
|
||||
"unit": k.unit,
|
||||
})
|
||||
|
||||
# 获取预警
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
|
||||
# 获取预警(多租户隔离 2026-09-07: 只统计本账套)
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).count()
|
||||
|
||||
# 构建分析prompt
|
||||
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
|
||||
@@ -339,8 +348,12 @@ async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
|
||||
@@ -422,9 +435,11 @@ async def _stream_analysis(prompt: str):
|
||||
|
||||
|
||||
@router.get("/dashboard-analysis-stream")
|
||||
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db)):
|
||||
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""AI分析驾驶舱数据 — SSE流式输出"""
|
||||
cache_key = f"dashboard_analysis:{role}"
|
||||
# 多租户隔离 2026-09-07: cache key 补 entity_id(原跨企业串缓存)
|
||||
cache_key = f"dashboard_analysis:{role}:{entity_id}"
|
||||
cached = cache_get("ai", cache_key)
|
||||
if cached:
|
||||
# 缓存存在,直接以流的形式一次性返回
|
||||
@@ -434,7 +449,10 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
|
||||
yield "data: {\"text\": \"[DONE]\"}\n\n"
|
||||
return StreamingResponse(cached_stream(), media_type="text/event-stream")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
kpi_summary = []
|
||||
for k in kpis:
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
||||
@@ -443,7 +461,10 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
|
||||
"target": k.target_value, "actual": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None, "unit": k.unit,
|
||||
})
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).count()
|
||||
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
|
||||
role_label = {"ceo": "CEO(总经理)", "finance": "财务部", "business": "业务部"}.get(role, "管理层")
|
||||
prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议:
|
||||
@@ -468,6 +489,7 @@ async def ask_question(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""自然语言查询 — CEO问企业经营问题"""
|
||||
body = await request.json()
|
||||
@@ -476,8 +498,11 @@ async def ask_question(
|
||||
if not question:
|
||||
raise HTTPException(400, "请输入问题")
|
||||
|
||||
# 收集系统数据作为上下文
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
# 收集系统数据作为上下文(多租户隔离 2026-09-07: 只取本账套)
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
kpi_context = []
|
||||
for k in kpis:
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
||||
@@ -487,13 +512,21 @@ async def ask_question(
|
||||
f"{' ⚠️' + alert.alert_level if alert else ''}"
|
||||
)
|
||||
|
||||
# 获取改善计划
|
||||
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(10).all()
|
||||
# 获取改善计划(多租户隔离 2026-09-07: 只取本账套)
|
||||
plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id,
|
||||
).order_by(ActionPlan.created_at.desc()).limit(10).all()
|
||||
plan_context = [f"- {p.title}({p.assignee}, {p.status}, {p.progress}%)" for p in plans]
|
||||
|
||||
# 获取预警
|
||||
red_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "red").count()
|
||||
yellow_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "yellow").count()
|
||||
red_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending", KPIAlert.alert_level == "red",
|
||||
).count()
|
||||
yellow_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending", KPIAlert.alert_level == "yellow",
|
||||
).count()
|
||||
|
||||
system_context = f"""你是管理会计OS的AI助手,基于以下企业数据回答管理层问题。
|
||||
|
||||
@@ -525,9 +558,13 @@ async def ask_question(
|
||||
async def review_plans(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""AI复盘改善行动计划执行效果"""
|
||||
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.asc()).all()
|
||||
# 多租户隔离 2026-09-07: ActionPlan 按 entity_id 过滤(表 2026-08-31 已加列回填)
|
||||
plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id,
|
||||
).order_by(ActionPlan.created_at.asc()).all()
|
||||
|
||||
if not plans:
|
||||
return {"analysis": "暂无改善行动计划,无法复盘"}
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
@@ -142,6 +143,7 @@ def update_bot_kpi_value(
|
||||
kpi_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""更新Bot KPI当前值"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
@@ -150,6 +152,9 @@ def update_bot_kpi_value(
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "Bot KPI不存在")
|
||||
# 多租户隔离 2026-09-07: 归属校验,禁止跨企业修改他企 Bot KPI
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "Bot KPI不存在")
|
||||
|
||||
actual_value = data.get("actual_value")
|
||||
if actual_value is None:
|
||||
|
||||
+36
-20
@@ -170,6 +170,8 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db),
|
||||
|
||||
# ============================================================
|
||||
# ABC 作业成本
|
||||
# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1,
|
||||
# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。
|
||||
# ============================================================
|
||||
|
||||
@router.get("/abc/activities")
|
||||
@@ -229,34 +231,41 @@ def list_allocations(period: Optional[str] = Query(None),
|
||||
# ============================================================
|
||||
|
||||
@router.get("/overview")
|
||||
def cost_overview(period: Optional[str] = Query(None)):
|
||||
"""成本总览(总成本、结构占比、趋势)"""
|
||||
def cost_overview(period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""成本总览(总成本、结构占比、趋势)— 多租户隔离 2026-09-07"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
return get_cost_overview(period)
|
||||
return get_cost_overview(period, entity_id=entity_id)
|
||||
|
||||
|
||||
@router.get("/variance")
|
||||
def cost_variance(product_code: str = Query(...),
|
||||
period: Optional[str] = Query(None)):
|
||||
"""量差价差分析"""
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""量差价差分析 — 多租户隔离 2026-09-07"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
return calc_product_variance(product_code, period)
|
||||
return calc_product_variance(product_code, period, entity_id=entity_id)
|
||||
|
||||
|
||||
@router.get("/breakdown")
|
||||
def cost_breakdown(product_code: str = Query(...),
|
||||
period: Optional[str] = Query(None)):
|
||||
"""成本构成(料/工/费占比)"""
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""成本构成(料/工/费占比)— 多租户隔离 2026-09-07"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
return get_cost_breakdown(product_code, period)
|
||||
return get_cost_breakdown(product_code, period, entity_id=entity_id)
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
def cost_method_comparison(entity: str = Query("hanke")):
|
||||
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)"""
|
||||
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)
|
||||
注:纯静态教学样例数据(无 DB 访问),不涉多租户泄漏——OpenCode 审查 2026-09-07 豁免标注"""
|
||||
if entity == "hanke":
|
||||
gross_revenue = 713 # 万
|
||||
channel_rebate_rate = 0.828
|
||||
@@ -317,35 +326,42 @@ def cost_method_comparison(entity: str = Query("hanke")):
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def cost_dashboard(period: Optional[str] = Query(None)):
|
||||
"""成本分析首页—汇总数据"""
|
||||
def cost_dashboard(period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""成本分析首页—汇总数据(多租户隔离 2026-09-07: ActualCost 按账套过滤)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
overview = get_cost_overview(period)
|
||||
overview = get_cost_overview(period, entity_id=entity_id)
|
||||
|
||||
# 获取所有产品列表
|
||||
db = get_db().__next__()
|
||||
# 获取所有产品列表(本账套)
|
||||
db_session = db if db is not None else get_db().__next__()
|
||||
try:
|
||||
products = db.query(ActualCost.product_code, ActualCost.product_name).filter(
|
||||
ActualCost.period == period
|
||||
products = db_session.query(ActualCost.product_code, ActualCost.product_name).filter(
|
||||
ActualCost.entity_id == entity_id,
|
||||
ActualCost.period == period,
|
||||
).distinct().all()
|
||||
product_list = [{"code": p[0], "name": p[1]} for p in products]
|
||||
|
||||
# 各产品成本
|
||||
product_costs = []
|
||||
for code, name in products:
|
||||
costs = db.query(ActualCost).filter(
|
||||
costs = db_session.query(ActualCost).filter(
|
||||
ActualCost.entity_id == entity_id,
|
||||
ActualCost.product_code == code,
|
||||
ActualCost.period == period,
|
||||
).all()
|
||||
total = round(sum(c.actual_cost for c in costs), 2)
|
||||
product_costs.append({"product_code": code, "product_name": name, "total_cost": total})
|
||||
finally:
|
||||
db.close()
|
||||
if db is None:
|
||||
db_session.close()
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"overview": overview,
|
||||
"products": product_list,
|
||||
"total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0,
|
||||
# 修复既有bug(2026-09-07测试实证): 原对 product_list 求 p["total_cost"] 必 KeyError(产品列表无total_cost键)
|
||||
# → 改为 product_costs(含各产品总额),有产品数据时端点不再 500
|
||||
"total_cost": round(sum(p["total_cost"] for p in product_costs) + overview.get("erp_cost", 0), 2) if product_costs else 0,
|
||||
}
|
||||
|
||||
@@ -79,7 +79,10 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month")
|
||||
return cached
|
||||
kpi_total = db.query(func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar()
|
||||
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
|
||||
alert_count = db.query(func.count(KPIAlert.id)).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).scalar()
|
||||
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||
).group_by(KPIDefinition.dimension).all()
|
||||
|
||||
@@ -731,6 +731,13 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
|
||||
data = apply_calc_type_inference(data)
|
||||
# create 侧字段白名单(OpenCode 审查残留项修复 2026-09-07):
|
||||
# 仿 update 白名单 + kpi_code(新建必填),拦截 status/important_flag/主键等敏感字段注入
|
||||
# (update 2026-08-31 已有白名单;create 此前直接 KPIDefinition(**data) 展开,可被注入)
|
||||
_create_allow = UPDATE_KPI_WHITELIST | {"kpi_code"}
|
||||
for k in list(data.keys()):
|
||||
if k != "entity_id" and k not in _create_allow:
|
||||
data.pop(k, None)
|
||||
kpi = KPIDefinition(**data)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user