Files
cma-management/backend/scripts/verify_customer_concentration_rule_e2e.py
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

185 lines
8.7 KiB
Python
Raw Permalink 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
"""任务B 交付验收脚本 — customer_concentration 客户集中度预警(生产 E2E)
做四件事(可复跑):
1. 为 entity1 配置常驻客户集中度规则(KPI=C_CUST_CONCENTRATION, source=auto → zxt 客户收入)
2. 打印真实数据下的单一客户占比(zxt_masterbill 近12个月)作为基线
3. 触发测试:建带标记的临时规则(source=kpi)+ 带标记的临时 KPIValue(占比 68%)
→ 调 POST /api/cma/alert-rules/run-customer-concentration → 断言 pending 预警生成
4. 清理临时规则/KPIValue/预警,回查归零
用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_customer_concentration_rule_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 = "【系统验证临时数据-验证后删除】"
TEMP_TAG = "e2e-verify-temp"
RULE_TYPE = "customer_concentration"
KPI_ID = 23 # C_CUST_CONCENTRATION 客户集中度 (entity1)
def http(method, path, token=None, body=None, timeout=60):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode() if body is not None else None,
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=1):
st, r = http("POST", "/api/cma/auth/login", body={**ADMIN, "entity_id": entity_id})
assert st == 200, f"登录失败: {st} {r}"
return r["token"]
def main():
sys.path.insert(0, ".")
from app.database import get_session_local
from app.models import KPIValue, KPIAlert, KPIDefinition
from app.api.alert_rules import AlertRule, _customer_revenue_shares
token = login(1)
db = get_session_local()()
created_ids = []
try:
# ── 1+2. 常驻规则 + 真实基线 ──────────────────────────────
print("=== 1. 常驻客户集中度规则(entity1)===")
st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
assert st == 200, f"列规则失败: {st} {rules}"
perm = [r for r in rules.get("data", []) if r["kpi_id"] == KPI_ID and r["entity_id"] == 1
and not (r.get("params") or {}).get("_verify")]
if perm:
print(f" 已存在 rule_id={perm[0]['id']}(幂等跳过)")
else:
st, r = http("POST", "/api/cma/alert-rules", token=token, body={
"kpi_id": KPI_ID,
"rule_type": RULE_TYPE,
"trigger_on": "actual",
"enabled": 1,
"params": {"threshold_pct": 50, "red_ratio": 1.5, "top_n": 1, "source": "auto"},
})
assert st == 200, f"建规则失败: {st} {r}"
print(f" 新建 rule_id={r['data']['id']} params={r['data']['params']}")
print("=== 2. 真实数据基线(zxt_masterbill 近12个月, entity1)===")
db.rollback()
shares, total, rng = _customer_revenue_shares(db, 1, lookback_months=12, top_n=5)
assert total > 0, "entity1 无客户收入数据 → 基线取数失败"
print(f" 客户收入合计={total:,.0f}元 ({rng[0]}~{rng[1]}), 客户数={len(shares)}")
for uid, amt, pct in shares:
print(f" 客户#{uid}: {amt:,.0f}元 / {pct}%")
top1 = shares[0][2]
print(f" → 单一客户占比 {top1}%(阈值50% → {'触发' if top1 > 50 else '未触发'})")
# ── 3. 触发测试(临时规则 + 临时KPI值)────────────────────
print("=== 3. 触发测试(构造占比超阈值场景)===")
st, r = http("POST", "/api/cma/alert-rules", token=token, body={
"kpi_id": KPI_ID,
"rule_type": RULE_TYPE,
"trigger_on": "actual",
"enabled": 1,
"params": {"threshold_pct": 50, "red_ratio": 1.5, "top_n": 1,
"source": "kpi", "_verify": TEMP_TAG},
})
assert st == 200, f"建临时规则失败: {st} {r}"
temp_rule_id = r["data"]["id"]
created_ids.append(temp_rule_id)
print(f" 临时规则 rule_id={temp_rule_id} (source=kpi, 阈值50%)")
db.rollback()
temp = KPIValue(kpi_id=KPI_ID, entity_id=1, period="2026-08", actual_value=68.0,
source_type="manual", data_status="pending", remark=MARK)
db.add(temp)
db.commit()
db.refresh(temp)
temp_kv_id = temp.id
print(f" 临时KPIValue id={temp_kv_id} 占比=68.0%")
st, r = http("POST", "/api/cma/alert-rules/run-customer-concentration", token=token)
assert st == 200, f"触发失败: {st} {r}"
print(f" run-customer-concentration → {r}")
db.rollback()
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == 1, KPIAlert.kpi_id == KPI_ID,
KPIAlert.alert_type == "concentration", 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 == "yellow", f"68%应为黄灯(50%~75%), 实际={a.alert_level}"
# ── 4. 清理 ────────────────────────────────────────────
print("=== 4. 清理临时数据(生产不留假数据)===")
db.rollback()
for obj in (db.query(KPIAlert).filter(KPIAlert.id == alert_id).first(),
db.query(KPIValue).filter(KPIValue.id == temp_kv_id).first(),
db.query(AlertRule).filter(AlertRule.id == temp_rule_id).first()):
if obj:
db.delete(obj)
db.commit()
left = {
"alert": db.query(KPIAlert).filter(KPIAlert.id == alert_id).count(),
"kpi_value": db.query(KPIValue).filter(KPIValue.remark == MARK).count(),
"temp_rule": db.query(AlertRule).filter(AlertRule.id == temp_rule_id).count(),
}
print(f" 回查: {left}")
assert all(v == 0 for v in left.values())
# ── 5. 终态 ────────────────────────────────────────────
print("=== 5. 常驻规则终态 ===")
st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
for r in rules.get("data", []):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r["kpi_id"]).first()
print(f" rule_id={r['id']} entity={r['entity_id']} kpi={kpi.kpi_code if kpi else r['kpi_id']}"
f" enabled={r['enabled']} params={r['params']}")
finally:
db.close()
print("\nALL PASS")
return 0
if __name__ == "__main__":
sys.exit(main())