#!/usr/bin/env python3 """生产E2E实证: Bot写操作分级+分层授权 (P0-② 2026-09-03) 链路验证: A. Bot通道(X-BOT-KEY)创建 kpi_target 高危建议 → status=unapplied B. 人token apply → KPI目标值变更 + apply_detail(before→after) + applied_by/applied_at C. 低危直写 bot-bridge /kpi-result (X-BRIDGE-TOKEN) → KPIValue.source_batch 落库 D. bot_audit.log 留痕(两条: 建议创建L2 + kpi-result L2) 数据纪律: 临时KPI/建议/值 全部带【系统验证临时数据-验证后删除】标记, 验证完成后精确清理并回查 COUNT=0。审计日志文件行保留(留痕证据)。 用法: cd /root/cma-management/backend && venv/bin/python3 scripts/verify_bot_write_tiering_e2e.py """ import json import os import sys import urllib.request import urllib.error BASE = "http://127.0.0.1:8010" MARK = "【系统验证临时数据-验证后删除】" KPI_CODE = "E2E_WT_TIER" SUG_PREFIX = "E2E-验证" CUR_PERIOD = "2026-09" BASELINE_TARGET = 100.0 NEW_TARGET = 888.88 LOW_VALUE = 777.0 SOURCE = "e2e-bot-tier-20260903" sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("CMA_DB_HOST", "127.0.0.1") from app.database import get_db # noqa: E402 from app.models import ( # noqa: E402 BotBridgeConfig, KPIDefinition, KPIValue, AISuggestion, OperationLog, Entity, ) def http(method, path, body=None, headers=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(BASE + path, data=data, method=method) req.add_header("Content-Type", "application/json") for k, v in (headers or {}).items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, json.loads(r.read().decode()) except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode()) def main(): failures = [] def check(name, cond, detail=""): tag = "PASS" if cond else "FAIL" print(f" [{tag}] {name}" + (f" | {detail}" if detail else "")) if not cond: failures.append(name) # ── 0. 取Bot密钥与bridge token ── with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")) as f: env = {} for line in f: if "=" in line and not line.strip().startswith("#"): k, _, v = line.strip().partition("=") env[k] = v.strip().strip('"').strip("'") keys = json.loads(env["CMA_BOT_API_KEYS"]) fin_key = next(k for k, v in keys.items() if v.get("name") == "财务BOT") db = next(get_db()) bridge = db.query(BotBridgeConfig).filter_by(is_active=True).first() bridge_token = bridge.token if bridge else None db.close() # ── 1. 登录(人token, entity=1) ── st, r = http("POST", "/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1}) check("登录 admin entity=1", st == 200, f"http={st}") token = r.get("token") if st == 200 else "" auth = {"Authorization": f"Bearer {token}"} # ── 2. 临时KPI准备(存在则复用, 目标值重置基线) ── db = next(get_db()) db.rollback() kpi = db.query(KPIDefinition).filter( KPIDefinition.kpi_code == KPI_CODE, KPIDefinition.entity_id == 1, ).first() if not kpi: kpi = KPIDefinition( entity_id=1, kpi_code=KPI_CODE, kpi_name=f"{MARK}写分层E2E临时KPI", dimension="finance", category="financial_report", formula="-", data_source_type="manual", data_source=f"{MARK}E2E", data_owner="系统验证", frequency="monthly", unit="万元", target_value=BASELINE_TARGET, kpi_level="operational", status="active", ) db.add(kpi) db.commit() db.refresh(kpi) kpi_id = kpi.id db.close() else: kpi_id = kpi.id kpi.target_value = BASELINE_TARGET db.commit() db.refresh(kpi) db.close() print(f" 临时KPI id={kpi_id} code={KPI_CODE} baseline={BASELINE_TARGET}") # ── 3. Bot创建高危建议(kpi_target) ── sug_title = f"{SUG_PREFIX} 上调临时KPI目标→{NEW_TARGET}" st, r = http("POST", "/api/cma/bot/ai/suggestions", { "suggestion_type": "kpi_target", "title": sug_title, "content": "分层授权E2E实证", "source": "bot", "entity_id": 1, "target_id": kpi_id, "suggestion_data": {"kpi_id": kpi_id, "target_value": NEW_TARGET}, }, {"X-BOT-KEY": fin_key}) check("A1 Bot创建建议 200", st == 200, f"http={st} body={str(r)[:200]}") sug_id = r.get("data", {}).get("id") if st == 200 else None check("A2 status=unapplied", st == 200 and r["data"]["status"] == "unapplied", f"id={sug_id} source={r.get('data', {}).get('source')}") # ── 4. 人apply ── st, r = http("POST", f"/api/cma/ai/suggestions/{sug_id}/apply", {"action": "kpi_target", "target_value": NEW_TARGET}, auth) check("B1 apply 200", st == 200, f"http={st} body={str(r)[:200]}") ad = r.get("data", {}) if st == 200 else {} detail = (ad.get("apply_detail") or [{}])[0] check("B2 apply_detail before→after", detail.get("before") == BASELINE_TARGET and detail.get("after") == NEW_TARGET, str(detail)[:160]) check("B3 applied_by/applied_at", bool(ad.get("applied_by")) and bool(ad.get("applied_at")), f"by={ad.get('applied_by')} at={ad.get('applied_at')}") db = next(get_db()) db.rollback() kpi2 = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() db_target = getattr(kpi2, "target_value", None) if kpi2 else None check("B4 KPI目标值生效", db_target == NEW_TARGET, f"db_target={db_target}") db.close() # ── 5. 低危直写 /kpi-result ── st, r = http("POST", "/api/cma/bot-bridge/kpi-result", { "entity_id": 1, "kpi_code": KPI_CODE, "period": CUR_PERIOD, "value": LOW_VALUE, "source": SOURCE, "remark": MARK, }, {"X-BRIDGE-TOKEN": bridge_token}) check("C1 kpi-result 200", st == 200, f"http={st} body={str(r)[:200]}") db = next(get_db()) db.rollback() val = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, KPIValue.period == CUR_PERIOD, ).order_by(KPIValue.id.desc()).first() check("C2 source_batch落库", val is not None and val.source_batch == SOURCE, f"source_batch={val.source_batch if val else None} actual={val.actual_value if val else None}") db.close() # ── 6. audit留痕 ── audit_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs", "bot_audit.log") lines = [] if os.path.exists(audit_path): with open(audit_path, encoding="utf-8") as f: lines = f.readlines() audit_str = "".join(lines[-60:]) check("D1 audit含建议创建记录", "/api/cma/bot/ai/suggestions" in audit_str and '"status": 200' in audit_str, f"sug_id={sug_id}") check("D2 audit含kpi-result记录", "/api/cma/bot-bridge/kpi-result" in audit_str and '"status": 200' in audit_str) # 取最近一条(倒序)建议创建成功记录验证字段;早于重启的401是pytest进程写文件的噪音 rec = None for l in reversed(lines[-60:]): try: r = json.loads(l) except Exception: continue if r.get("endpoint") == "/api/cma/bot/ai/suggestions" and r.get("status") == 200: rec = r break if rec: check("D3 建议创建audit字段(bot_name/L2)", rec.get("bot_name") == "财务BOT" and rec.get("risk_level") == "L2", str(rec)[:200]) # ── 7. 清理(标记精确删除) + 回查 ── db = next(get_db()) db.rollback() del_sug = db.query(AISuggestion).filter( AISuggestion.title.like(f"{SUG_PREFIX}%"), AISuggestion.title.like("%上调临时KPI目标%"), ).delete(synchronize_session=False) del_val = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, KPIValue.source_batch == SOURCE, ).delete(synchronize_session=False) db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply", OperationLog.target_id == kpi_id).delete(synchronize_session=False) del_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).delete(synchronize_session=False) db.commit() db.rollback() left_sug = db.query(AISuggestion).filter( AISuggestion.title.like(f"{SUG_PREFIX}%"), AISuggestion.title.like("%上调临时KPI目标%"), ).count() left_val = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, KPIValue.source_batch == SOURCE, ).count() left_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).count() check("E1 清理回查=0", left_sug == 0 and left_val == 0 and left_kpi == 0, f"sug={left_sug} val={left_val} kpi={left_kpi}") db.close() print() if failures: print(f"E2E RESULT: FAIL ({len(failures)}): {failures}") sys.exit(1) print(f"E2E RESULT: PASS | 建议id={sug_id}(已清理) kpi_id={kpi_id}(已清理) 审计行保留于 logs/bot_audit.log") if __name__ == "__main__": main()