Files
group_xinghuo_jinrong/scripts/dev/risk_gap_e2e_smoke.py
T
zhanghongyu_0626 188bf6a438 feat(analyst_agent): Add support for recent days queries and enhance SQL hint generation
- Introduced `_recent_days_series_hint` to handle queries related to "最近N天" for time series data aggregation.
- Added `_cn_num_to_int` function to convert Chinese numerals to integers for better query parsing.
- Updated `_nl_sql_hints` to incorporate the new hint generation logic, ensuring accurate SQL output for recent days queries.
- Enhanced documentation to reflect these changes and improve clarity on the new functionalities.
2026-09-13 22:36:19 +08:00

400 lines
21 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.
"""风控线端到端补测(真实 HTTP · RK7–RK8)。
**为什么另起脚本而不扩 `risk_e2e_smoke.py`:** 那支是 Part C 的**回归基线**,其价值在于
「退出码仍为 0」。RK8 要做的是**修复前/后浏览器结果对照**,天然会产出差异记录;
把它塞进基线脚本会让「真回归退化」与「补测发现」不可区分。故基线脚本**一个字都不动**。
盘点的两个结论决定了本脚本只做**补漏**而非铺开:
1. 风控线**后端 4 条路由已被 `risk_e2e_smoke.py` 全覆盖**(R1 鉴权/R2 列表与分页/
R3 处置状态机/R4 适当性/R5 AML/R6 模拟交易),其中已含 `page_size=101→422`、
`handler_result 非法→422`、`重复处置→409` 等边界。→ 本脚本**只补它没测的面**,
不重复已有断言(重复不会增加证据,只会让报告变长)。
2. 本线真正的缺口在**跨接口自洽**与**未对账的边界**:
- `page=0` 下界(R2 只测了 `page_size` 上界)
- 分页「不重不漏」(page1 ∩ page2 = ∅)
- 同一响应内 `stats` 与 `total` 自洽
- 处置动作与 `stats` 的**前后差值**(跨接口一致性 —— 只测单接口的话,
「处置了但统计没动」这类缺陷永远测不出来)
- 未知资源在**归属校验阶段**与**判定阶段**的两种语义分裂
(`app/api/risk.py:134` 实测:未知**客户** → 404;未知**产品** → 200 结构化 not_found)
**RK8** 是文件级对照:把 `10-risk-pages.mjs` 的本轮产物与上一轮基线
`results-10-risk.json` 按**用例 id** 对齐,专盯 **PASS→FAIL** ——
那正是原来被 `03-risk.mjs:23` 恒真断言(F-01)掩盖的真缺陷。
用法:
python scripts/dev/risk_gap_e2e_smoke.py
python scripts/dev/risk_gap_e2e_smoke.py --only RK7
python scripts/dev/risk_gap_e2e_smoke.py --report docs/.../_raw/rk-gap.md
退出码:0 全过 / 1 有 FAIL / 2 前置自检失败。
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import customer_trade_e2e_smoke as e2e # noqa: E402
from customer_trade_e2e_smoke import ( # noqa: E402
FAIL,
INFO,
PASS,
SKIP,
Ctx,
bad,
body,
data_of,
db_query,
err_code,
expect_status,
expect_true,
info,
login,
ok,
skip,
)
OFFICER = "STAFF-30001" # risk_officer + risk_demo
MANAGER = "STAFF-31001" # risk_manager
COMPLIANCE = "STAFF-40001" # compliance
ADVISOR = "STAFF-10086" # advisor(需归属查询的对照角色)
CUSTOMER = "CUST-9527" # customer(self 域对照角色)
UNKNOWN_CUSTOMER = "CUST-000000" # 格式合法但库中不存在
KNOWN_CUSTOMER = "CUST-1001" # 库中确实存在
KNOWN_PRODUCT = "PROD-110022" # 库中确实存在
UNKNOWN_PRODUCT = "PROD-DOES-NOT-EXIST"
REQUIRED_PATHS = {
"/api/risk/alerts",
"/api/risk/alerts/{alert_id}/handle",
"/api/risk/suitability/check",
"/api/risk/aml/scan",
}
#: 上一轮的浏览器基线产物(仓库外,只读引用;本轮不复制进仓库,避免制造第二份事实源)
BASELINE_10 = Path("C:/Users/Windows/e2e-jinrong/results-10-risk.json")
#: 本轮 `10-risk-pages.mjs` 的产物(由 `_raw/` 下的 env 指定输出目录写出)
CURRENT_10 = (
Path(__file__).resolve().parents[2]
/ "docs" / "memory" / "tests" / "2026-09-13-risk-e2e" / "_raw" / "results-10-risk.json"
)
def risk_h(base: dict) -> dict:
"""风控线请求头:Bearer + **必需的** X-Agent-Type(`risk.py` 走 `get_auth_context`)。"""
return {**base, "X-Agent-Type": "risk"}
def preflight(ctx: Ctx, base_url: str) -> bool:
print(f"== 风控线端到端补测 @ {base_url} ==\n")
failures: list[str] = []
try:
r = ctx.client.get("/api/ready")
print(f" /api/ready -> HTTP {r.status_code}")
if r.status_code != 200:
failures.append(f"/api/ready HTTP {r.status_code}")
except Exception as exc: # noqa: BLE001
failures.append(f"后端不可达 /api/ready: {exc}")
try:
paths = set(ctx.client.get("/openapi.json").json().get("paths", {}))
missing = sorted(REQUIRED_PATHS - paths)
print(f" openapi 路径总数 {len(paths)}(基线 66)· 风控线必需路径缺 {len(missing)}")
failures.extend(f"openapi 缺路径 {p}(后端跑的是旧进程?)" for p in missing)
except Exception as exc: # noqa: BLE001
failures.append(f"取 openapi 失败: {exc}")
# ⚠️ 客户账号必须显式 `token_type="customer"`:默认的 `"staff"` 签不出 `customer_id`,
# self 域判定会失败(实测:客户查自己也得 403 NOT_OWNER)。与 GX 包同一陷阱。
for key, actor, tt in (("officer", OFFICER, "staff"),
("manager", MANAGER, "staff"),
("compliance", COMPLIANCE, "staff"),
("advisor", ADVISOR, "staff"),
("customer", CUSTOMER, "customer")):
try:
ctx.h[key] = login(ctx, actor, tt)
print(f" 登录 {actor}(token_type={tt})-> ok")
except Exception as exc: # noqa: BLE001
failures.append(f"登录 {actor} 失败: {exc}")
if failures:
print("\n前置自检未通过:")
for f in failures:
print(f" - {f}")
return not failures
# --------------------------------------------------------------------------
# RK7 · 后端补漏(只补 `risk_e2e_smoke.py` 未覆盖的面)
# --------------------------------------------------------------------------
def rk7(ctx: Ctx) -> None:
G = "RK7"
h = risk_h(ctx.h["officer"])
path = "/api/risk/alerts"
# ── 7-01 分页**下界**(R2 只测了 page_size 上界)──
r = ctx.client.get(path, headers=h, params={"page": 0})
expect_true(G, "7-01 page=0 → 422(Query(ge=1) 下界)",
r.status_code == 422, "HTTP 422 REQUEST_VALIDATION_FAILED",
f"HTTP {r.status_code} {err_code(r)}")
r = ctx.client.get(path, headers=h, params={"page": -3})
expect_true(G, "7-01b page=-3 → 422(负数同样越界)",
r.status_code == 422, "HTTP 422",
f"HTTP {r.status_code} {err_code(r)}")
# ── 7-02 远超末页:不得报错,且分页元数据要自洽 ──
r_first = ctx.client.get(path, headers=h, params={"page": 1, "page_size": 5})
d_first = body(r_first) if r_first.status_code == 200 else {}
total = d_first.get("total")
r_far = ctx.client.get(path, headers=h, params={"page": 999999, "page_size": 5})
if expect_status(G, "7-02 超末页 page=999999 → 200(不报错)", r_far, 200):
d_far = body(r_far)
expect_true(G, "7-02a 超末页 items 为空列表",
d_far.get("items") == [], "items=[]", f"items={d_far.get('items')!r}")
expect_true(G, "7-02b 超末页 total 与首页一致(分页元数据不随页码漂移)",
d_far.get("total") == total, f"total={total}", f"total={d_far.get('total')!r}")
expect_true(G, "7-02c 超末页回显 page 与请求一致",
d_far.get("page") == 999999, "page=999999", f"page={d_far.get('page')!r}")
# ── 7-03 不重不漏:相邻两页的 alert_id 交集必须为空 ──
r1 = ctx.client.get(path, headers=h, params={"page": 1, "page_size": 5})
r2 = ctx.client.get(path, headers=h, params={"page": 2, "page_size": 5})
if r1.status_code == 200 and r2.status_code == 200:
ids1 = {i.get("alert_id") for i in (body(r1).get("items") or [])}
ids2 = {i.get("alert_id") for i in (body(r2).get("items") or [])}
dup = ids1 & ids2
expect_true(G, "7-03 page1 ∩ page2 = ∅(分页不重复)",
not dup, "交集为空", f"重复 {sorted(x for x in dup if x)}")
expect_true(G, "7-03a 两页各自非空(否则上一条会因空集假过)",
len(ids1) > 0 and len(ids2) > 0, "两页均非空",
f"page1={len(ids1)} page2={len(ids2)}")
else:
skip(G, "7-03 分页不重不漏", f"取两页失败 {r1.status_code}/{r2.status_code}")
# ── 7-04 同一响应内自洽:按 status 过滤时,items 状态必须全等于该过滤值 ──
r = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
if expect_status(G, "7-04 status=pending_review 过滤生效", r, 200):
d = body(r)
items = d.get("items") or []
states = {i.get("status") for i in items}
expect_true(G, "7-04a 过滤后 items 状态集合 = {pending_review}",
states <= {"pending_review"} and len(items) > 0,
"items 全为 pending_review 且非空", f"n={len(items)} states={sorted(s for s in states if s)}")
# 同一响应内 stats 与 total 的自洽(不看库,纯接口内部一致性)
stats = d.get("stats") or {}
pr = stats.get("pending_review_count")
expect_true(G, "7-04b stats.pending_review_count 与 total 自洽(同响应内不矛盾)",
pr == d.get("total"), f"stats.pending_review_count == total == {d.get('total')}",
f"stats={pr!r} total={d.get('total')!r}")
# ── 7-05 只读对账:接口 total 与库计数一致(agent 库)──
rows = db_query("SELECT COUNT(*) FROM risk_alert WHERE status='pending_review'", "agent")
if rows and r.status_code == 200:
db_n = rows[0][0]
api_n = body(r).get("total")
expect_true(G, "7-05 接口 total 与库计数一致(pending_review)",
db_n == api_n, f"DB={db_n}", f"API total={api_n!r} / DB={db_n}")
else:
info(G, "7-05 接口 total 与库计数对账", "库查询不可用,跳过对账(不代表通过)")
# ── 7-06 处置动作 → stats 的**前后差值**(跨接口一致性)──
# 这是本组最有价值的一条:只测单接口时,「处置成功但统计没动」永远测不出来。
# 副作用说明:本用例会把 1 条 pending 预警推进为已处置;`risk_alert` 属追加型
# 审计表,按纪律**不还原**,故这是**有意的、记录在案的残留**(不新增行,只改状态)。
before = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
cand = (body(before).get("items") or []) if before.status_code == 200 else []
if not cand:
skip(G, "7-06 处置 → stats 前后差值", "取不到 pending_review 预警,无法构造")
else:
aid = cand[0].get("alert_id")
n_before = body(before).get("total")
rh = ctx.client.post(f"/api/risk/alerts/{aid}/handle", headers=h,
json={"handler_result": "confirmed_normal", "handler_comment": "E2E RK7-06 前后差值取证"})
if expect_status(G, f"7-06 处置 {aid}", rh, 200):
after = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
n_after = body(after).get("total")
expect_true(G, "7-06a 处置后 pending_review 计数恰好 -1(前后差值,非绝对计数)",
isinstance(n_after, int) and isinstance(n_before, int) and n_before - n_after == 1,
f"{n_before} → {n_before - 1}", f"{n_before} → {n_after}")
info(G, "7-06b 处置残留登记", f"alert_id={aid} 已由 pending_review 推进为 confirmed_normal"
"(追加型审计表,按纪律不还原)")
# ── 7-07 未知资源的语义分裂(R10 边界)──
# 首跑我按源码 `risk.py:134-135`(LookupError → 404)**推断**「未知客户必 404」,
# 实测**该分支没被触发**:`assert_customer_access` 对全量读角色**先 return**、
# 根本不做存在性查询,于是未知客户直接进入判定阶段 → 200 结构化 not_found。
# 三个演示角色实测三分(详见 7-07a/b/c),**没有任何一条走到 404**。
r = ctx.client.post("/api/risk/suitability/check", headers=h,
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
if expect_status(G, "7-07 未知客户(risk_officer 全量读)→ 200 结构化返回", r, 200):
d = body(r)
expect_true(G, "7-07a officer 视图下未知客户被判 blocked(fail-closed,非静默放行)",
d.get("blocked") is True and d.get("mismatch_type") == "not_found",
"blocked=True + mismatch_type='not_found'",
f"blocked={d.get('blocked')!r} mismatch_type={d.get('mismatch_type')!r} "
f"code={d.get('block_response_code')!r}")
info(G, "7-07b `risk.py:134` 的 404 分支本轮**未被触发**",
"三个演示角色(officer/advisor/customer)打未知客户分别得 200/403/403,"
"无一走 404 —— 该分支的可达条件本轮未能构造,属**未覆盖路径**而非缺陷")
# ⚠️ `X-Agent-Type` 必须与**账号真实类型**一致,否则先在矩阵层被拦:
# advisor 账号带 `X-Agent-Type: risk` → `AUTH_403_AGENT_MISMATCH`(实测,见下条对照),
# 根本走不到归属校验。故这里用 advisor 通道,才能验证「归属查询分支」。
hr = {**ctx.h["advisor"], "X-Agent-Type": "advisor"}
r_bad_channel = ctx.client.post("/api/risk/suitability/check",
headers={**ctx.h["advisor"], "X-Agent-Type": "risk"},
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
expect_true(G, "7-07c 通道值与账号类型不符 → 矩阵层拦 AGENT_MISMATCH(早于归属校验)",
r_bad_channel.status_code == 403 and err_code(r_bad_channel) == "AUTH_403_AGENT_MISMATCH",
"HTTP 403 AUTH_403_AGENT_MISMATCH",
f"HTTP {r_bad_channel.status_code} {err_code(r_bad_channel)}")
r = ctx.client.post("/api/risk/suitability/check", headers=hr,
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
expect_true(G, "7-07d 未知客户(advisor 归属查询分支)→ 403 NOT_ASSIGNED(非 404)",
r.status_code == 403 and err_code(r) == "AUTH_403_NOT_ASSIGNED",
"HTTP 403 AUTH_403_NOT_ASSIGNED", f"HTTP {r.status_code} {err_code(r)}")
hc = {**ctx.h["customer"], "X-Agent-Type": "customer"}
r = ctx.client.post("/api/risk/suitability/check", headers=hc,
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
expect_true(G, "7-07e 未知客户(customer self 域)→ 403 NOT_OWNER(非 404)",
r.status_code == 403 and err_code(r) == "AUTH_403_NOT_OWNER",
"HTTP 403 AUTH_403_NOT_OWNER", f"HTTP {r.status_code} {err_code(r)}")
r = ctx.client.post("/api/risk/suitability/check", headers=h,
json={"customer_id": KNOWN_CUSTOMER, "product_id": UNKNOWN_PRODUCT})
if expect_status(G, "7-08 已知客户 + 未知**产品** → 200(判定阶段结构化返回,不 404)", r, 200):
d = body(r)
expect_true(G, "7-08a 未知产品被判为 blocked 且 mismatch_type='not_found'",
d.get("blocked") is True and d.get("mismatch_type") == "not_found",
"blocked=True + mismatch_type='not_found'",
f"blocked={d.get('blocked')!r} mismatch_type={d.get('mismatch_type')!r} "
f"code={d.get('block_response_code')!r}")
info(G, "7-08b 同路由的「找不到」语义分裂(记录,非缺陷判定)",
"未知**产品**=200+blocked;未知**客户**则按角色三分(officer 200 / advisor 403 / customer 403),"
"**三者都不是 404** —— 见 7-07 组;`risk.py:134` 的 404 分支本轮未触发")
# ── 7-09 阻断类响应必须带 G-08 三要素(advice/notice)──
# `risk.py:157-161`:仅当 blocked 为真才补 advice/notice。
if r.status_code == 200:
d = body(r)
expect_true(G, "7-09 阻断响应补齐 advice/notice(G-08 三要素)",
bool(d.get("advice")) and bool(d.get("notice")),
"advice + notice 均非空",
f"advice={str(d.get('advice'))[:40]!r} notice={str(d.get('notice'))[:40]!r}")
# ── 7-10 时间字段口径(记录):接口返回的是 UTC naive 还是本地时间 ──
r = ctx.client.get(path, headers=h, params={"page_size": 1})
if r.status_code == 200:
items = body(r).get("items") or []
if items:
created = items[0].get("created_at")
info(G, "7-10 预警 created_at 口径", f"{created!r}"
"(与本地时区关系见报告;影响「近 N 分钟」类查询,R4 窗口陷阱的根源)")
# --------------------------------------------------------------------------
# RK8 · 浏览器结果对照(修复前后 PASS→FAIL 追踪)
# --------------------------------------------------------------------------
def rk8(ctx: Ctx) -> None:
"""把本轮 `10-risk-pages.mjs` 产物与上一轮基线按**用例 id** 对齐。
**专盯 PASS→FAIL**:`03-risk.mjs:23` 的恒真断言(F-01)被修掉之后,原本被它
掩盖的真缺陷会在此暴露。PASS→PASS 只计数,不逐条列(避免噪音淹没信号)。
"""
G = "RK8"
if not BASELINE_10.exists():
skip(G, "8-00 浏览器结果对照", f"基线产物不存在:{BASELINE_10}")
return
if not CURRENT_10.exists():
skip(G, "8-00 浏览器结果对照",
f"本轮产物不存在:{CURRENT_10}(须先跑 Part B 的 10-risk-pages.mjs 并指定 E2E_OUT_DIR)")
return
def load(p: Path) -> dict:
d = json.loads(p.read_text(encoding="utf-8"))
return {r.get("id"): r for r in d.get("results", []) if isinstance(r, dict)}
old, new = load(BASELINE_10), load(CURRENT_10)
info(G, "8-00 对照规模", f"基线 {len(old)} 条 / 本轮 {len(new)} 条 · 交集 {len(set(old) & set(new))} 条")
regressed = [k for k in set(old) & set(new)
if old[k].get("status") == "PASS" and new[k].get("status") != "PASS"]
fixed = [k for k in set(old) & set(new)
if old[k].get("status") != "PASS" and new[k].get("status") == "PASS"]
only_new = sorted(set(new) - set(old))
only_old = sorted(set(old) - set(new))
for k in sorted(regressed):
bad(G, f"8-01 ★PASS→FAIL★ {k} {new[k].get('name')}",
"修复恒真断言后仍应 PASS", f"{new[k].get('status')} · {str(new[k].get('actual'))[:160]}",
json.dumps({"before": old[k].get("actual"), "after": new[k].get("actual")},
ensure_ascii=False)[:400])
if not regressed:
ok(G, "8-01 无 PASS→FAIL 退化(恒真断言修复后原 PASS 项全部仍 PASS)",
"∅", f"比较 {len(set(old) & set(new))} 条交集")
info(G, "8-02 修复的旧 FAIL 条目", f"{sorted(fixed) if fixed else '无'}")
info(G, "8-03 仅本轮新增的用例", f"{only_new if only_new else '无'}")
info(G, "8-04 仅基线有的用例(本轮未复跑)", f"{only_old if only_old else '无'}")
GROUPS = {"RK7": rk7, "RK8": rk8}
ORDER = ["RK7", "RK8"]
def main() -> int:
parser = argparse.ArgumentParser(description="风控线端到端补测(RK7–RK8)")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--report", default=None)
parser.add_argument("--only", action="append", default=None)
parser.add_argument("--timeout", type=float, default=120.0)
args = parser.parse_args()
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ctx = Ctx(client=httpx.Client(base_url=args.base_url.rstrip("/"), timeout=args.timeout))
try:
if not preflight(ctx, args.base_url):
print("\n前置自检未通过 —— 未产生任何用例判定。")
return 2
selected = [g for g in ORDER if not args.only or g in args.only]
print(f"\n-- 执行分组: {', '.join(selected)} --\n")
for name in selected:
try:
GROUPS[name](ctx)
except Exception as exc: # noqa: BLE001
bad(name, f"{name}-00 分组执行异常", "分组正常跑完",
f"{type(exc).__name__}: {exc}", traceback.format_exc()[-800:])
finally:
ctx.client.close()
counts = {s: sum(1 for r in e2e.RESULTS if r.status == s) for s in (PASS, FAIL, SKIP, INFO)}
print(f"\n== 结果: {counts[PASS]} PASS / {counts[FAIL]} FAIL / "
f"{counts[SKIP]} SKIP / {counts[INFO]} INFO ==")
for r in e2e.RESULTS:
if r.status == FAIL:
print(f" - {r.group} · {r.name}: 期望 {r.expected} / 实际 {r.actual[:160]}")
if args.report:
e2e.write_report(Path(args.report))
print(f"\n报告已写入 {args.report}")
return 1 if counts[FAIL] else 0
if __name__ == "__main__":
sys.exit(main())