"""风控线端到端补测(真实 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())