Files
cma-management/backend/scripts/verify_bot_write_tiering_e2e.py
T
Hermes CI Fix be71c2296e test(bot-tier): M4 生产E2E实证脚本 verify_bot_write_tiering_e2e.py (P0-②)
全PASS: Bot建议→unapplied→人apply→目标生效+apply_detail before/after→低危kpi-result source_batch落库→audit L2留痕→清理回查=0
2026-09-03 18:15:47 +08:00

220 lines
9.2 KiB
Python

#!/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()