Files
cma-management/backend/scripts/verify_forecast_deviation_rules_e2e.py
T
Hermes CI Fix 40d522cd34 fix(security): retire default-credential surface (yanxue-20260914-cma-defaultcred-code-fix)
- Login.vue: no prefilled default creds; no anonymous entity enumeration on mount

- deploy.sh: readiness polling (0.5s/30s cap) replaces fixed sleep 3; login smoke reads same-source 600 secret file, missing file = explicit abort

- 19 verify/check/gen_report scripts: shared _admin_creds() loader (CMA_ADMIN_ENV -> ops .secrets -> profile copy); no hardcoded creds

- tests: single TEST_USER_PASSWORD fixture constant; docs: pointer to 600 secret file; removed 4 legacy .bak + pycache residue
2026-09-14 17:39:53 +08:00

187 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())