"""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.deps import require_bot_entity_id 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 → **缺省即报错**。 与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源), 本端点不接收登录token,故不在此处理 Bearer。 ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**—— 缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。 """ 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) eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions") 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, }