#!/usr/bin/env python3 """任务A 交付验收脚本 — forecast_deviation 预测偏差规则(酣客entity1 / 博海entity2) 做四件事(可复跑,幂等): 1. 经生产 API(管理员 JWT)为 entity1 / entity2 各建 1 条 forecast_deviation 规则 2. 回查 API 列表 + 直查 DB 实证规则存在(含 rule_id / kpi / params) 3. 触发测试:给 entity1 规则构造一期「预测偏差超阈值」场景(带标记的临时 KPIValue) → 调 POST /api/cma/alert-rules/run-forecast-deviation → 断言 pending 预警生成 4. 清理:删除临时 KPIValue 与该次生成的预警,回查归零(生产不留假数据) 用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_forecast_deviation_rules_e2e.py """ import json import sys import urllib.request import urllib.error import os # noqa: E402 (凭据加载器需要) # ---- 管理员凭据同源加载(2026-09-14 cma-defaultcred-code-fix)---- # 生产凭据只存 600 密钥文件;禁止在脚本/文档/日志里硬编码口令。 # 顺序:$CMA_ADMIN_ENV → wecom-ops 主副本(ops 轮换落点,权威) → 本 profile 副本。 def _admin_creds(): """返回 (user, password, 来源文件);未找到 → 显式报错退出(不静默退回默认口令)""" for p in (os.environ.get("CMA_ADMIN_ENV"), "/root/.hermes/profiles/wecom-ops/.secrets/cma-admin.env", "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-admin.env"): if p and os.path.isfile(p): kv = {} for line in open(p, encoding="utf-8"): line = line.strip() if "=" in line and not line.startswith("#"): k, v = line.split("=", 1) kv[k.strip()] = v.strip().strip('"').strip("'") if kv.get("CMA_ADMIN_USER") and kv.get("CMA_ADMIN_PASS"): return kv["CMA_ADMIN_USER"], kv["CMA_ADMIN_PASS"], p raise SystemExit("[错误] 未找到 CMA 管理员凭据:请设 $CMA_ADMIN_ENV,或准备 600 权限密钥文件 " ".secrets/cma-admin.env(键名 CMA_ADMIN_USER / CMA_ADMIN_PASS)") ADMIN_USER, ADMIN_PASS, CRED_SRC = _admin_creds() print(f"[creds] 来源={CRED_SRC} 账号={ADMIN_USER}(口令不回显)") BASE = "http://127.0.0.1:8010" ADMIN = {"username": ADMIN_USER, "password": ADMIN_PASS} MARK = "【系统验证临时数据-验证后删除】" # 规则目标:酣客 → 应收账款周转天数(F_AR_DAYS, id=6,有 2026-09 预测日志) # 博海 → 营收(BH_REVENUE, id=421) TARGETS = [ {"entity_id": 1, "kpi_id": 6, "kpi_label": "F_AR_DAYS"}, {"entity_id": 2, "kpi_id": 421, "kpi_label": "BH_REVENUE"}, ] RULE_TYPE = "forecast_deviation" PARAMS = {"threshold_pct": 15, "red_ratio": 2} # >15% 黄,>30% 红(实现按 threshold×2 定红) def http(method, path, token=None, body=None, timeout=30): url = BASE + path data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Content-Type", "application/json") if token: req.add_header("Authorization", f"Bearer {token}") try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, json.loads(r.read().decode() or "{}") except urllib.error.HTTPError as e: return e.code, {"detail": e.read().decode()[:300]} def login(entity_id): st, r = http("POST", "/api/cma/auth/login", body={**ADMIN, "entity_id": entity_id}) assert st == 200, f"登录 entity{entity_id} 失败: {st} {r}" return r["token"], r.get("entity_name") def main(): ok = True # ── 1+2. 建规则 ──────────────────────────────────────────── print("=== 1. 创建 forecast_deviation 规则 ===") created = {} for t in TARGETS: eid = t["entity_id"] token, ename = login(eid) st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token) assert st == 200, f"列规则失败: {st} {rules}" exist = [r for r in rules.get("data", []) if r["kpi_id"] == t["kpi_id"] and r["rule_type"] == RULE_TYPE] if exist: rid = exist[0]["id"] print(f" entity{eid}({ename}) {t['kpi_label']}: 已存在 rule_id={rid}(幂等跳过)") else: st, r = http("POST", "/api/cma/alert-rules", token=token, body={ "kpi_id": t["kpi_id"], "rule_type": RULE_TYPE, "trigger_on": "actual", "enabled": 1, "params": PARAMS, }) assert st == 200, f"建规则失败 entity{eid}: {st} {r}" rid = r["data"]["id"] print(f" entity{eid}({ename}) {t['kpi_label']}: 新建 rule_id={rid} params={r['data']['params']}") created[eid] = rid # ── 3. DB 回查 ───────────────────────────────────────────── print("=== 2. DB 回查实证 ===") sys.path.insert(0, ".") from app.database import get_session_local from app.models import KPIDefinition, KPIValue, KPIAlert, KpiForecastLog from app.api.alert_rules import AlertRule db = get_session_local()() try: for eid, rid in created.items(): row = db.query(AlertRule).filter(AlertRule.id == rid).first() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == row.kpi_id).first() print(f" rule_id={row.id} entity_id={row.entity_id} kpi_id={row.kpi_id}" f"({kpi.kpi_code if kpi else '?'}) type={row.rule_type} enabled={row.enabled} params={row.params}") assert row.entity_id == eid and row.rule_type == RULE_TYPE assert row.enabled == 1 # ── 4. 触发测试(entity1)───────────────────────────── print("=== 3. 触发测试(构造偏差场景, entity1)===") t1 = TARGETS[0] rid = created[1] rule = db.query(AlertRule).filter(AlertRule.id == rid).first() log = (db.query(KpiForecastLog) .filter(KpiForecastLog.entity_id == 1, KpiForecastLog.kpi_id == rule.kpi_id) .order_by(KpiForecastLog.created_at.desc()).first()) forecast = float(log.forecast_value) # 构造:实际值 = 预测值的一半 → 偏差 100% > 15% → red(>30%) actual = round(forecast / 2, 2) temp = KPIValue(kpi_id=rule.kpi_id, entity_id=1, period=log.period, actual_value=actual, source_type="manual", data_status="pending", remark=MARK) db.add(temp) db.commit() db.refresh(temp) print(f" 临时KPIValue id={temp.id} period={log.period} 预测={forecast} 实际={actual}") token1, _ = login(1) st, r = http("POST", "/api/cma/alert-rules/run-forecast-deviation", token=token1, timeout=60) assert st == 200, f"触发失败: {st} {r}" print(f" run-forecast-deviation → {r}") db.expire_all() # MySQL REPEATABLE READ:API 在另一进程提交,本 session 需结束旧事务快照才能读到 db.rollback() alerts = db.query(KPIAlert).filter( KPIAlert.entity_id == 1, KPIAlert.kpi_id == rule.kpi_id, KPIAlert.alert_type == "forecast", KPIAlert.status == "pending", ).order_by(KPIAlert.id.desc()).all() assert alerts, "未生成预测偏差预警 → 触发测试 FAIL" a = alerts[0] alert_id = a.id print(f" ✅ 预警生成: id={a.id} level={a.alert_level} type={a.alert_type}") print(f" message={a.alert_message}") assert a.alert_level == "red", f"偏差100%应为红灯, 实际={a.alert_level}" # ── 5. 清理 ──────────────────────────────────────────── print("=== 4. 清理临时数据(生产不留假数据)===") temp_id = temp.id db.rollback() a_row = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() temp_row = db.query(KPIValue).filter(KPIValue.id == temp_id).first() if a_row: db.delete(a_row) if temp_row: db.delete(temp_row) db.commit() left_v = db.query(KPIValue).filter(KPIValue.remark == MARK).count() left_a = db.query(KPIAlert).filter(KPIAlert.id == alert_id).count() print(f" 回查: 临时KPIValue剩余={left_v} / 本次预警剩余={left_a}") assert left_v == 0 and left_a == 0 print("=== 5. 规则终态(交付物保留)===") for eid, rid in created.items(): row = db.query(AlertRule).filter(AlertRule.id == rid).first() print(f" entity{eid}: rule_id={row.id} kpi_id={row.kpi_id} type={row.rule_type} enabled={row.enabled}") finally: db.close() print("\nALL PASS" if ok else "\nFAILED") return 0 if __name__ == "__main__": sys.exit(main())