- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客); 无 key 的存量公开/用户端点行为不变(仍 401/兜底 1) - 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族) 接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径 - deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落 「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无 kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变 - alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份) - 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例); 4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
177 lines
7.8 KiB
Python
177 lines
7.8 KiB
Python
"""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,
|
||
}
|