Files
cma-management/backend/app/api/bot_suggestions.py
Hermes CI Fix 80fbc11cba feat(bot-tier): M1 Bot侧高危建议创建通道+目标值直写收口 (P0-②分层授权)
- 新增 POST /api/cma/bot/ai/suggestions (bot_suggestions.py, X-BOT-KEY鉴权, L2)
  高危建议(kpi_target/budget_adjust/action_plan) status恒unapplied 等人审批
  source白名单(bot/rule)防冒充manual; entity多租户归属校验; 幂等防重
- risk_levels.py: API_RISK_MAP加新端点 + 文件头补写操作语义分级low/high说明
- 收口: bot/kpis/create-with-links 拒绝直写非零target_value(显式置0, 考核基准=人拍板)
- test_risk_levels: L2计数5→6; 新增test_bot_suggestion_channel 12条(鉴权/语义/多租户/幂等/审批闭环/审计)
2026-09-03 18:08:27 +08:00

176 lines
7.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Bot侧AI高危建议创建通道 — 写操作分层授权落地 (P0-② 2026-09-03)
背景(CMA交互架构规范 v1 5.1 分层授权模型,任总拍板):
考核基准类(KPI目标值/预算调整)→ 🔒 Bot只能"建议",人审批后生效
Bot不得直写 KPI target_value / budget 审批类字段;
Bot产生的高危变更一律走本通道建 ai_suggestions(status=unapplied)
→ UI AI建议中心(SuggestionCenter.vue)待批列表 → 人 apply 后生效+留痕。
通道关系(两套分级勿混,见 risk_levels.py 顶部注释):
L1-L4(技术风险分级): 本端点挂 L2(业务写单条)
low/high(业务写语义分级): 本端点是 high 类的"建议载体"——
端点本身不直写考核基准,只建建议行,属 high 流程的入口。
鉴权/审计:
X-BOT-KEY(verify_bot_key, bot_bridge 共享密钥)+ @risk_level("L2")
路径 /api/cma/bot/* → bot_audit_middleware 自动留痕(bot_name/risk/status)
entity_id 多租户: X-Entity-Id header → body.entity_id → 默认1,校验 Entity.active
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import AISuggestion, KPIDefinition, Entity
from app.api.bot_bridge import verify_bot_key
from app.api.ai_suggestions import _sug_dict
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot/ai/suggestions", tags=["AI建议Bot通道"])
# 高危建议类型(Bot仅允许创建这三类建议,禁止其他)
_SUPPORTED_TYPES = ("kpi_target", "budget_adjust", "action_plan")
# source 白名单:Bot 不得冒充 manual/dashboard/kpi/budget(影响UI语义与推送判断)
_ALLOWED_SOURCES = ("rule", "bot")
_TARGET_TYPE_MAP = {
"kpi_target": "kpi",
"budget_adjust": "budget",
"action_plan": "kpi",
}
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → 默认1,校验active。
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
本端点不接收登录token,故不在此处理 Bearer。
"""
raw = None
if data.get("entity_id") not in (None, ""):
raw = data["entity_id"]
else:
xh = request.headers.get("X-Entity-Id")
if xh and str(xh).isdigit():
raw = int(xh)
try:
eid = int(raw) if raw is not None else 1
except (TypeError, ValueError):
raise HTTPException(422, f"entity_id 无效: {raw!r}")
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent or ent.status != "active":
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
return eid
@router.post("")
@risk_level("L2")
def bot_create_suggestion(
request: Request,
data: dict,
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""Bot创建高危建议(kpi_target/budget_adjust/action_plan),status 固定 unapplied 等人审批。
入参(字段与人工创建端点 /api/cma/ai/suggestions 一致):
suggestion_type (str, 必填): kpi_target / budget_adjust / action_plan
title (str, 必填): 建议标题(幂等键之一)
entity_id (int, 可选, 默认1): 账套;也支持 X-Entity-Id header
target_id (int, 可选): 目标ID;kpi_target/budget_adjust 必填(kpi_id 亦可)
suggestion_data(dict, 可选): 应用参数
kpi_target: {kpi_id, target_value}
budget_adjust:{kpi_id, period:YYYY-MM, budget_value}
action_plan: {kpi_id, title?, assignee?, priority?, due_date?}
content (str, 可选): 建议理由/依据(财务Bot三情景测算content携带)
source (str, 默认"bot"): 仅允许 bot/rule,禁止冒充 manual/dashboard
target_type (str, 可选): 缺省按 suggestion_type 映射 kpi/budget
约束:
- status 恒为 unapplied(Bot 不能直接生效)
- kpi_target/budget_adjust 必须带有效 kpi_id/target_id 且属于当前账套
- 幂等: 同 entity+suggestion_type+target_id+title 且 unapplied 已存在 → 返回既有建议
"""
suggestion_type = data.get("suggestion_type")
title = str(data.get("title") or "").strip()
if suggestion_type not in _SUPPORTED_TYPES:
raise HTTPException(
400,
f"Bot建议仅支持 suggestion_type: {'/'.join(_SUPPORTED_TYPES)}(高危变更=建议通道,不提供直写)",
)
if not title:
raise HTTPException(400, "缺少 title")
source = data.get("source", "bot")
if source not in _ALLOWED_SOURCES:
raise HTTPException(400, f"source 仅允许 {'/'.join(_ALLOWED_SOURCES)}(禁止冒充 manual/dashboard/kpi/budget)")
entity_id = _resolve_bot_entity(request, data, db)
sd = data.get("suggestion_data") or {}
target_type = data.get("target_type") or _TARGET_TYPE_MAP.get(suggestion_type)
# 目标归属校验(多租户铁律: 只能建议当前账套的KPI)
kpi_id = None
raw_kpi = data.get("kpi_id") or data.get("target_id") or sd.get("kpi_id")
if raw_kpi is not None:
try:
kpi_id = int(raw_kpi)
except (TypeError, ValueError):
kpi_id = None
if suggestion_type in ("kpi_target", "budget_adjust"):
if kpi_id is None:
raise HTTPException(400, f"suggestion_type={suggestion_type} 必须带 kpi_id/target_id")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在于当前账套 entity_id={entity_id}")
if suggestion_type == "kpi_target" and sd.get("target_value") is None:
raise HTTPException(400, "kpi_target 建议的 suggestion_data 必须带 target_value")
if suggestion_type == "budget_adjust" and (
not sd.get("period") or sd.get("budget_value") is None
):
raise HTTPException(400, "budget_adjust 建议的 suggestion_data 必须带 period(YYYY-MM) + budget_value")
# 幂等: 同 entity+type+target_id+title 的 unapplied 建议存在则不重复创建
target_id = kpi_id if kpi_id is not None else data.get("target_id")
dup = db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.suggestion_type == suggestion_type,
AISuggestion.target_id == target_id,
AISuggestion.title == title,
AISuggestion.status == "unapplied",
).first()
if dup:
return {
"success": True,
"message": "建议已存在(幂等,未重复创建)",
"data": _sug_dict(dup),
"duplicated": True,
}
category = "alert" if target_type == "alert" else data.get("category", "decision")
sug = AISuggestion(
entity_id=entity_id,
user_id=None, # Bot 创建无人工账号;bot 名与调用留痕在 bot_audit.log(X-BOT-KEY)
source=source,
suggestion_type=suggestion_type,
target_type=target_type,
category=category,
target_id=target_id,
title=title,
content=data.get("content"),
suggestion_data=sd,
status="unapplied", # 铁律: Bot 只能建议,生效必须人 apply
)
db.add(sug)
db.commit()
db.refresh(sug)
return {
"success": True,
"message": "高危建议已创建,等待人工审批(AI建议中心 → 应用)",
"data": _sug_dict(sug),
"duplicated": False,
}