Files
group_xinghuo_jinrong/scripts/dev/customer_trade_e2e_smoke.py
T
zhanghongyu_0626 5c18164d34 fix(trade): close CT-001 P0/P1/L1 and align customer-trade E2E
Dev login no longer honors injected roles; self-service simulate/convert
blocks R4 disclosure grades like the UI and chat already do. Reuse of a
convert idempotency key with a different body returns 409. CT6 redeem qty
is derived from T+2 lots instead of a fixed 35000; CT7-05 and CT10-07
assertions follow. Update project memory and v1.1 post-fix test artifacts.
2026-09-13 20:04:41 +08:00

2233 lines
112 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.
"""客户线交易端到端 API 冒烟(真实 HTTP · CT1–CT12)。
对**运行中的** uvicorn 发真实 HTTP 请求,覆盖客户自助交易的完整链路:
- 申购 / 赎回 / 转换 T+1 受理(`POST /api/simulate/trade`)
- 撤单 / 查询(`/api/simulate/trade/convert/{gid}` 与 `/cancel`)
- 确认批处理与元数据(`/api/admin/convert/*`)
- 下单闸门与鉴权矩阵(本人 / 归属顾问 / risk_demo / 越权负例)
- 读侧对账(`/api/customers/*` · 下单 → 读侧 → 前端数字三方一致)
- 对话线 SSE 帧序与红线(`/api/chat/stream` 的 `meta.pending_trade`)
与 `scripts/dev/risk_e2e_smoke.py` / `advisor_e2e_smoke.py` 同构(同一骨架、
同一退出码约定),差异有两处**刻意**设计:
1. **两个请求头构造器**:`trade_h`(带必需的 `X-Agent-Type`)与 `platform_h`
(代销平台口径,**不要求**该头)。混用会让一半用例假 401。
2. **一致性断言一律用「前后差值」**:既有演示库存在**快照 ≠ 批次**的历史不一致
(见 CT1-08),绝对相等的断言会立刻假 FAIL。差值口径同时为「同表被多条用例
串行读写」提供免疫。
用法:
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
python scripts/dev/customer_trade_e2e_smoke.py
python scripts/dev/customer_trade_e2e_smoke.py --only CT2 --only CT4
python scripts/dev/customer_trade_e2e_smoke.py --report docs/.../_raw/ct-api.md
退出码:0 全过 / 1 有 FAIL / 2 前置自检失败(含「后端跑的是旧进程」)。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal, InvalidOperation
from pathlib import Path
import httpx
# 直接 `python scripts/dev/customer_trade_e2e_smoke.py` 时 sys.path[0] 是脚本目录,
# 签发令牌 / 查库需要仓库根目录在 path 上。
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
# ── 演示账号(与 app/gateway/jwt_service.py:DEFAULT_ROLES_BY_ACTOR 对齐)──
CUSTOMER = "CUST-9527" # customer 本人 · 风评 C3 · 归属 STAFF-10086
OTHER_CUSTOMER = "CUST-1002" # 非本人客户
ADVISOR = "STAFF-10086" # CUST-9527 的归属 advisor
OTHER_ADVISOR = "STAFF-10087" # 非归属 advisor
RISK = "STAFF-30001" # risk_officer + risk_demo(同一张 token 两个角色)
MANAGER = "STAFF-31001" # risk_manager(无 risk_demo / 无 risk_officer)
# ── 测试标的产品(选取理由见各分组注释;期望值一律由库中事实推导,不写死)──
#: R1 且客户**已持有** —— 用于 CT2 鉴权矩阵(放行不被适当性干扰)。
P_AUTH = "PROD-110022"
#: R2/TA-CN-002 且客户**未持有** —— CT3 干净批次断言。
P_SUB_NEW = "PROD-005828"
#: R3/TA-CN-001 且客户**未持有** —— CT3/CT10 的「无行 → 有行」对账锚点。
P_SUB_R3 = "PROD-510300"
#: R3/TA-CN-001 且客户**已持有 3 档批次** —— CT6 FIFO 跨档赎回。
P_REDEEM = "PROD-005827"
#: 同管理人同 TA(华夏/TA-CN-001)—— 转换合法对。
P_CONV_FROM = "PROD-110022"
P_CONV_TO = "PROD-110023"
#: 易方/TA-CN-002 —— 与转出端跨主体,转换非法对。
P_CONV_CROSS = "PROD-161726"
#: R4(C3×R4 = allowed_with_disclosure)——「需揭示/网点办理」档。
#: 两只都取 **TA-CN-002**,用于申购通道(CT4)交叉印证,排除单品配置差异。
P_R4 = "PROD-161725"
P_R4B = "PROD-510500"
#: R4 且 **TA-CN-001**(与 P_CONV_FROM 同主体)—— 转换通道专用:
#: `_validate_products` 早于 `suitability_check`,跨 TA 会先被 CROSS_ENTITY 拦下。
P_R4_TA1 = "PROD-003095"
#: R5(C3×R5 = forbidden)。
P_R5 = "PROD-XYZ999"
#: 运行中后端**必须具备**的客户交易路径 —— 缺任一即判「后端跑的是旧进程」,
#: 整轮所有用例都会假 FAIL,故前置自检直接以退出码 2 终止。
REQUIRED_PATHS = {
"/api/simulate/trade",
"/api/simulate/trade/convert/{convert_group_id}",
"/api/simulate/trade/convert/{convert_group_id}/cancel",
"/api/admin/convert/confirm",
"/api/admin/convert/meta",
"/api/chat/stream",
"/api/customers/{customer_id}/holdings",
"/api/customers/{customer_id}/trades",
}
#: 单据幂等键前缀(每轮唯一;固定键重跑会命中上一轮的单,语义即变为「非首次受理」)。
RUNSTAMP = datetime.now().strftime("%Y%m%d%H%M%S")
IDEM_PREFIX = f"E2E-CT-{RUNSTAMP}"
PASS, FAIL, SKIP, INFO = "PASS", "FAIL", "SKIP", "INFO"
#: 未确认受理单**不得**出现的折算字段(`convert_admin._CONFIRMED_ONLY_FIELDS`)。
#: `forced_full_transfer` 是唯一例外:受理段即写出(`_query_response` 无条件附)。
CONFIRMED_ONLY_FIELDS = (
"out_trade_id",
"out_nav",
"out_amount",
"lot_count",
"lot_breakdown",
"redeem_fee",
"in_trade_id",
"in_nav",
"convert_amount",
"diff_fee",
"in_amount",
"in_qty",
"rounding_diff",
"out_subscribe_fee_rate",
"in_subscribe_fee_rate",
"nav_date",
"nav_stale",
)
CENT = Decimal("0.01")
@dataclass
class Result:
group: str
name: str
status: str
expected: str
actual: str
evidence: str = ""
@dataclass
class Ctx:
client: httpx.Client
h: dict[str, dict] = field(default_factory=dict) # 角色 → 基础头(无 X-Agent-Type)
state: dict = field(default_factory=dict)
notes: list[str] = field(default_factory=list)
RESULTS: list[Result] = []
# --------------------------------------------------------------------------
# 断言原语
# --------------------------------------------------------------------------
def record(group: str, name: str, status: str, expected: str, actual: str, evidence: str = "") -> None:
RESULTS.append(Result(group, name, status, expected, actual, evidence[:600]))
mark = {PASS: " ok ", FAIL: " FAIL", SKIP: " skip", INFO: " info"}[status]
print(f"[{mark}] {group} {name} | {actual}")
def ok(group: str, name: str, expected: str, actual: str, evidence: str = "") -> None:
record(group, name, PASS, expected, actual, evidence)
def bad(group: str, name: str, expected: str, actual: str, evidence: str = "") -> None:
record(group, name, FAIL, expected, actual, evidence)
def skip(group: str, name: str, reason: str) -> None:
record(group, name, SKIP, "-", reason)
def info(group: str, name: str, actual: str, evidence: str = "") -> None:
"""软探针:记录实测,**不计入 FAIL 分母**(LLM 语义 / 契约未冻结项)。"""
record(group, name, INFO, "-", actual, evidence)
def expect_status(group: str, name: str, resp: httpx.Response, want: int) -> bool:
"""断言 HTTP 状态码;返回是否通过,便于调用方决定后续依赖用例是否 SKIP。"""
if resp.status_code == want:
ok(group, name, f"HTTP {want}", f"HTTP {resp.status_code}")
return True
bad(group, name, f"HTTP {want}", f"HTTP {resp.status_code}", resp.text[:400])
return False
def expect_code(group: str, name: str, resp: httpx.Response, want_status: int, want_code: str) -> bool:
"""断言 HTTP 状态码 **且** 业务 error_code。"""
got_code = err_code(resp)
if resp.status_code == want_status and got_code == want_code:
ok(group, name, f"HTTP {want_status} {want_code}", f"HTTP {resp.status_code} {got_code}")
return True
bad(
group,
name,
f"HTTP {want_status} {want_code}",
f"HTTP {resp.status_code} {got_code}",
resp.text[:400],
)
return False
def expect_true(group: str, name: str, cond: bool, expected: str, actual: str, evidence: str = "") -> bool:
if cond:
ok(group, name, expected, actual, evidence)
else:
bad(group, name, expected, actual, evidence)
return cond
def body(resp: httpx.Response) -> dict:
try:
return resp.json()
except Exception:
return {}
def err_code(resp: httpx.Response) -> str | None:
b = body(resp)
# 平台 ApiError → 顶层 error_code;AdvisorAppError → data.error_code
return b.get("error_code") or (b.get("data") or {}).get("error_code")
def data_of(resp: httpx.Response) -> dict:
"""平台 `/api/customers/*` 等返回 `{code,message,data,trace_id}`;交易端点返回裸 dict。"""
b = body(resp)
d = b.get("data")
return d if isinstance(d, dict) else b
# --------------------------------------------------------------------------
# 查库(只读交叉印证;失败返回空表,不阻塞用例)
# --------------------------------------------------------------------------
def db_query(sql: str, db: str = "core", params: tuple | None = None) -> list[tuple]:
try:
import pymysql
from app.config.settings import settings
name = settings.mysql_core_database if db == "core" else settings.mysql_database
conn = pymysql.connect(
host=settings.mysql_host,
port=int(settings.mysql_port),
user=settings.mysql_user,
password=settings.mysql_password,
database=name,
charset="utf8mb4",
)
try:
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
finally:
conn.close()
except Exception as exc: # noqa: BLE001
print(f" (查库跳过: {exc})")
return []
def db_one(sql: str, db: str = "core", params: tuple | None = None):
rows = db_query(sql, db=db, params=params)
return rows[0][0] if rows and rows[0] else None
def dec(value) -> Decimal:
if value is None:
return Decimal("0")
return Decimal(str(value))
def round2(value: Decimal) -> Decimal:
return value.quantize(CENT, rounding=ROUND_HALF_UP)
def product_round(value: Decimal, digits: int, mode: str) -> Decimal:
"""产品级份额舍入(D27)—— 独立复算,不调用服务层实现(避免自证)。"""
rounding = {"half_up": ROUND_HALF_UP, "truncate": ROUND_DOWN}.get(mode)
if rounding is None:
raise ValueError(f"未知舍入模式 {mode!r}")
return value.quantize(Decimal(1).scaleb(-digits), rounding=rounding)
# --------------------------------------------------------------------------
# 独立预言机(oracle):从库中事实复算期望值,不复用服务层函数
# --------------------------------------------------------------------------
def share_rounding(product_id: str, business_type: str = "subscribe") -> tuple[int, str, str]:
"""复算 `get_share_rounding` 的回退链:规则行 → `core_product` → (2, half_up)。
返回 (digits, mode, 来源说明)。
"""
digits = mode = None
rule = db_query(
"SELECT share_digits, rounding_mode FROM core_share_rule "
"WHERE product_id=%s AND business_type=%s",
params=(product_id, business_type),
)
src = "default"
if rule:
if rule[0][0] is not None:
digits = int(rule[0][0])
if rule[0][1]:
mode = str(rule[0][1]).strip()
src = "core_share_rule"
prod = db_query(
"SELECT share_digits, rounding_mode FROM core_product WHERE product_id=%s",
params=(product_id,),
)
if prod:
if digits is None and prod[0][0] is not None:
digits = int(prod[0][0])
src = "core_product"
if mode is None and prod[0][1]:
mode = str(prod[0][1]).strip()
if digits is None:
digits = 2
if mode is None:
mode = "half_up"
return digits, mode, src
def nav_as_of(product_id: str, as_of: date) -> Decimal | None:
"""T 日(含)前最新一期净值(未知价法)。"""
v = db_one(
"SELECT nav FROM core_product_nav WHERE product_id=%s AND nav_date<=%s "
"ORDER BY nav_date DESC LIMIT 1",
params=(product_id, as_of.isoformat()),
)
return dec(v) if v is not None else None
def prev_biz_day(day: date) -> date | None:
"""前一交易日(`_t2_available_from` 的独立复算)。"""
v = db_one(
"SELECT MAX(cal_date) FROM core_trade_calendar WHERE cal_date<%s AND is_open=1",
params=(day.isoformat(),),
)
if v is None:
return None
return v if isinstance(v, date) else datetime.strptime(str(v), "%Y-%m-%d").date()
def fee_rate_for(product_id: str, hold_days: int) -> Decimal:
"""赎回费率档(左闭右开;多档命中取 min_hold_days 最大者)。"""
rows = db_query(
"SELECT min_hold_days, max_hold_days, rate FROM core_fee_rule "
"WHERE product_id=%s AND fee_type='redeem'",
params=(product_id,),
)
best = None
for mn, mx, rate in rows:
mn = int(mn)
if hold_days < mn:
continue
if mx is not None and hold_days >= int(mx):
continue
if best is None or mn > best[0]:
best = (mn, dec(rate))
return best[1] if best else Decimal("0")
def t2_lots(customer_id: str, product_id: str, trade_day: date) -> list[dict]:
"""T+2 可扣批次(`confirmed_at < 业务日前一交易日 + 1 天`,按 FIFO 排序)。"""
prev = prev_biz_day(trade_day)
if prev is None:
sql = (
"SELECT lot_id, remain_qty, nav, confirmed_at FROM core_share_lot "
"WHERE customer_id=%s AND product_id=%s AND remain_qty>0 "
"ORDER BY confirmed_at, lot_id"
)
rows = db_query(sql, params=(customer_id, product_id))
else:
cutoff = datetime.combine(prev + timedelta(days=1), datetime.min.time())
sql = (
"SELECT lot_id, remain_qty, nav, confirmed_at FROM core_share_lot "
"WHERE customer_id=%s AND product_id=%s AND remain_qty>0 AND confirmed_at<%s "
"ORDER BY confirmed_at, lot_id"
)
rows = db_query(sql, params=(customer_id, product_id, cutoff))
return [
{"lot_id": r[0], "remain_qty": dec(r[1]), "nav": dec(r[2]), "confirmed_at": r[3]}
for r in rows
]
def plan_lots(lots: list[dict], qty: Decimal) -> list[tuple[dict, Decimal]]:
"""FIFO 选批(独立实现):返回 [(lot, 本批分配份额)]。"""
allocs: list[tuple[dict, Decimal]] = []
left = qty
for lot in lots:
if left <= 0:
break
take = min(left, lot["remain_qty"])
if take > 0:
allocs.append((lot, take))
left -= take
return allocs
def derive_redeem_qty(lots: list[dict], total_avail: Decimal) -> Decimal | None:
"""从 T+2 可扣批次推导赎回申报量:优先跨 FIFO 首档边界,且不超过可用合计。"""
if total_avail <= 0 or not lots:
return None
if len(lots) >= 2:
first = lots[0]["remain_qty"]
second = lots[1]["remain_qty"]
if first > 0 and second > 0 and total_avail > first:
bite = min(max(Decimal("1"), round2(second * Decimal("0.01"))), second)
cross = min(first + bite, total_avail)
if cross > first and len(plan_lots(lots, cross)) >= 2:
return round2(cross)
partial = round2(total_avail * Decimal("0.3"))
qty = min(max(Decimal("1"), partial), total_avail)
return round2(qty) if qty > 0 else None
def oracle_redeem_amount(
customer_id: str, product_id: str, qty: Decimal, trade_day: date
) -> tuple[Decimal, list[dict]]:
"""独立复算赎回金额:逐批 `round2(qty×nav) − round2(金额×费率)` 后求和。
与 `_redeem_quote` 同口径(含 D26「按 T 日净值而非批次自身净值」)。
"""
nav = nav_as_of(product_id, trade_day)
if nav is None:
raise ValueError("无可用净值")
lots = t2_lots(customer_id, product_id, trade_day)
plan = plan_lots(lots, qty)
gross = Decimal("0")
fee = Decimal("0")
detail = []
for lot, take in plan:
days = (trade_day - _as_date(lot["confirmed_at"])).days
rate = fee_rate_for(product_id, days)
amt = round2(take * nav)
f = round2(amt * rate)
gross += amt
fee += f
detail.append(
{
"lot_id": lot["lot_id"],
"take": str(take),
"hold_days": days,
"rate": str(rate),
"amount": str(amt),
"fee": str(f),
}
)
return round2(gross - fee), detail
def _as_date(value) -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return datetime.strptime(str(value)[:10], "%Y-%m-%d").date()
# --------------------------------------------------------------------------
# 鉴权与请求头
# --------------------------------------------------------------------------
def login(ctx: Ctx, actor_id: str, token_type: str = "staff", roles: list[str] | None = None) -> dict:
"""走真实登录接口,返回**不含** X-Agent-Type 的基础头。"""
payload: dict = {"actor_id": actor_id, "token_type": token_type}
if roles is not None:
payload["roles"] = roles
r = ctx.client.post("/api/auth/login", json=payload)
r.raise_for_status()
b = body(r)
token = b.get("access_token") or (b.get("data") or {}).get("access_token")
if not token:
raise RuntimeError(f"登录响应无 access_token: {r.text[:200]}")
return {"Authorization": f"Bearer {token}"}
def _jwt_claims(headers: dict) -> dict:
"""解出 JWT 载荷(**不验签**,仅用于取证据:看服务端究竟签发了什么角色)。"""
import base64
token = (headers.get("Authorization") or "").removeprefix("Bearer ").strip()
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
except Exception: # noqa: BLE001
return {}
def trade_h(base: dict, kind: str) -> dict:
"""交易/对话/管理线请求头:Bearer + **必需的** X-Agent-Type。"""
return {**base, "X-Agent-Type": kind}
def platform_h(base: dict) -> dict:
"""代销平台线请求头:**不要求** X-Agent-Type(`get_platform_auth_context`)。"""
return dict(base)
def sse_frames(
ctx: Ctx, path: str, payload: dict, headers: dict, timeout: float = 120.0
) -> tuple[int, list[tuple[str, str]]]:
"""逐帧捕获 SSE。返回 (status, [(kind, raw)]),kind ∈ data/comment/event/error。
**帧序**是本函数的存在理由:`meta.pending_trade` 必须出现在文本 delta
**之前**,只断言「出现过」会漏掉「弹窗晚于文本」的时序缺陷。
"""
frames: list[tuple[str, str]] = []
status = 0
try:
with ctx.client.stream("POST", path, json=payload, headers=headers, timeout=timeout) as r:
status = r.status_code
if status != 200:
r.read()
frames.append(("body", r.text[:400]))
return status, frames
for line in r.iter_lines():
if line.startswith("data:"):
frames.append(("data", line[5:].strip()))
elif line.startswith(":"):
frames.append(("comment", line.strip()))
elif line.strip():
frames.append(("event", line.strip()))
except Exception as exc: # noqa: BLE001
frames.append(("error", str(exc)))
return status, frames
def frame_json(raw: str) -> dict:
try:
v = json.loads(raw)
return v if isinstance(v, dict) else {}
except Exception:
return {}
def pending_trade_frame(frames: list[tuple[str, str]]) -> tuple[int, dict] | None:
"""首个携带 `meta.pending_trade` 的帧 → (下标, 帧体)。"""
for i, (kind, raw) in enumerate(frames):
if kind != "data":
continue
j = frame_json(raw)
pt = (j.get("meta") or {}).get("pending_trade")
if pt:
return i, j
return None
def first_text_frame(frames: list[tuple[str, str]]) -> int | None:
"""首个**非空**文本 delta 帧的下标。"""
for i, (kind, raw) in enumerate(frames):
if kind != "data":
continue
delta = (frame_json(raw).get("choices") or [{}])[0].get("delta") or {}
if delta.get("content"):
return i
return None
# --------------------------------------------------------------------------
# 前置自检
# --------------------------------------------------------------------------
def preflight(ctx: Ctx, base_url: str) -> bool:
print(f"== 客户线交易端到端冒烟 @ {base_url} ==")
print(f" 幂等键前缀 {IDEM_PREFIX} · 交易日 {date.today().isoformat()}\n")
failures: list[str] = []
# `uvicorn --reload` 未指定 --reload-dir 时监视**仓库根目录**,故编辑本脚本
# 自身就会触发后端重启 → /health 会有几秒不可达。这里做**有界重试并逐次打印**,
# 让「重启中」与「真的连不上」在日志里可区分(静默重试才是掩盖问题)。
health_ok = False
for attempt in range(1, 7):
try:
r = ctx.client.get("/health", timeout=15)
if r.status_code == 200 and body(r).get("status") == "ok":
if attempt > 1:
print(f" [ok] /health → ok(第 {attempt} 次尝试;前几次为 --reload 重启窗口)")
else:
print(f" [ok] /health → {body(r).get('status')} (env={body(r).get('env')})")
health_ok = True
break
print(f" [..] /health 第 {attempt} 次: HTTP {r.status_code}")
except Exception as exc: # noqa: BLE001
print(f" [..] /health 第 {attempt} 次不可达: {type(exc).__name__}")
if attempt < 6:
time.sleep(3)
if not health_ok:
failures.append("/health 连续 6 次不可达(约 18s)—— 后端未运行或持续崩溃")
# 关键:确认后端进程跑的是**当前**代码。旧进程缺交易/转换路由,
# 会让整轮用例假 FAIL —— 与 risk_e2e_smoke.py 同一道防线。
try:
spec = ctx.client.get("/openapi.json", timeout=20).json()
paths = set(spec.get("paths", {}))
missing = REQUIRED_PATHS - paths
if missing:
failures.append(
f"后端缺客户交易路由(旧进程?需重启): {sorted(missing)};"
f"当前共 {len(paths)} 条路径"
)
else:
print(f" [ok] openapi 含全部 {len(REQUIRED_PATHS)} 条必需路径(共 {len(paths)} 条)")
ctx.state["openapi_path_count"] = len(paths)
except Exception as exc: # noqa: BLE001
failures.append(f"/openapi.json 不可读: {exc}")
if failures:
print("\n前置自检失败:")
for f in failures:
print(f" - {f}")
return False
try:
ctx.h["customer"] = login(ctx, CUSTOMER, "customer")
ctx.h["other_customer"] = login(ctx, OTHER_CUSTOMER, "customer")
ctx.h["advisor"] = login(ctx, ADVISOR, "staff")
ctx.h["other_advisor"] = login(ctx, OTHER_ADVISOR, "staff")
ctx.h["risk"] = login(ctx, RISK, "staff")
ctx.h["manager"] = login(ctx, MANAGER, "staff")
print(" [ok] 演示账号令牌 6/6")
except Exception as exc: # noqa: BLE001
print(f"\n前置自检失败:登录失败 {exc}")
return False
# 种子事实加载:后续期望值**全部**由这些事实推导(净值 / 批次 / 费率 / 日历)。
load_seed(ctx)
if ctx.state.get("nav_date"):
print(f" [ok] 净值库最新 nav_date={ctx.state['nav_date']}"
f" · 标的产品净值 {len(ctx.state.get('navs') or {})} 只")
return True
def load_seed(ctx: Ctx) -> None:
"""把库中事实载入 `ctx.state`,供**任意分组**独立推导期望值。
放在 preflight 而非 CT1 内,是为了 `--only CT6` 这类单组复跑也能拿到种子 ——
否则单组运行会 KeyError(首版即踩此坑)。所有期望值必须由这些事实推导,
**不得写死数字**,种子补到新日期时脚本不腐化。
"""
ctx.state["today"] = date.today()
ctx.state["nav_date"] = db_one("SELECT MAX(nav_date) FROM core_product_nav")
prod_rows = db_query(
"SELECT product_id, min_risk_code, requires_disclosure, ta_code, fund_company, "
"can_subscribe, can_redeem, share_class FROM core_product"
)
ctx.state["products"] = {
r[0]: {
"risk": r[1], "disclosure": int(r[2] or 0), "ta": r[3], "company": r[4],
"can_sub": int(r[5] or 0), "can_redeem": int(r[6] or 0), "share_class": r[7],
}
for r in prod_rows
}
navs: dict[str, Decimal] = {}
for pid in {P_AUTH, P_SUB_NEW, P_SUB_R3, P_REDEEM, P_CONV_FROM, P_CONV_TO,
P_CONV_CROSS, P_R4, P_R4B, P_R4_TA1, P_R5}:
if (nav := nav_as_of(pid, ctx.state["today"])) is not None:
navs[pid] = nav
ctx.state["navs"] = navs
# --------------------------------------------------------------------------
# CT1 前置自检与契约冻结
# --------------------------------------------------------------------------
def ct1(ctx: Ctx) -> None:
G = "CT1"
r = ctx.client.get("/api/ready", timeout=20)
d = body(r)
expect_true(G, "ready → ok=true", r.status_code == 200 and d.get("ok") is True,
"HTTP 200 ok=true", f"HTTP {r.status_code} ok={d.get('ok')} degraded={d.get('degraded')}",
json.dumps(d.get("checks"), ensure_ascii=False))
# Redis 是「序号续槽 / 在途锁」的依赖,但**不据此判前置失败**(与既有脚本同口径)
redis_ok = bool((d.get("checks") or {}).get("redis"))
record(G, "checks.redis 记录", INFO if not redis_ok else PASS, "-",
f"redis={redis_ok}", "Redis 不可达时序号续槽走 restore_flow_from_context 兜底")
info(G, "openapi 路径总数", f"{ctx.state.get('openapi_path_count')} 条",
"基线 66 条;数量变化说明路由有增删,需人工确认")
# ── 种子事实(已由 preflight 的 load_seed 写进 state;此处只做断言)──
products = ctx.state["products"]
expect_true(G, "种子:产品目录已加载(preflight 载入)", len(products) >= 14,
"≥14 只产品", f"{len(products)} 只")
test_products = sorted({P_AUTH, P_SUB_NEW, P_SUB_R3, P_REDEEM, P_CONV_FROM, P_CONV_TO,
P_CONV_CROSS, P_R4, P_R4B, P_R4_TA1, P_R5})
navs = ctx.state.get("navs") or {}
for pid in test_products:
if pid not in products:
bad(G, f"种子:{pid} 存在", "在产品目录中", "缺失")
elif pid not in navs:
bad(G, f"种子:{pid} 有可用净值", "nav != None", "无净值(相关用例将 SKIP)")
ok(G, f"种子:{len(test_products)} 只标的产品 + 净值就绪", "全部就绪",
f"{len(navs)}/{len(test_products)} 只取到净值")
# 转换合法对 / 非法对的前置事实(不满足则 CT7 会假 400)
okc = products.get(P_CONV_FROM, {})
inc = products.get(P_CONV_TO, {})
expect_true(G, "种子:转换合法对同管理人同 TA",
okc.get("company") == inc.get("company") and okc.get("ta") == inc.get("ta"),
f"{P_CONV_FROM} 与 {P_CONV_TO} 同 company+ta",
f"company {okc.get('company')}/{inc.get('company')} · ta {okc.get('ta')}/{inc.get('ta')}")
xc = products.get(P_CONV_CROSS, {})
expect_true(G, "种子:转换非法对跨主体",
okc.get("company") != xc.get("company") or okc.get("ta") != xc.get("ta"),
f"{P_CONV_FROM} 与 {P_CONV_CROSS} 跨 company 或 ta",
f"company {okc.get('company')}/{xc.get('company')} · ta {okc.get('ta')}/{xc.get('ta')}")
# ── 字段名陷阱(T-9 变更后的字段面)──
r = ctx.client.post(
"/api/simulate/trade", headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "subscribe", "product_id": P_AUTH,
"amount": "1000", "client_request_id": f"{IDEM_PREFIX}-ct1-probe"},
)
b = body(r)
expect_true(G, "字段面:放行体含 alert_ids(复数)", "alert_ids" in b, "alert_ids 存在",
f"keys={sorted(b)[:12]}")
expect_true(G, "字段面:放行体**不含** alert_id(单数)", "alert_id" not in b,
"无 alert_id", f"present={'alert_id' in b}")
ctx.state["ct1_probe_trade_id"] = b.get("trade_id")
if b.get("trade_id"):
info(G, "CT1 契约探针已落一笔申购(供字段面断言)", b["trade_id"],
"该笔写侧副作用由 CT12 变更清单纳入统计")
# ── 基线不一致记录(**非本轮引入**,必须先记录以免与回归混为一谈)──
rows = db_query(
"SELECT h.customer_id, h.product_id, h.qty, COALESCE(l.s,0) FROM core_holding h "
"LEFT JOIN (SELECT customer_id, product_id, SUM(remain_qty) s FROM core_share_lot "
" GROUP BY customer_id, product_id) l "
" ON l.customer_id=h.customer_id AND l.product_id=h.product_id "
"WHERE h.customer_id=%s ORDER BY h.product_id",
params=(CUSTOMER,),
)
drift = [
f"{r[1]}: 快照 {dec(r[2])} vs 批次和 {dec(r[3])} (差 {dec(r[2]) - dec(r[3])})"
for r in rows
if dec(r[2]) != dec(r[3])
]
if drift:
info(G, "基线:快照与批次不一致(**本轮之前既有**)",
f"{len(drift)}/{len(rows)} 只产品不一致",
";".join(drift) + " —— 故本轮所有一致性断言一律用「前后差值」,不用绝对值")
ctx.state["baseline_drift"] = drift
else:
ok(G, "基线:快照与批次一致", "无漂移", f"{len(rows)} 只产品全部一致")
# --------------------------------------------------------------------------
# CT2 鉴权矩阵 · 下单闸门
# --------------------------------------------------------------------------
def ct2(ctx: Ctx) -> None:
G = "CT2"
path = "/api/simulate/trade"
def payload(cid=CUSTOMER, crid=None):
p = {"customer_id": cid, "trade_type": "subscribe", "product_id": P_AUTH, "amount": "1000"}
if crid:
p["client_request_id"] = crid
return p
before = db_one("SELECT COUNT(*) FROM core_trade")
expected_writes = 0 # 逐条累加「本组预期写库的笔数」,含越权探针**若**成功写入的笔
# 2-01 客户本人
r = ctx.client.post(path, headers=trade_h(ctx.h["customer"], "customer"), json=payload(crid=f"{IDEM_PREFIX}-ct2-01"))
b = body(r)
if expect_true(G, "2-01 客户本人下单 → 200 放行",
r.status_code == 200 and b.get("blocked") is False and b.get("trade_id"),
"HTTP 200 blocked=false + trade_id",
f"HTTP {r.status_code} blocked={b.get('blocked')} trade_id={b.get('trade_id')}"):
ctx.state["ct2_trade_id"] = b["trade_id"]
expected_writes += 1
trace = r.headers.get("X-Trace-Id") or b.get("trace_id")
ctx.state["ct2_trace_id"] = trace
# 2-02 非本人客户
r = ctx.client.post(path, headers=trade_h(ctx.h["other_customer"], "customer"), json=payload())
expect_code(G, "2-02 非本人客户下单 → 403 AUTH_403_ROLE", r, 403, "AUTH_403_ROLE")
# 2-03 归属顾问
r = ctx.client.post(path, headers=trade_h(ctx.h["advisor"], "advisor"), json=payload(crid=f"{IDEM_PREFIX}-ct2-03"))
if expect_true(G, "2-03 归属顾问代客下单 → 200",
r.status_code == 200, "HTTP 200", f"HTTP {r.status_code}", r.text[:200]):
expected_writes += 1
# 2-04 非归属顾问
r = ctx.client.post(path, headers=trade_h(ctx.h["other_advisor"], "advisor"), json=payload())
expect_code(G, "2-04 非归属顾问下单 → 403 AUTH_403_ROLE", r, 403, "AUTH_403_ROLE")
# 2-05 risk_demo(与 risk_officer 同 token)
r = ctx.client.post(path, headers=trade_h(ctx.h["risk"], "risk"), json=payload(crid=f"{IDEM_PREFIX}-ct2-05"))
if expect_true(G, "2-05 risk_demo 下单 → 200", r.status_code == 200, "HTTP 200",
f"HTTP {r.status_code}", r.text[:200]):
expected_writes += 1
# 2-06 无 token(dev 无 Bearer → debug 头兜底通道报缺头)
r = ctx.client.post(path, json=payload())
got = err_code(r)
expect_true(G, "2-06 无 token → 401",
r.status_code == 401 and got in ("AUTH_401_MISSING_DEBUG_HEADERS", "AUTH_401_MISSING_BEARER"),
"HTTP 401 (缺 Bearer/调试头)", f"HTTP {r.status_code} {got}")
# 2-07 有 token 但缺 X-Agent-Type(本轮新增契约,单列冻结)
r = ctx.client.post(path, headers=platform_h(ctx.h["customer"]), json=payload())
expect_code(G, "2-07 有 token 缺 X-Agent-Type → 401 AUTH_401_MISSING_AGENT_TYPE",
r, 401, "AUTH_401_MISSING_AGENT_TYPE")
# 2-08 越权探针:`/api/auth/login` 对请求体 roles **原样采信**
# 对照组(不注入)先跑,才有「注入导致状态码翻转」的因果证据 ——
# 只报注入后的 200 无法排除「该端点本就对所有登录者开放」。
root_cause = (
"根因:app/gateway/jwt_service.py:118-120 `infer_roles` 首行即 `if roles: return roles`,"
"对调用方传入的角色**无任何校验**;app/api/auth.py:16-44 `/api/auth/login` 无 app_env 门禁,"
"且 main.py:103 无条件挂载该路由,而 jwt_ready() 的守卫只在 main.lifespan 检查「能否启动」,"
"不覆盖「启动之后」。生产若配 RS256,本路由签出的 HS256 token 会在验签处被拒("
"auth_service.py:133 `algorithms=['RS256']`);**development 下则是真实的提权通道**。"
)
def probe(label, actor, token_type, roles, headers_kind, request):
"""对照组 + 实验组并排跑:返回 (control_status, escaped_status)。"""
try:
ctl = login(ctx, actor, token_type) if roles else None
ctl_status = request(trade_h(ctl, headers_kind)).status_code if ctl else "n/a"
except Exception: # noqa: BLE001
ctl_status = "登录失败"
try:
hdr = login(ctx, actor, token_type, roles=roles)
resp = request(trade_h(hdr, headers_kind))
esc_status = resp.status_code
claims = _jwt_claims(hdr)
except Exception as exc: # noqa: BLE001
info(G, f"2-08 {label}", f"登录失败 {exc}")
return ctl_status, None, None
if esc_status == 200 and ctl_status != 200:
bad(G, f"2-08 {label}",
"注入角色应被拒绝(403/401)",
f"对照组 HTTP {ctl_status} → 实验组 HTTP {esc_status}(**状态码被注入翻转**)",
f"actor={actor} 注入 roles={roles},JWT 实签发 roles={claims.get('roles')};{root_cause}")
elif esc_status != 200:
ok(G, f"2-08 {label}", "注入后仍被拒绝",
f"对照组 HTTP {ctl_status} · 实验组 HTTP {esc_status} {err_code(resp)}")
else:
info(G, f"2-08 {label}", f"对照组即 200(本端点对 {actor} 本就开放)",
"非越权,属正常授权")
return ctl_status, esc_status, resp
# 探针 A:customer token 注入 risk_officer → token_type 不在 risk 通道白名单,
# 预期被 AGENT_MISMATCH 拦下(这是矩阵生效的一面)
probe("A customer token 注入 risk_officer 调 /api/risk/alerts",
CUSTOMER, "customer", ["customer", "risk_officer"], "risk",
lambda h: ctx.client.get("/api/risk/alerts", headers=h))
# 探针 B:**staff token** 注入 risk_officer → token_type 在白名单内,越权面成立
probe("B advisor(STAFF-10087) 注入 risk_officer 调 /api/risk/alerts",
OTHER_ADVISOR, "staff", ["risk_officer"], "risk",
lambda h: ctx.client.get("/api/risk/alerts", headers=h))
# 探针 C:**白名单外的 actor** 自造 advisor+risk_demo → 可代任意客户下单(资金动作)
ctl, esc, resp = probe(
f"C 白名单外 actor(STAFF-99999) 自造 advisor+risk_demo 代 {OTHER_CUSTOMER} 下单",
"STAFF-99999", "staff", ["advisor", "risk_demo"], "advisor",
lambda h: ctx.client.post(path, headers=h,
json=payload(cid=OTHER_CUSTOMER, crid=f"{IDEM_PREFIX}-ct2-08c")),
)
if esc == 200:
expected_writes += 1 # 越权单**确实写进了 core_trade**,2-10 的差额据此解释
# 同一张 token 是否连「下单给任意客户」都放行,取决于 simulate 的角色闸门
info(G, "2-08C 越权单落库归属", f"trade_id={body(resp).get('trade_id')} 归属 {OTHER_CUSTOMER}",
"STAFF-99999 不在 DEFAULT_ROLES_BY_ACTOR 任何白名单内 → 说明「actor 是否存在」也不校验")
# 2-09 拒绝动作的审计留痕(utils/authz.record_authz_denial 出口)
n_authz = db_one(
"SELECT COUNT(*) FROM audit_log WHERE event_type='authz' "
"AND created_at >= DATE_SUB(NOW(), INTERVAL 20 MINUTE)", db="agent",
)
expect_true(G, "2-09 越权尝试产生 authz 审计留痕(近 20 分钟)",
(n_authz or 0) >= 3, "≥3 条(2-02/2-04/2-07/8-x 等拒绝点)",
f"{n_authz} 条",
"落点 app/utils/authz.py:record_authz_denial → audit_log(event_type='authz') "
"+ input_guard_log(guard_type='illegal_param', action='blocked')")
# 逐条口径核对:input_summary.code 是否记录了具体拒绝码
rows = db_query(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(input_summary,'$.code')), COUNT(*) FROM audit_log "
"WHERE event_type='authz' AND created_at >= DATE_SUB(NOW(), INTERVAL 20 MINUTE) "
"GROUP BY 1 ORDER BY 2 DESC", db="agent",
)
info(G, "2-09b 拒绝码分布", ";".join(f"{r[0]}×{r[1]}" for r in rows) or "无",
"用于核对「拒绝原因可追溯到具体闸门」")
if ctx.state.get("ct2_trace_id"):
n_t = db_one(
"SELECT COUNT(*) FROM audit_log WHERE trace_id=%s",
db="agent", params=(ctx.state["ct2_trace_id"],),
)
info(G, "2-09c 放行请求的 trace_id 是否也留痕", f"{n_t} 行",
f"trace_id={ctx.state['ct2_trace_id']} —— 放行路径是否留痕决定「全量可追溯」口径")
else:
info(G, "2-09c 放行请求留痕", "跳过(未取到 trace_id)")
# 2-10 放行路径确已写库(对照 2-02/2-04/2-07 的零增量)
after = db_one("SELECT COUNT(*) FROM core_trade")
delta = (after or 0) - (before or 0)
expect_true(G, "2-10 放行笔数 = core_trade 增量(拒绝路径零写入)",
delta == expected_writes,
f"COUNT 增量 = {expected_writes}(放行 2-01/03/05"
f"{' + 越权探针 2-08C' if expected_writes > 3 else ''})",
f"before={before} after={after} Δ={delta}",
"拒绝类用例(2-02/2-04/2-06/2-07)的贡献为 0 —— 若 Δ > 放行笔数,"
"说明有拒绝请求逃逸到了写库路径")
# --------------------------------------------------------------------------
# CT3 申购放行 + 写侧对账
# --------------------------------------------------------------------------
def _snapshot_product(customer_id: str, product_id: str) -> dict:
"""按业务键读该客户该产品的三方状态(快照 / 批次和 / 有效批次明细)。"""
h = db_query(
"SELECT qty, cost_amount FROM core_holding WHERE customer_id=%s AND product_id=%s",
params=(customer_id, product_id),
)
lots = db_query(
"SELECT lot_id, qty, remain_qty, nav, confirmed_at FROM core_share_lot "
"WHERE customer_id=%s AND product_id=%s ORDER BY confirmed_at, lot_id",
params=(customer_id, product_id),
)
return {
"holding_qty": dec(h[0][0]) if h else None,
"holding_cost": dec(h[0][1]) if h else None,
"lot_sum": sum((dec(r[2]) for r in lots), Decimal("0")),
"lots": [{"lot_id": r[0], "qty": dec(r[1]), "remain": dec(r[2]),
"nav": dec(r[3]), "at": r[4]} for r in lots],
}
def ct3(ctx: Ctx) -> None:
G = "CT3"
path = "/api/simulate/trade"
today = ctx.state["today"]
products = ctx.state["products"]
cases = [
("3-01", P_AUTH, Decimal("10000"), "R1 已持有品(批次追加)"),
("3-02", P_SUB_NEW, Decimal("5000"), "R2 未持有品(批次从 0 起)"),
("3-03", P_SUB_R3, Decimal("20000"), "R3 未持有品(CT10 对账锚点)"),
]
for cid, pid, amount, label in cases:
nav = nav_as_of(pid, today)
if nav is None:
skip(G, f"{cid} 申购 {pid} {label}", "无可用净值")
continue
digits, mode, src = share_rounding(pid, "subscribe")
exp_qty = product_round(amount / nav, digits, mode)
before = _snapshot_product(CUSTOMER, pid)
h_before = _holdings_map(ctx)
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "subscribe", "product_id": pid,
"amount": str(amount), "client_request_id": f"{IDEM_PREFIX}-{cid}"},
)
b = body(r)
if not expect_true(G, f"{cid} 申购 {label} → 200 放行",
r.status_code == 200 and b.get("blocked") is False,
"HTTP 200 blocked=false",
f"HTTP {r.status_code} blocked={b.get('blocked')} trade_id={b.get('trade_id')}",
r.text[:300]):
continue
after = _snapshot_product(CUSTOMER, pid)
h_after = _holdings_map(ctx)
# ── R1 假 PASS 防线:四条链**同时**断言,缺一即 FAIL ──
trade_id = b.get("trade_id")
n_trade = db_one("SELECT COUNT(*) FROM core_trade WHERE trade_id=%s", params=(trade_id,))
expect_true(G, f"{cid} 链① core_trade 新行",
(n_trade or 0) == 1, "该 trade_id 存在", f"rows={n_trade}")
exp_new_lot = exp_qty
got_new_lot = after["lot_sum"] - before["lot_sum"]
expect_true(G, f"{cid} 链② core_share_lot 批次变动 = 折算份额",
got_new_lot == exp_new_lot,
f"Δ批次和 = {exp_new_lot}({amount}/{nav} 按 {mode}/{digits} 位 · 源 {src})",
f"Δ批次和 = {got_new_lot}",
f"新增批次={[l for l in after['lots'] if l['lot_id'] not in {x['lot_id'] for x in before['lots']}]}")
exp_h = (before["holding_qty"] or Decimal("0")) + exp_new_lot
expect_true(G, f"{cid} 链③ core_holding 快照变动 = 折算份额",
after["holding_qty"] == exp_h,
f"快照 = {exp_h}", f"快照 = {after['holding_qty']}")
key = pid
before_api = h_before.get(key)
after_api = h_after.get(key)
exp_api = (dec(before_api.get("qty")) if before_api else Decimal("0")) + exp_new_lot
expect_true(G, f"{cid} 链④ GET /holdings 返回值变动",
after_api is not None and dec(after_api.get("qty")) == exp_api,
f"读侧 qty = {exp_api}",
f"读侧 qty = {after_api.get('qty') if after_api else '无该行'}")
# 净值取值口径:未知价法取 ≤ 当日的最近值
nav_used = db_one(
"SELECT nav_date FROM core_product_nav WHERE product_id=%s AND nav_date<=%s "
"ORDER BY nav_date DESC LIMIT 1", params=(pid, today.isoformat()),
)
info(G, f"{cid} 净值取值口径", f"nav={nav} @ {nav_used}",
f"未知价法:取 ≤ {today} 的最近可得净值(09-13 为周日,最新为 09-11)")
# 3-04 舍入优先级(D27):规则行 **优先于** core_product 列
pid = P_SUB_NEW
nav = nav_as_of(pid, today)
if nav is not None:
digits, mode, src = share_rounding(pid, "subscribe")
prod_row = db_query(
"SELECT rounding_mode, share_digits FROM core_product WHERE product_id=%s", params=(pid,)
)
pmode = str(prod_row[0][0]) if prod_row and prod_row[0][0] else "?"
# 选一个 half_up 与 truncate 结果**不同**的金额,让本用例具备判别力
amount = _discriminating_amount(pid, nav, digits)
exp_mode = product_round(amount / nav, digits, mode)
alt_mode = product_round(amount / nav, digits, "half_up" if mode == "truncate" else "truncate")
before = _snapshot_product(CUSTOMER, pid)
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "subscribe", "product_id": pid,
"amount": str(amount), "client_request_id": f"{IDEM_PREFIX}-ct3-04"},
)
after = _snapshot_product(CUSTOMER, pid)
got = after["lot_sum"] - before["lot_sum"]
expect_true(G, "3-04 舍入按 core_share_rule 优先(覆盖 core_product 列)",
got == exp_mode and exp_mode != alt_mode,
f"份额 = {exp_mode}({src} → {mode}/{digits} 位;"
f"若按 core_product 的 {pmode} 则为 {alt_mode})",
f"份额 = {got}(金额 {amount} / 净值 {nav})",
f"判别力:两模式结果 {'不同 ✓' if exp_mode != alt_mode else '相同 ✗(本金额无法判别)'}")
# 3-05 放行后适当性日志**不得**出现阻断痕迹
n = db_one(
"SELECT COUNT(*) FROM audit_log WHERE decision='suitability_blocked' AND actor_id=%s "
"AND created_at >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)",
db="agent", params=(CUSTOMER,),
)
expect_true(G, "3-05 放行路径无 suitability_blocked 留痕",
(n or 0) == 0, "0 条阻断留痕", f"{n} 条(本组均为放行请求)")
# 3-06 小数金额边界探针(契约未冻结 → 软探针)
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "subscribe", "product_id": P_AUTH,
"amount": "1000.555", "client_request_id": f"{IDEM_PREFIX}-ct3-06"},
)
info(G, "3-06 amount 三位小数边界探针(不预设)",
f"HTTP {r.status_code} {err_code(r) or ''}",
f"body={r.text[:200]} —— 契约未规定小数位上限,记录实测")
def _discriminating_amount(pid: str, nav: Decimal, digits: int) -> Decimal:
"""找一个能判别 half_up / truncate 的金额((amount/nav) 的第 digits+1 位 ≥ 5)。"""
for candidate in (Decimal("1000"), Decimal("1001"), Decimal("1002"), Decimal("1003"),
Decimal("2000"), Decimal("3000"), Decimal("12345")):
q = candidate / nav
floor = q.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_DOWN)
if q - floor >= Decimal(1).scaleb(-digits) / 2:
return candidate
return Decimal("1000")
def _holdings_map(ctx: Ctx) -> dict:
"""GET /holdings → {product_id: row}(读侧口径,供四方对账)。"""
r = ctx.client.get(
f"/api/customers/{CUSTOMER}/holdings", headers=platform_h(ctx.h["customer"])
)
d = data_of(r)
return {row.get("product_id"): row for row in (d.get("items") or [])}
# --------------------------------------------------------------------------
# CT4 适当性阻断(网关唯一阻断点 trade_gateway.submit_trade)
# --------------------------------------------------------------------------
def ct4(ctx: Ctx) -> None:
G = "CT4"
path = "/api/simulate/trade"
products = ctx.state["products"]
def submit(pid, amount="10000", cid=CUSTOMER, headers=None):
return ctx.client.post(
path,
headers=headers or trade_h(ctx.h["customer"], "customer"),
json={"customer_id": cid, "trade_type": "subscribe", "product_id": pid,
"amount": amount, "client_request_id": f"{IDEM_PREFIX}-ct4-{pid}-{cid}"},
)
before = db_one("SELECT COUNT(*) FROM core_trade")
# 4-01 R4(requires_disclosure=1)→ 「需揭示」必须在**交易通道**生效(阻断)。
#
# 【修复后状态 · 2026-09-13 收口】本组初轮曾 FAIL(P1),根因与修法留档如下:
# · 初轮事实:适当性层 `check_suitability` 对 R4 返回 match_result="allowed_with_disclosure"、
# requires_disclosure=True、block_response_code="SUIT_NEED_DISCLOSURE"、blocked=**False**
# (core_ro.py:176-182);而 `trade_gateway.submit_trade` 只在 `if result.blocked:` 分支
# 回传这些字段(trade_gateway.py:534 起),blocked=False 时**整体丢弃** →
# 响应里连 requires_disclosure 都不存在,纯 API 客户端无从得知需揭示。
# · 当时另三处执行点**都**已把 R4 当作「非自助」:
# - 前端 web/src/utils/tradeEligibility.ts:29-31 → canSelfServe=false、tag=需揭示/网点办理
# - 对话线 app/service/trade_action_service.py:88-91 `subscribe_self_service_allowed`
# - 产品清单 API 正确回传 match_result=allowed_with_disclosure(见 CT10-06c)
# → 交易通道是**唯一**漏判的执行点(单点实现缺失,非设计分歧)。
# · **修法**:新增 `suitability.simulate_self_service_blocked(result)`
# (allowed_with_disclosure / requires_disclosure / needs_branch_confirm 一律视同阻断),
# `trade_gateway` 与 `convert_service` 转入端同口径收敛,与前端 tradeEligibility 对齐。
# · 故本组断言**保持不变**(期望「需揭示须体现」),修复后自然转绿 —— 是回归验证,非改写期望。
r = submit(P_R4)
b = body(r)
info(G, "4-01a R4 实测响应体(原样)", f"HTTP {r.status_code} keys={sorted(b)}",
json.dumps(b, ensure_ascii=False)[:400])
expect_true(G, "4-01 R4 需揭示产品不应被自助通道静默放行",
b.get("requires_disclosure") or b.get("blocked") or
b.get("match_result") == "allowed_with_disclosure",
"响应须体现需揭示(requires_disclosure=true 或 blocked=true)",
f"HTTP {r.status_code} blocked={b.get('blocked')} "
f"requires_disclosure={b.get('requires_disclosure')} match={b.get('match_result')}",
"落点 trade_gateway.py:534 仅 blocked 分支回传适当性字段;可复用 "
"trade_action_service.subscribe_self_service_allowed 收敛四处判定")
# 4-02 R4 交叉印证(同结论第二只产品,排除单品配置差异)
r2 = submit(P_R4B)
b2 = body(r2)
expect_true(G, "4-02 R4 第二只产品交叉印证(排除单品差异)",
b2.get("requires_disclosure") or b2.get("blocked"),
"同 4-01", f"blocked={b2.get('blocked')} requires_disclosure={b2.get('requires_disclosure')}",
"两只 R4 产品行为一致 → 非单品配置问题,是通道级判定缺失")
# 4-03 R5 → 真阻断(这一档是**真** blocked=true,与 R4 语义不同,二者不可混为一谈)
r = submit(P_R5)
b = body(r)
if expect_true(G, "4-03 R5 产品 → 200 + blocked=true + forbidden",
r.status_code == 200 and b.get("blocked") is True
and b.get("match_result") == "forbidden",
"HTTP 200 blocked=true forbidden",
f"HTTP {r.status_code} blocked={b.get('blocked')} match={b.get('match_result')}"):
missing = [k for k in ("block_response_code", "rule_refs", "reasons", "advice", "notice")
if not b.get(k)]
expect_true(G, "4-03b 阻断响应字段齐备", not missing,
"block_response_code/rule_refs/reasons/advice/notice 全非空",
f"缺失={missing or '无'}", json.dumps(b, ensure_ascii=False)[:400])
# 4-04 **红线**:真阻断(R5)不得写 core_trade。
# 用「R5 前后 + 未知产品前后」两次采样,把 R4 的写入排除在外单独计数。
n_before_blocked = db_one("SELECT COUNT(*) FROM core_trade")
submit(P_R5)
submit("PROD-NOPE-999")
n_after_blocked = db_one("SELECT COUNT(*) FROM core_trade")
expect_true(G, "4-04 红线:真阻断路径不写 core_trade",
(n_after_blocked or 0) == (n_before_blocked or 0),
"R5 + 未知产品两次阻断 → core_trade 零增量",
f"before={n_before_blocked} after={n_after_blocked} "
f"Δ={(n_after_blocked or 0) - (n_before_blocked or 0)}",
"阻断响应**照常返回 trade_id**(预生成),但该 id 不落库 —— 已单独验证 Δ0")
# 4-04b 红线:R4 被阻断后**同样**不写 core_trade(与 4-04 的 R5 一起构成两类阻断的零写入证据)
#
# ⚠️ 本断言于 2026-09-13 收口时**反转**。初轮写的是「R4 放行则确实写库」——
# 那是把修复前的**缺陷行为**固化成了期望值(同 `sandbox_risk_test.py` 的 T1 腐化模式:
# 断言跟着 bug 走,bug 一修就变假红)。P1 修复后 R4 归入阻断,故期望值随之翻转为负例。
after = db_one("SELECT COUNT(*) FROM core_trade")
expect_true(G, "4-04b 红线:R4 被阻断后同样不写 core_trade",
(after or 0) == (before or 0),
f"R4 两次请求不应改变 core_trade(before={before})",
f"after={after} Δ={(after or 0) - (before or 0)}",
"与 4-04 合起来:R4(需揭示)与 R5(越级)两类阻断**都**不落库")
# 4-05 未知客户 / 未知产品 → **fail-closed**(实测为 200+blocked,非 404)
for label, cid, pid in (("未知客户·格式合法", "CUST-9999", P_AUTH),
("未知客户·格式非法", "NOT-A-CUSTOMER-ID!!", P_AUTH),
("未知产品", CUSTOMER, "PROD-NOPE-999")):
r = submit(pid, cid=cid, headers=trade_h(ctx.h["risk"], "risk"))
b = body(r)
expect_true(G, f"4-05 {label} → fail-closed 阻断",
r.status_code == 200 and b.get("blocked") is True
and b.get("block_response_code") == "SUIT_NOT_FOUND",
"HTTP 200 blocked=true SUIT_NOT_FOUND",
f"HTTP {r.status_code} blocked={b.get('blocked')} "
f"code={b.get('block_response_code')}",
"**口径修正**:计划原写「未知产品→404」,实测为 fail-closed 阻断;"
"阻断优于 404(不泄露产品存在性),以实测为准")
# 4-06 响应全键枚举(不泄露内部字段)
r = submit(P_R4)
b = body(r)
leaked = [k for k in b if k in ("traceback", "stack", "sql", "nav_pending", "raw")]
expect_true(G, "4-06 阻断响应不含内部字段", not leaked,
"无 traceback/stack/sql 等内部键", f"泄露键={leaked or '无'}",
f"全部键={sorted(b)}")
# --------------------------------------------------------------------------
# CT5 申购参数校验
# --------------------------------------------------------------------------
def ct5(ctx: Ctx) -> None:
G = "CT5"
path = "/api/simulate/trade"
h = trade_h(ctx.h["customer"], "customer")
def post(payload):
return ctx.client.post(path, headers=h, json=payload)
base = {"customer_id": CUSTOMER, "trade_type": "subscribe", "product_id": P_AUTH}
cases = [
("5-01 缺 amount", {**base}, 422, "request validation 拒绝"),
("5-02 amount=0", {**base, "amount": "0"}, 422, "gt=0 校验(pydantic 对 0 触发)"),
("5-03 amount 负数", {**base, "amount": "-100"}, 422, "gt=0 校验"),
("5-04 缺 product_id", {"customer_id": CUSTOMER, "trade_type": "subscribe", "amount": "1000"}, 422,
"model_validator 按类型分池拒绝"),
]
for name, payload, want, why in cases:
r = post(payload)
got = err_code(r)
expect_true(G, f"{name} → {want}", r.status_code == want, f"HTTP {want}", f"HTTP {r.status_code} {got or ''}",
why)
# 5-05 未知产品 → **fail-closed 阻断**(实测,非计划所写的 404)
# 适当性层把「产品或客户不存在」当作 match_result=forbidden 处理
# (core_ro.py:161-172 `matrix is None` 分支 → SUIT_NOT_FOUND),
# 故走的是 200+blocked 而非 404。这**优于** 404:不泄露产品是否存在。
r = post({"customer_id": CUSTOMER, "trade_type": "subscribe", "product_id": "PROD-NOPE-999",
"amount": "1000"})
b = body(r)
expect_true(G, "5-05 未知产品 → fail-closed 阻断(非 404)",
r.status_code == 200 and b.get("blocked") is True
and b.get("block_response_code") == "SUIT_NOT_FOUND",
"HTTP 200 blocked=true SUIT_NOT_FOUND",
f"HTTP {r.status_code} blocked={b.get('blocked')} code={b.get('block_response_code')}",
"口径修正:计划原写 404;实测 fail-closed。以实测为准(更安全的一档)")
expect_true(G, "5-05b 未知产品阻断不写库",
db_one("SELECT COUNT(*) FROM core_trade WHERE product_id='PROD-NOPE-999'") == 0,
"0 行", f"{db_one('SELECT COUNT(*) FROM core_trade')} 行为总表计数")
# 5-06 未知 trade_type → 400(**不是** 422:model_validator 刻意不拦未知类型)
r = post({"customer_id": CUSTOMER, "trade_type": "swap", "product_id": P_AUTH, "amount": "1000"})
expect_true(G, "5-06 未知 trade_type → 400(非 422)",
r.status_code == 400, "HTTP 400", f"HTTP {r.status_code} {err_code(r)}",
"UnsupportedTradeType → ApiError(400),是刻意保留的既有口径")
# 5-07 client_request_id 格式非法 → 422(**仅 convert 池校验**,申赎不校验)
r = post({"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "100", "client_request_id": "bad id!!!"})
expect_true(G, "5-07 convert 的 client_request_id 非法 → 422",
r.status_code == 422, "HTTP 422", f"HTTP {r.status_code} {err_code(r)}",
"HEADER_ID_PATTERN 白名单校验在 convert 分支内(simulate.py:114)")
r = post({**base, "amount": "1000", "client_request_id": "bad id!!!"})
info(G, "5-08 申购池的 client_request_id 非法(软探针)",
f"HTTP {r.status_code} {err_code(r) or ''}",
"申购/赎回池**不校验**该字段(校验只在 convert 分支)→ 全局白名单仅对 convert 生效")
# --------------------------------------------------------------------------
# CT6 赎回(金额 = qty × T日净值 − FIFO 逐批赎回费)
# --------------------------------------------------------------------------
def ct6(ctx: Ctx) -> None:
G = "CT6"
path = "/api/simulate/trade"
today = ctx.state["today"]
pid = P_REDEEM
nav = nav_as_of(pid, today)
if nav is None:
skip(G, "6-01 赎回放行与金额复算", f"{pid} 无可用净值")
return
lots_before = t2_lots(CUSTOMER, pid, today)
qty = derive_redeem_qty(lots_before, sum((l["remain_qty"] for l in lots_before), Decimal("0")))
total_avail = sum((l["remain_qty"] for l in lots_before), Decimal("0"))
if qty is None or total_avail < qty:
skip(G, "6-01 赎回放行与金额复算",
f"T+2 可扣批次合计 {total_avail},无法推导合法申报量(库中事实不足,非缺陷)")
return
exp_amount, detail = oracle_redeem_amount(CUSTOMER, pid, qty, today)
before = _snapshot_product(CUSTOMER, pid)
h_before = _holdings_map(ctx)
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "redeem", "product_id": pid,
"qty": str(qty), "client_request_id": f"{IDEM_PREFIX}-ct6-01"},
)
b = body(r)
if not expect_true(G, "6-01 赎回放行 → 200", r.status_code == 200 and b.get("blocked") is False,
"HTTP 200 blocked=false",
f"HTTP {r.status_code} blocked={b.get('blocked')} trade_id={b.get('trade_id')}",
r.text[:300]):
return
ctx.state["ct6_redeemed_qty"] = str(qty)
ctx.state["ct6_redeemed_product"] = pid
after = _snapshot_product(CUSTOMER, pid)
h_after = _holdings_map(ctx)
# 6-02 金额复算(独立预言机 = FIFO 逐批 round2(qty×nav) − round2(金额×费率) 求和)
db_amount = db_one(
"SELECT amount FROM core_trade WHERE trade_id=%s", params=(b.get("trade_id"),)
)
expect_true(G, "6-02 赎回金额 = qty×T日净值 − FIFO 逐批赎回费",
dec(db_amount) == exp_amount,
f"复算 {exp_amount}(nav={nav} @ T-1)",
f"落库 {db_amount}",
json.dumps(detail, ensure_ascii=False))
# 6-03 FIFO 批次扣减(按业务键定位,不用行号)
exp_plan = plan_lots(lots_before, qty)
exp_remain = {l["lot_id"]: l["remain_qty"] for l in lots_before}
for lot, take in exp_plan:
exp_remain[lot["lot_id"]] -= take
got_remain = {l["lot_id"]: l["remain"] for l in after["lots"]}
mismatches = [
f"{lid}: 期望 {exp_remain[lid]} 实际 {got_remain.get(lid, '缺行')}"
for lid in exp_remain
if got_remain.get(lid) != exp_remain[lid]
]
expect_true(G, "6-03 FIFO 跨档扣减顺序正确", not mismatches,
f"FIFO 消耗 {len(exp_plan)} 批:" + "、".join(f"{l['lot_id']} 扣 {t}" for l, t in exp_plan),
f"不一致 {len(mismatches)} 项", ";".join(mismatches))
# 6-04 四条链的第三条:快照同步
exp_h = (before["holding_qty"] or Decimal("0")) - qty
expect_true(G, "6-04 core_holding 快照同步扣减", after["holding_qty"] == exp_h,
f"快照 = {exp_h}", f"快照 = {after['holding_qty']}")
# 6-05 读侧一致
bb = h_before.get(pid)
aa = h_after.get(pid)
exp_api = (dec(bb.get("qty")) if bb else Decimal("0")) - qty
expect_true(G, "6-05 GET /holdings 读侧同步", aa is not None and dec(aa.get("qty")) == exp_api,
f"读侧 qty = {exp_api}", f"读侧 qty = {aa.get('qty') if aa else '无该行'}")
# 6-06 超份额 → 400 INSUFFICIENT_SHARES
over = total_avail + Decimal("1000000")
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "redeem", "product_id": pid,
"qty": str(over), "client_request_id": f"{IDEM_PREFIX}-ct6-06"},
)
expect_code(G, "6-06 超可用份额 → 400 INSUFFICIENT_SHARES", r, 400, "INSUFFICIENT_SHARES")
# 6-07 缺 qty → 422(份额申报口径,不再退回 amount 反算)
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "redeem", "product_id": pid, "amount": "1000"},
)
expect_true(G, "6-07 赎回缺 qty(只给 amount)→ 422", r.status_code == 422, "HTTP 422",
f"HTTP {r.status_code} {err_code(r)}",
"D26/R-6:赎回按份额申报,v1.0 的 amount÷nav 反算已退役")
# 6-08 无可用净值 → 503(14 只产品全有净值则 SKIP)
no_nav = _product_without_nav(today)
if no_nav is None:
skip(G, "6-08 无可用净值 → 503 NAV_NOT_READY",
"库中 14 只产品全部有可用净值,该分支本轮不可达(记入未覆盖项)")
else:
r = ctx.client.post(
path, headers=trade_h(ctx.h["customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "redeem", "product_id": no_nav, "qty": "1"},
)
expect_code(G, "6-08 无可用净值 → 503 NAV_NOT_READY", r, 503, "NAV_NOT_READY")
def _product_without_nav(today: date) -> str | None:
rows = db_query(
"SELECT p.product_id FROM core_product p "
"LEFT JOIN core_product_nav n ON n.product_id=p.product_id AND n.nav_date<=%s "
"WHERE n.product_id IS NULL LIMIT 1",
params=(today.isoformat(),),
)
return rows[0][0] if rows else None
# --------------------------------------------------------------------------
# CT7 转换受理(T 日只受理,不扣份额)
# --------------------------------------------------------------------------
def ct7(ctx: Ctx) -> None:
G = "CT7"
path = "/api/simulate/trade"
h = trade_h(ctx.h["customer"], "customer")
before = _snapshot_product(CUSTOMER, P_CONV_FROM)
n_conv_before = db_one("SELECT COUNT(*) FROM core_convert_request")
idem = f"{IDEM_PREFIX}-ct7-01"
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "1000", "client_request_id": idem},
)
b = body(r)
gid = b.get("convert_group_id")
if expect_true(G, "7-01 转换受理 → 202 + accepted",
r.status_code == 202 and b.get("accepted") is True and b.get("status") == "accepted",
"HTTP 202 accepted=true status=accepted",
f"HTTP {r.status_code} accepted={b.get('accepted')} status={b.get('status')} gid={gid}",
json.dumps(b, ensure_ascii=False)[:400]):
ctx.state["ct7_gid"] = gid
missing = [k for k in ("confirm_date", "available_date", "cancel_deadline") if not b.get(k)]
expect_true(G, "7-01b 回执三个展示日期齐备", not missing,
"confirm_date/available_date/cancel_deadline 全非空",
f"缺失={missing or '无'}",
f"confirm={b.get('confirm_date')} avail={b.get('available_date')} "
f"cancel_before={b.get('cancel_deadline')}")
# 7-02 **本组关键**:受理期不扣份额
after = _snapshot_product(CUSTOMER, P_CONV_FROM)
n_conv_after = db_one("SELECT COUNT(*) FROM core_convert_request")
expect_true(G, "7-02 受理期不扣份额(批次零变化)",
after["lot_sum"] == before["lot_sum"],
f"Δ批次和 = 0(受理前 {before['lot_sum']})",
f"Δ批次和 = {after['lot_sum'] - before['lot_sum']}")
expect_true(G, "7-02b 受理期持仓快照零变化",
after["holding_qty"] == before["holding_qty"],
f"Δ快照 = 0(受理前 {before['holding_qty']})",
f"Δ快照 = {after['holding_qty'] - before['holding_qty']}")
expect_true(G, "7-02c 仅 core_convert_request +1 行",
(n_conv_after or 0) - (n_conv_before or 0) == 1,
"Δ受理单 = 1",
f"before={n_conv_before} after={n_conv_after}")
# 7-03 跨主体 → 400 CROSS_ENTITY_NOT_SUPPORTED
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_CROSS, "qty": "100", "client_request_id": f"{IDEM_PREFIX}-ct7-03"},
)
expect_code(G, "7-03 跨主体转换 → 400 CROSS_ENTITY_NOT_SUPPORTED", r, 400, "CROSS_ENTITY_NOT_SUPPORTED")
# 7-04 幂等重放(同键同体)
n_before = db_one("SELECT COUNT(*) FROM core_convert_request")
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "1000", "client_request_id": idem},
)
b = body(r)
n_after = db_one("SELECT COUNT(*) FROM core_convert_request")
expect_true(G, "7-04 幂等重放 → 同 gid + idempotent=true 且不新增行",
r.status_code == 202 and b.get("idempotent") is True
and b.get("convert_group_id") == gid and (n_after or 0) == (n_before or 0),
f"HTTP 202 idempotent=true gid={gid} 且受理单零增量",
f"HTTP {r.status_code} idempotent={b.get('idempotent')} gid={b.get('convert_group_id')} "
f"Δ行={(n_after or 0) - (n_before or 0)}")
# 7-05 幂等:同键**不同体** → 409 IDEMPOTENCY_CONFLICT
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "9999", "client_request_id": idem},
)
expect_code(G, "7-05 同幂等键不同请求体 → 409 IDEMPOTENCY_CONFLICT", r, 409, "IDEMPOTENCY_CONFLICT")
# 7-06 缺 to_product_id → 422
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM, "qty": "100"},
)
expect_true(G, "7-06 缺 to_product_id → 422", r.status_code == 422, "HTTP 422",
f"HTTP {r.status_code} {err_code(r)}")
# 7-07 转入端 R4(需揭示产品)—— 与 CT4-01 同源,此处看**第二通道**是否同样失守。
# 必须用**同 TA** 的 R4 产品:`accept_convert` 里 `_validate_products`(:453)
# 早于 `suitability_check`(:457),跨 TA 会先撞 CROSS_ENTITY_NOT_SUPPORTED,
# 根本走不到适当性判定 —— 首版误用 PROD-161725(TA-CN-002) 即为此坑。
n_before = db_one("SELECT COUNT(*) FROM core_convert_request")
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_R4_TA1, "qty": "100", "client_request_id": f"{IDEM_PREFIX}-ct7-07"},
)
b = body(r)
n_after = db_one("SELECT COUNT(*) FROM core_convert_request")
info(G, "7-07a 转入端 R4 实测(原样)", f"HTTP {r.status_code} keys={sorted(b)}",
json.dumps(b, ensure_ascii=False)[:300])
expect_true(G, "7-07 转入端 R4 需揭示产品不应被受理",
r.status_code == 200 and b.get("blocked") is True,
"HTTP 200 blocked=true(**不是** 202 受理)",
f"HTTP {r.status_code} blocked={b.get('blocked')} match={b.get('match_result')} "
f"gid={b.get('convert_group_id')}",
"若为 202 → CT4-01 的根因在**转换通道同样存在**(同一声明、不同入口)")
expect_true(G, "7-07b 阻断的受理单零新增",
(n_after or 0) == (n_before or 0), "Δ受理单 = 0",
f"Δ受理单 = {(n_after or 0) - (n_before or 0)}")
# 7-07c 对照组:同 TA 的**可自助**产品(R3)应正常受理 → 证明 7-07 的结论
# 来自适当性判定而非「转换通道整体不可用」
r = ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_REDEEM, "qty": "100", "client_request_id": f"{IDEM_PREFIX}-ct7-07c"},
)
b = body(r)
expect_true(G, "7-07c 对照:转同 TA 可自助产品(R3)→ 202 受理",
r.status_code == 202 and b.get("accepted") is True,
"HTTP 202 accepted=true",
f"HTTP {r.status_code} accepted={b.get('accepted')} blocked={b.get('blocked')}",
"对照组通过即锁定:7-07 的差异只能归因于 R4 的适当性档位")
# 7-08 非本人客户发起
r = ctx.client.post(
path, headers=trade_h(ctx.h["other_customer"], "customer"),
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "100"},
)
expect_code(G, "7-08 非本人客户发起转换 → 403", r, 403, "AUTH_403_ROLE")
# 7-09 回执字段面无 group_id(T-9 字段名变更)
b = body(ctx.client.post(
path, headers=h,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "500", "client_request_id": f"{IDEM_PREFIX}-ct7-09"},
))
expect_true(G, "7-09 回执含 convert_group_id、不含 group_id",
"convert_group_id" in b and "group_id" not in b,
"convert_group_id 存在且无 group_id",
f"keys={sorted(b)}")
# --------------------------------------------------------------------------
# CT8 撤单与查询(owner/scope 双闸门)
# --------------------------------------------------------------------------
def ct8(ctx: Ctx) -> None:
G = "CT8"
h_cust = trade_h(ctx.h["customer"], "customer")
h_adv = trade_h(ctx.h["advisor"], "advisor")
h_other_adv = trade_h(ctx.h["other_advisor"], "advisor")
# 现造一笔待撤的单(不复用 CT7 的,避免与 CT9 确认批次相互干扰)
r = ctx.client.post(
"/api/simulate/trade", headers=h_cust,
json={"customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": P_CONV_FROM,
"to_product_id": P_CONV_TO, "qty": "300", "client_request_id": f"{IDEM_PREFIX}-ct8-01"},
)
b = body(r)
gid = b.get("convert_group_id")
if not gid or r.status_code != 202:
skip(G, "8-01 客户本人撤单 → cancelled", f"待撤单未建立:HTTP {r.status_code} {r.text[:160]}")
return
ctx.state["ct8_gid"] = gid
# 8-01 查询(未确认态):不得出现折算字段
r = ctx.client.get(f"/api/simulate/trade/convert/{gid}", headers=h_cust)
d = body(r)
expect_status(G, "8-01 客户本人查询受理单 → 200", r, 200)
leaked = [k for k in CONFIRMED_ONLY_FIELDS if k in d]
expect_true(G, "8-01b 未确认态不返回折算金额",
not leaked, "无任何 CONFIRMED_ONLY 字段",
f"泄露={leaked or '无'}", f"keys={sorted(d)}")
expect_true(G, "8-01c 未确认态 confirmed=false",
d.get("confirmed") is False, "confirmed=false", f"confirmed={d.get('confirmed')}")
# 8-02 advisor **可查**同一张单(查询 scope 闸门)
r = ctx.client.get(f"/api/simulate/trade/convert/{gid}", headers=h_adv)
expect_status(G, "8-02 归属顾问可查同一张单 → 200(查询 scope)", r, 200)
# 8-03 advisor **不可撤**(交易 owner 闸门;红线 7 两类闸门不可合并)
r = ctx.client.post(f"/api/simulate/trade/convert/{gid}/cancel", headers=h_adv)
expect_code(G, "8-03 归属顾问撤单 → 403 AUTH_403_ROLE(红线 7)", r, 403, "AUTH_403_ROLE")
# 8-04 非归属顾问不可撤
r = ctx.client.post(f"/api/simulate/trade/convert/{gid}/cancel", headers=h_other_adv)
expect_code(G, "8-04 非归属顾问撤单 → 403", r, 403, "AUTH_403_ROLE")
# 8-05 非本人客户越权查询
r = ctx.client.get(f"/api/simulate/trade/convert/{gid}", headers=trade_h(ctx.h["other_customer"], "customer"))
expect_code(G, "8-05 非本人客户查询 → 403 AUTH_403_NOT_OWNER", r, 403, "AUTH_403_NOT_OWNER")
# 8-06 客户本人撤单 → cancelled
r = ctx.client.post(f"/api/simulate/trade/convert/{gid}/cancel", headers=h_cust)
b = body(r)
expect_true(G, "8-06 客户本人撤单 → cancelled",
r.status_code == 200 and b.get("status") == "cancelled",
"HTTP 200 status=cancelled",
f"HTTP {r.status_code} status={b.get('status')} {err_code(r) or ''}",
json.dumps(b, ensure_ascii=False)[:300])
# 8-07 重复撤单 → 409 CANCEL_NOT_ALLOWED(状态闸门先于时间闸门)
r = ctx.client.post(f"/api/simulate/trade/convert/{gid}/cancel", headers=h_cust)
expect_code(G, "8-07 重复撤单 → 409 CANCEL_NOT_ALLOWED", r, 409, "CANCEL_NOT_ALLOWED")
# 8-08 不存在的单 → 404
r = ctx.client.get("/api/simulate/trade/convert/CNV-NOPE-000000", headers=h_cust)
expect_code(G, "8-08 不存在的受理单 → 404 CONVERT_REQUEST_NOT_FOUND",
r, 404, "CONVERT_REQUEST_NOT_FOUND")
# 8-09 撤单后份额占用释放(可用份额回到物理余量)
avail = db_one(
"SELECT SUM(remain_qty) FROM core_share_lot WHERE customer_id=%s AND product_id=%s AND remain_qty>0",
params=(CUSTOMER, P_CONV_FROM),
)
inflight = db_one(
"SELECT COALESCE(SUM(qty),0) FROM core_convert_request WHERE customer_id=%s AND from_product_id=%s "
"AND status IN ('accepted','nav_pending')",
params=(CUSTOMER, P_CONV_FROM),
)
info(G, "8-09 撤单后占用释放(口径核对)",
f"物理余量 {dec(avail)} · 未终态占用 {dec(inflight)} · 可用 {dec(avail) - dec(inflight)}",
"撤单后该单离开未终态集合 → 占用自动释放(无冻结列,R-3)")
# --------------------------------------------------------------------------
# CT9 转换管理端点(仅 risk_officer)
# ⚠️ 顺序硬约束:本组必须在 CT8 之后 —— 一旦确认成功,「未确认态无折算金额」不再可复现。
# --------------------------------------------------------------------------
def ct9(ctx: Ctx) -> None:
G = "CT9"
h_risk = trade_h(ctx.h["risk"], "risk")
# 9-01 meta 放行 + latest_nav_date 与库中 MAX(nav_date) 一致
r = ctx.client.get("/api/admin/convert/meta", headers=h_risk)
d = body(r)
db_max = db_one("SELECT MAX(nav_date) FROM core_product_nav")
expect_true(G, "9-01 meta 放行且 latest_nav_date 与库一致",
r.status_code == 200 and str(d.get("latest_nav_date")) == str(db_max),
f"HTTP 200 latest_nav_date={db_max}", f"HTTP {r.status_code} {d.get('latest_nav_date')}",
"该值即前端确认批处理的默认日(F4 回归相关)")
latest = str(d.get("latest_nav_date") or "")
# 9-02 manager 调 meta → 403(非 risk_officer)
r = ctx.client.get("/api/admin/convert/meta", headers=trade_h(ctx.h["manager"], "risk"))
expect_code(G, "9-02 risk_manager 调 meta → 403 AUTH_403_ROLE", r, 403, "AUTH_403_ROLE")
# 9-03 客户调管理端点 → 403
r = ctx.client.get("/api/admin/convert/meta", headers=trade_h(ctx.h["customer"], "customer"))
expect_true(G, "9-03 客户调管理端点 → 403",
r.status_code == 403, "HTTP 403", f"HTTP {r.status_code} {err_code(r)}",
"客户 token_type 不在 risk 通道白名单 → AGENT_MISMATCH 或 ROLE")
# 9-04 confirm 放行(用 meta 给出的业务日)
n_detail_before = db_one("SELECT COUNT(*) FROM core_convert_lot_detail")
r = ctx.client.post("/api/admin/convert/confirm", headers=h_risk, params={"accept_date": latest})
d = body(r)
expect_true(G, f"9-04 confirm?accept_date={latest} → 200", r.status_code == 200,
"HTTP 200", f"HTTP {r.status_code} {err_code(r) or ''}", json.dumps(d, ensure_ascii=False)[:400])
if r.status_code == 200:
keys = {"as_of", "scanned", "confirmed", "nav_pending", "rejected", "skipped", "failed", "locked"}
expect_true(G, "9-04b 汇总键齐备", keys <= set(d),
f"含 {sorted(keys)}", f"缺失={sorted(keys - set(d))}",
json.dumps({k: d.get(k) for k in sorted(keys)}, ensure_ascii=False))
info(G, "9-04c 批处理计数实测",
f"scanned={d.get('scanned')} confirmed={d.get('confirmed')} nav_pending={d.get('nav_pending')} "
f"rejected={d.get('rejected')} skipped={d.get('skipped')} failed={d.get('failed')}",
f"⚠️ 净值库最新 {latest},而新受理单的 accept_date 为下一交易日 → "
"T 日净值不可得时按 FR-C27 记 nav_pending(不 reject、不降级),"
"故本环境下「受理 → 确认折算」全链路未必可达,记入未覆盖项")
# 9-04d **FR-C27 实测**:净值不可得的业务日 → 全部 nav_pending,**不 reject、不降级**。
# 计划曾担心「净值止于 09-11、受理日滚动到 09-14 → 全链路不可达」而打算记 SKIP;
# 实测换个业务日即可命中该分支(18 单全 nav_pending),故升格为**硬断言** ——
# 这是转换链路里唯一覆盖「净值晚公告」这一真实运营场景的用例。
next_biz = _next_biz_day(ctx.state["nav_date"])
if next_biz is None:
skip(G, "9-04d FR-C27 净值不可得 → nav_pending", "取不到下一交易日")
else:
n_detail0 = db_one("SELECT COUNT(*) FROM core_convert_lot_detail")
n_lots0 = db_one("SELECT COALESCE(SUM(remain_qty),0) FROM core_share_lot WHERE customer_id=%s",
params=(CUSTOMER,))
r = ctx.client.post("/api/admin/convert/confirm", headers=h_risk,
params={"accept_date": next_biz.isoformat()}, timeout=90)
d2 = body(r)
n_detail1 = db_one("SELECT COUNT(*) FROM core_convert_lot_detail")
n_lots1 = db_one("SELECT COALESCE(SUM(remain_qty),0) FROM core_share_lot WHERE customer_id=%s",
params=(CUSTOMER,))
if expect_true(G, f"9-04d confirm?accept_date={next_biz}(T 日净值不可得)→ 200",
r.status_code == 200, "HTTP 200", f"HTTP {r.status_code} {err_code(r)}",
json.dumps({k: d2.get(k) for k in
("scanned", "confirmed", "nav_pending", "rejected", "skipped", "failed")},
ensure_ascii=False)):
expect_true(G, "9-04e FR-C27:净值得不到的当日单**全部** nav_pending",
d2.get("scanned", 0) > 0
and d2.get("nav_pending") == d2.get("scanned")
and d2.get("confirmed") == 0,
"nav_pending == scanned 且 confirmed == 0",
f"scanned={d2.get('scanned')} nav_pending={d2.get('nav_pending')} "
f"confirmed={d2.get('confirmed')}",
f"as_of={next_biz},净值库最新 {ctx.state['nav_date']} → T 日净值不可得")
expect_true(G, "9-04f FR-C27 不 reject、不 failed(不降级处理)",
d2.get("rejected") == 0 and d2.get("failed") == 0,
"rejected=0 且 failed=0",
f"rejected={d2.get('rejected')} failed={d2.get('failed')}",
"区分「净值未到」与「确实不合法」——前者必须挂起等下一轮,不得当失败处理")
expect_true(G, "9-04g 挂起期间无任何折算落库",
(n_detail1 or 0) == (n_detail0 or 0),
"core_convert_lot_detail 零增量", f"{n_detail0} → {n_detail1}",
"nav_pending 必须**零副作用**:不写折算明细、不扣批次")
expect_true(G, "9-04h 挂起期间批次份额零变化",
dec(n_lots1) == dec(n_lots0),
f"Σ批次 remain_qty 不变({n_lots0})", f"{n_lots0} → {n_lots1}")
# 挂起后受理单状态必须仍是待确认态(不得被改成 rejected/confirmed)
still = db_one(
"SELECT COUNT(*) FROM core_convert_request WHERE convert_group_id IN "
"(SELECT convert_group_id FROM core_convert_request WHERE status IN ('accepted','nav_pending'))"
)
info(G, "9-04i 挂起后受理单状态分布", f"待确认态 {still} 单",
"nav_pending 后状态须仍可被下一轮捞起,否则会永久卡单")
# 9-05 重复触发 → 不产生第二套折算
n_detail_mid = db_one("SELECT COUNT(*) FROM core_convert_lot_detail")
r = ctx.client.post("/api/admin/convert/confirm", headers=h_risk, params={"accept_date": latest})
n_detail_after = db_one("SELECT COUNT(*) FROM core_convert_lot_detail")
expect_true(G, "9-05 重复触发幂等(不产生第二套折算)",
(n_detail_after or 0) == (n_detail_mid or 0),
"core_convert_lot_detail 零增量",
f"首次后={n_detail_mid} 第二次后={n_detail_after} "
f"(本轮起 {n_detail_before})")
# 9-06 accept_date 非法格式:非 YYYY-MM-DD → 422(Query pattern);日历非法 → 400(handler)
r = ctx.client.post("/api/admin/convert/confirm", headers=h_risk, params={"accept_date": "2026/09/15"})
expect_true(G, "9-06 accept_date 非 YYYY-MM-DD → 422(Query pattern 拦截)",
r.status_code == 422, "HTTP 422", f"HTTP {r.status_code} {err_code(r)}",
"pattern 校验早于 handler,不走 _parse_as_of 的 400 分支")
r = ctx.client.post("/api/admin/convert/confirm", headers=h_risk, params={"accept_date": "2026-13-45"})
expect_true(G, "9-06b accept_date 日历非法 → 400(走 handler 分支)",
r.status_code == 400, "HTTP 400", f"HTTP {r.status_code} {err_code(r)}",
"pattern 通过但 strptime 失败 → ApiError(400)")
# 9-07 manager 调 confirm → 403
r = ctx.client.post("/api/admin/convert/confirm", headers=trade_h(ctx.h["manager"], "risk"),
params={"accept_date": latest})
expect_code(G, "9-07 risk_manager 调 confirm → 403 AUTH_403_ROLE", r, 403, "AUTH_403_ROLE")
# 9-08 并发冲突:**确定性**做法 = 外部预占批处理锁,而非赛跑(避免 flaky)
if not latest:
skip(G, "9-08 批处理锁被占用 → 409 CONCURRENT_CONFLICT", "未取到 latest_nav_date")
return
held, key_or_err = _hold_confirm_lock(ctx, latest)
if held:
# 抢锁等待上限 2s(LOCK_TIMEOUT_SECONDS),故本请求预期耗时 ≥2s 后返回 409
t0 = time.monotonic()
r = ctx.client.post("/api/admin/convert/confirm", headers=h_risk,
params={"accept_date": latest}, timeout=60)
waited = time.monotonic() - t0
expect_code(G, "9-08 批处理锁被占用 → 409 CONCURRENT_CONFLICT", r, 409, "CONCURRENT_CONFLICT")
info(G, "9-08b 抢锁等待耗时与键名", f"{waited:.2f}s(预期 ≥2s 等待窗口)· key={ctx.state.get('confirm_lock_key')}",
"耗时明显短于 2s 说明走的不是「等待超时」路径,需复核")
_release_confirm_lock(ctx)
else:
skip(G, "9-08 批处理锁被占用 → 409 CONCURRENT_CONFLICT",
f"Redis 预占失败({key_or_err})——Redis 不可达时会退回进程内锁并正常处理,不产生 409")
def _next_biz_day(day) -> date | None:
"""`day` 之后的第一个交易日(用于构造「T 日净值尚未公告」的业务日)。"""
d = day if isinstance(day, date) else datetime.strptime(str(day)[:10], "%Y-%m-%d").date()
v = db_one("SELECT MIN(cal_date) FROM core_trade_calendar WHERE cal_date>%s AND is_open=1",
params=(d.isoformat(),))
if v is None:
return None
return v if isinstance(v, date) else datetime.strptime(str(v), "%Y-%m-%d").date()
def _hold_confirm_lock(ctx: Ctx, as_of: str) -> tuple[bool, str]:
"""外部预占 Redis 批处理锁,让 409 分支**确定性**可达(而非赛跑型 flaky)。
键名由 `app.service.risk.locks` 的**单一事实源**构造(`lock:` 前缀 +
`convert:confirm:{as_of}`),不手写字符串 —— 否则键名一改本用例就静默失效。
语义链(本轮已核实):`run_locked` Redis 抢锁**等待超时(2s)** → `fn(locked=False)`
→ `confirm_batch` 返回 `locked=False` → `convert_admin.py:265` 显式化 `ConcurrentConflict` 409。
注意区分两条降级路径:**Redis 不可用**会退回进程内锁并正常处理(**不**产生 409),
只有「Redis 在、但键被占」才走超时分支 —— 故本探针必须真在 Redis 上占键。
"""
try:
import redis as redis_lib
from app.config.settings import settings
from app.service.risk.locks import convert_confirm_lock_key
key = "lock:" + convert_confirm_lock_key(datetime.strptime(as_of, "%Y-%m-%d").date())
cli = redis_lib.from_url(settings.redis_url)
held = bool(cli.set(key, "e2e-probe", nx=True, ex=25))
ctx.state["confirm_lock_key"] = key
return held, key
except Exception as exc: # noqa: BLE001
print(f" (锁预占跳过: {exc})")
return False, str(exc)
def _release_confirm_lock(ctx: Ctx) -> None:
"""释放探针占用的锁,避免影响紧随其后的用例与回归复跑。"""
key = ctx.state.get("confirm_lock_key")
if not key:
return
try:
import redis as redis_lib
from app.config.settings import settings
redis_lib.from_url(settings.redis_url).delete(key)
except Exception as exc: # noqa: BLE001
print(f" (锁释放跳过: {exc})")
# --------------------------------------------------------------------------
# CT10 读侧对账
# --------------------------------------------------------------------------
def ct10(ctx: Ctx) -> None:
G = "CT10"
h = platform_h(ctx.h["customer"])
# 10-01 holdings 与 core_holding 逐项对账(差值口径)
r = ctx.client.get(f"/api/customers/{CUSTOMER}/holdings", headers=h)
d = data_of(r)
items = d.get("items") or []
expect_status(G, "10-01 GET /holdings → 200", r, 200)
mismatches = []
for row in items:
db_qty = db_one(
"SELECT qty FROM core_holding WHERE customer_id=%s AND product_id=%s",
params=(CUSTOMER, row.get("product_id")),
)
if db_qty is None:
mismatches.append(f"{row.get('product_id')}: 读侧有行、快照无行")
elif dec(db_qty) != dec(row.get("qty")):
mismatches.append(f"{row.get('product_id')}: 读侧 {row.get('qty')} vs 快照 {db_qty}")
expect_true(G, "10-01b 读侧 holdings 与 core_holding 逐项一致", not mismatches,
f"{len(items)} 行全一致", f"不一致 {len(mismatches)} 项",
";".join(mismatches) or "—")
# 10-02 脱敏与类型
raw = r.text
leaked = [p for p in ("身份证", "手机号", "id_card", "phone") if p in raw]
expect_true(G, "10-02 读侧无敏感字段原文", not leaked, "无 id_card/phone 等键",
f"命中={leaked or '无'}")
types_ok = all(isinstance(row.get("qty"), (int, float, str)) for row in items)
expect_true(G, "10-02b 数值字段类型可序列化", types_ok, "qty 为数值或字符串", f"types_ok={types_ok}")
# 10-03 limit 边界
r0 = ctx.client.get(f"/api/customers/{CUSTOMER}/holdings", headers=h, params={"limit": 0})
r501 = ctx.client.get(f"/api/customers/{CUSTOMER}/holdings", headers=h, params={"limit": 501})
expect_true(G, "10-03 limit=0 / 501 → 422(Query ge/le 边界)",
r0.status_code == 422 and r501.status_code == 422,
"两者均 422", f"limit=0 → {r0.status_code};limit=501 → {r501.status_code}")
expect_true(G, "10-03b truncated 字段存在且为 false(行数远小于 limit)",
"truncated" in d and d.get("truncated") is False,
"truncated=false", f"truncated={d.get('truncated')} total={d.get('total')}")
# 10-04 **TODO P1 明确欠账**:下单后持仓数字确实变化(CT3-03 的落点)
row = next((x for x in items if x.get("product_id") == P_SUB_R3), None)
expect_true(G, "10-04 申购后新增持仓行可见(CT3-03 落点)",
row is not None and dec(row.get("qty")) > 0,
f"{P_SUB_R3} 出现在读侧且 qty>0",
f"{P_SUB_R3} qty={row.get('qty') if row else '无该行'}")
if row is not None:
lot_sum = db_one(
"SELECT SUM(remain_qty) FROM core_share_lot WHERE customer_id=%s AND product_id=%s",
params=(CUSTOMER, P_SUB_R3),
)
expect_true(G, "10-04b 该行与批次和一致(差值为 0)",
dec(row.get("qty")) == dec(lot_sum),
f"qty == Σ批次 = {lot_sum}", f"读侧 {row.get('qty')} vs 批次和 {lot_sum}")
# 10-05 trades 流水分页与字段
r = ctx.client.get(f"/api/customers/{CUSTOMER}/trades", headers=h, params={"limit": 10})
d2 = data_of(r)
rows = d2.get("items") or []
expect_true(G, "10-05 GET /trades → 200 且首屏有行",
r.status_code == 200 and len(rows) > 0, "首屏 >0 行", f"{len(rows)} 行")
if rows:
need = {"trade_id", "trade_type", "product_id", "amount", "trade_status", "traded_at"}
expect_true(G, "10-05b 流水行含前端列所需字段", need <= set(rows[0]),
f"含 {sorted(need)}", f"缺失={sorted(need - set(rows[0]))}")
# 本轮新交易应在首屏(按 traded_at 倒序)
types = {row.get("trade_type") for row in rows}
info(G, "10-05c 首屏流水类型分布", f"{sorted(types)}",
"前端「类型」列经 labelTradeType 渲染 —— convert 是否有中文标签由 Part B B4 核实")
# 10-06 products 携带适当性字段(前端 tradeEligibility 的数据源)
r = ctx.client.get(f"/api/customers/{CUSTOMER}/products", headers=h, params={"limit": 50})
d3 = data_of(r)
prods = d3.get("items") or d3.get("products") or []
expect_true(G, "10-06 GET /products → 200 且非空", r.status_code == 200 and len(prods) > 0,
">0 条", f"{len(prods)} 条")
if prods:
keys = set(prods[0])
elig = [k for k in keys if "match" in k or "disclos" in k or "block" in k or "risk" in k]
info(G, "10-06b 产品行键面", f"{sorted(keys)}",
f"适当性相关键:{sorted(elig) or '无'}(前端需据此判定可申购/需揭示)")
by_id = {p.get("product_id"): p for p in prods}
for pid, exp in ((P_R4, "非 allowed"), (P_R5, "forbidden")):
p = by_id.get(pid)
got = None
if p:
got = p.get("match_result") or p.get("suitability") or p.get("block_response_code")
info(G, f"10-06c {pid} 适当性字段实测", f"{got}(期望 {exp})",
json.dumps(p, ensure_ascii=False)[:300] if p else "该产品不在返回集中")
# 10-07 赎回后读侧一致(消费 CT6 结果;CT6 未跑则 SKIP 而非空 PASS)
if not ctx.state.get("ct6_redeemed_qty"):
skip(G, "10-07 赎回后读侧与快照一致", "CT6 未发生赎回(整组 SKIP 或 6-01 失败)")
return
pid_redeem = ctx.state.get("ct6_redeemed_product") or P_REDEEM
r = ctx.client.get(f"/api/customers/{CUSTOMER}/holdings", headers=h)
items2 = data_of(r).get("items") or []
row = next((x for x in items2 if x.get("product_id") == pid_redeem), None)
db_qty = db_one(
"SELECT qty FROM core_holding WHERE customer_id=%s AND product_id=%s",
params=(CUSTOMER, pid_redeem),
)
expect_true(G, "10-07 赎回后读侧与快照一致",
row is not None and dec(row.get("qty")) == dec(db_qty),
f"读侧 == 快照 == {db_qty}",
f"读侧={row.get('qty') if row else '无该行'} 快照={db_qty}")
# --------------------------------------------------------------------------
# CT11 对话线(SSE 帧序 + 红线)
# --------------------------------------------------------------------------
def _chat(
ctx: Ctx, message: str, session: str | None, headers: dict
) -> tuple[int, list[tuple[str, str]], str | None]:
"""一轮流式对话。`session=None` 时由服务端新建(首帧 meta 回传 session_id)。
返回 (status, frames, 服务端实际使用的 session_id)。
"""
payload: dict = {"message": message, "customer_id": CUSTOMER}
if session:
payload["session_id"] = session
status, frames = sse_frames(ctx, "/api/chat/stream", payload, headers)
sid = None
for kind, raw in frames:
if kind != "data":
continue
meta = frame_json(raw).get("meta") or {}
if meta.get("session_id"):
sid = meta["session_id"]
break
return status, frames, sid
def _number_of(text: str, product_id: str) -> int | None:
"""从清单文本里取某产品的全局序号(`N. 名称(PROD-xxx)— 标签`)。"""
for num, _name, pid in re.findall(r"^\s*(\d+)[.、]\s*(.+?)((PROD-[\w-]+))", text, flags=re.M):
if pid == product_id:
return int(num)
return None
def _stream_text(frames: list[tuple[str, str]]) -> str:
"""拼接文本 delta(判断回复语义用)。"""
return "".join(
((frame_json(raw).get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
for kind, raw in frames if kind == "data"
)
def ct11(ctx: Ctx) -> None:
G = "CT11"
h = trade_h(ctx.h["customer"], "customer")
# 11-01 帧序骨架:首帧 meta(含 session_id)→ 心跳注释
# 注意**不得**自造 session_id:`_guard_session` 对不存在的会话 404
# (chat.py:132-136,fail-closed)。首轮省略该字段、由服务端新建 ——
# 这也正是真实前端的流程(否则本组会全军 404)。
status, frames, sess = _chat(ctx, "你好", None, h)
if not expect_true(G, "11-01 对话流 → HTTP 200", status == 200, "HTTP 200",
f"HTTP {status}",
json.dumps([f for f in frames if f[0] == "body"], ensure_ascii=False)[:300]):
return
expect_true(G, "11-01a 服务端新建会话并回传 session_id", bool(sess),
"首帧 meta.session_id 非空", f"session_id={sess}",
"客户端不自造 id —— 自造会命中 _guard_session 的 404")
if not sess:
return # 无 sid 则后续多轮用例无法进行
first = next((f for f in frames if f[0] == "data"), None)
fj = frame_json(first[1]) if first else {}
expect_true(G, "11-01b 首帧为 role=assistant + meta",
(fj.get("choices") or [{}])[0].get("delta", {}).get("role") == "assistant" and fj.get("meta"),
"delta.role=assistant 且带 meta",
f"delta={(fj.get('choices') or [{}])[0].get('delta')} meta_keys={sorted(fj.get('meta') or {})}",
json.dumps(fj.get("meta"), ensure_ascii=False)[:300])
expect_true(G, "11-01c 心跳注释帧存在", any(k == "comment" for k, _ in frames),
"存在 `: ping` 注释帧", f"注释帧数={sum(1 for k, _ in frames if k == 'comment')}")
# 11-02 挂单:SSE 携带 pending_trade **且位于文本 delta 之前**
n_trade_before = db_one("SELECT COUNT(*) FROM core_trade")
status, frames, _ = _chat(ctx, "我要申购 PROD-110022 1000 元", None, h)
pt = pending_trade_frame(frames)
txt = first_text_frame(frames)
if expect_true(G, "11-02 意图命中 → SSE 携带 meta.pending_trade", pt is not None,
"存在 pending_trade 帧", f"帧数={len(frames)} pt={'有' if pt else '无'}",
json.dumps([f[1][:120] for f in frames[:6]], ensure_ascii=False)):
pt_idx, pt_frame = pt
expect_true(G, "11-02b pending_trade 帧**先于**文本 delta(帧序断言)",
txt is None or pt_idx < txt,
f"pending_trade@{pt_idx} < 首个文本 delta@{txt}",
f"pending_trade@{pt_idx} · 文本@{txt}")
payload = ((pt_frame.get("meta") or {}).get("pending_trade") or {})
pay = payload.get("payload") or payload
expect_true(G, "11-02c payload 含 trade_type/customer_id",
pay.get("trade_type") == "subscribe" and pay.get("customer_id") == CUSTOMER,
f"trade_type=subscribe customer_id={CUSTOMER}",
json.dumps(pay, ensure_ascii=False)[:300])
info(G, "11-02d pending_trade 结构", f"keys={sorted(payload)}",
json.dumps(payload, ensure_ascii=False)[:400])
# 11-03 **红线**:对话只解析,不写库
n_trade_after = db_one("SELECT COUNT(*) FROM core_trade")
expect_true(G, "11-03 红线:对话挂单不写 core_trade",
(n_trade_after or 0) == (n_trade_before or 0),
"core_trade 零增量(下单只能由 Modal 触发 POST /api/simulate/trade)",
f"before={n_trade_before} after={n_trade_after}")
# 11-04 「我能买什么」→ 清单分组与序号(**同一会话**续聊,序号槽位才能跨轮命中)
status, frames, sess2 = _chat(ctx, "我能买什么", sess, h)
text = _stream_text(frames)
ctx.state["ct11_catalog_text"] = text
has_footer = "请回复上方序号" in text
footer_ok = expect_true(G, "11-04 清单含结尾引导语", has_footer,
"含「请回复上方序号」", f"命中={has_footer}",
f"回复长度={len(text)}")
if not footer_ok:
info(G, "11-04 实际回复首段", text[:300], "用于判断是否走了清单工具路径")
# 11-04b 清单**逐条**标注可购性 —— 实测口径与计划预设不同,以实测为准。
# 计划原写「只列可自助档位(不应出现 R4/R5 分组)」;实测清单**列全 14 只**,
# 但每条都带明确处置标签:R1–R3「可直接申购」/ R4「需理财师/代理人办理
# (本助手不可自助申购)」/ R5「与风评不匹配,不能购买」,且表头声明
# 「仅标注『可直接申购』的序号会进入申购金额填写」。
# → 这是**更透明**的设计(客户能看到全货架并知道为何不可买),不是缺陷;
# 真正的要求是「不可自助的条目必须被明确标注」,故据此改写断言。
groups = sorted(set(re.findall(r"【R(\d)】", text)))
if groups:
expect_true(G, "11-04b 清单按风险档位分组且含全部档位",
groups == sorted({"1", "2", "3", "4", "5"}),
"分组 = R1..R5(全货架)", f"实测分组={groups}")
# 逐条处置标签:R1–R3 可直接申购;R4 需办理;R5 不可购买
m_r4 = re.findall(r"^\s*\d+\.\s*(.+?)((PROD-[\w-]+))\s*—\s*(.+)$", text, flags=re.M)
by_pid = {pid: label.strip() for _, pid, label in m_r4}
self_serve = [p for p, lb in by_pid.items() if "可直接申购" in lb]
r4_labeled = [p for p, lb in by_pid.items()
if lb.startswith("需") or "不可自助" in lb]
expect_true(G, "11-04b2 R1–R3 条目标注为「可直接申购」",
len(self_serve) >= 6, "≥6 只标为可直接申购",
f"{len(self_serve)} 只:{self_serve[:8]}")
expect_true(G, "11-04b3 不可自助条目被**明确标注**(不得含糊列出)",
len(r4_labeled) >= 5, "≥5 只标注需办理/不可自助",
f"{len(r4_labeled)} 只:{r4_labeled[:8]}",
"R4 标注「本助手不可自助申购」是 CT4-01 的第三个佐证:"
"清单、前端 tradeEligibility、对话闸门三者都判 R4 不可自助")
# 全局连续序号:跨组递增不回绕
nums = [int(x) for x in re.findall(r"^\s*(\d+)[.、]", text, flags=re.M)]
if nums:
expect_true(G, "11-04c 序号全局连续递增(跨组不回绕)",
nums == list(range(nums[0], nums[0] + len(nums))),
"严格 +1 递增", f"实测={nums}")
else:
info(G, "11-04c 序号解析", "未按 `N.` 格式解析到序号", f"回复片段={text[:200]}")
else:
info(G, "11-04b 清单分组未出现", "回复中无 【Rn】 分组标题",
"可能被 LLM 改写或走了其他意图 → 需人工核对(软探针,不计 FAIL)")
# 11-04d **选择不可自助条目(R4 序号)** → 不得生成挂单。
# 放在**独立会话**里做(不复用 sess2):否则会把「当前选中产品」槽位
# 留在会话里,污染紧随其后的 11-05 续槽断言。
_, fr_cat, sess_r4 = _chat(ctx, "我能买什么", None, h)
r4_no = _number_of(_stream_text(fr_cat), P_R4_TA1) or _number_of(_stream_text(fr_cat), P_R4)
if r4_no is None:
skip(G, "11-04d 选不可自助条目不应生成挂单", "清单中未解析到 R4 条目序号")
else:
n_before_r4 = db_one("SELECT COUNT(*) FROM core_trade")
st_r4, fr_r4, _ = _chat(ctx, str(r4_no), sess_r4, h)
pt_r4 = pending_trade_frame(fr_r4)
expect_true(G, f"11-04d 选 R4 序号({r4_no}) → 不生成 pending_trade",
pt_r4 is None, "无 pending_trade 帧",
f"HTTP {st_r4} pending_trade={'有' if pt_r4 else '无'}",
f"回复={_stream_text(fr_r4)[:240]}")
expect_true(G, "11-04e 选 R4 序号不写库",
db_one("SELECT COUNT(*) FROM core_trade") == n_before_r4,
"core_trade 零增量",
f"{n_before_r4} → {db_one('SELECT COUNT(*) FROM core_trade')}")
# 11-05 序号续槽(Redis 已起 → 真实槽位路径):回「1」应**索取金额**而非重出清单
status, frames, _ = _chat(ctx, "1", sess2, h)
text2 = _stream_text(frames)
expect_true(G, "11-05 序号续槽:槽位命中(未重出清单)",
"【R" not in text2,
"回复中**不再**出现分组清单标题", f"回复={text2[:200]}",
"Redis 槽位命中 → 索取金额(重出清单即槽位丢失)")
# 11-05b 续槽后补金额 → **端到端**生成 pending_trade(对话挂单全链路的收口断言)
n_before_slot = db_one("SELECT COUNT(*) FROM core_trade")
status, frames, _ = _chat(ctx, "5000", sess2, h)
pt_slot = pending_trade_frame(frames)
if expect_true(G, "11-05b 槽位补齐金额 → 生成 pending_trade",
pt_slot is not None, "存在 pending_trade 帧",
f"HTTP {status} pending_trade={'有' if pt_slot else '无'}",
f"回复={_stream_text(frames)[:200]}"):
pay_slot = ((pt_slot[1].get("meta") or {}).get("pending_trade") or {})
p2 = pay_slot.get("payload") or pay_slot
expect_true(G, "11-05c 挂单金额 = 槽位补入的 5000",
dec(p2.get("amount")) == Decimal("5000"),
"payload.amount = 5000", f"payload={json.dumps(p2, ensure_ascii=False)[:200]}")
expect_true(G, "11-05d 序号选中的产品与清单序号一致",
p2.get("product_id") == "PROD-000001",
"product_id = PROD-000001(清单序号 1)", f"product_id={p2.get('product_id')}")
expect_true(G, "11-05e 红线:续槽挂单同样不写库",
db_one("SELECT COUNT(*) FROM core_trade") == n_before_slot,
"core_trade 零增量",
f"{n_before_slot} → {db_one('SELECT COUNT(*) FROM core_trade')}")
# 11-06 非流式对话同形携带 pending_trade(**新建会话**,与流式同一契约面)
r = ctx.client.post(
"/api/chat", headers=h,
json={"message": "我要申购 PROD-110022 2000 元", "customer_id": CUSTOMER},
)
d = data_of(r)
has_pt = isinstance(d.get("pending_trade"), dict) or isinstance(body(r).get("pending_trade"), dict)
expect_true(G, "11-06 非流式 POST /api/chat 同样携带 pending_trade", has_pt,
"响应含 pending_trade", f"HTTP {r.status_code} keys={sorted(d)}",
json.dumps(d.get("pending_trade"), ensure_ascii=False)[:300])
# 11-07 **契约冻结**:自造 session_id → 404(fail-closed,非静默新建)
# 首版误以为可自造 id,本组曾全军 404;此处把该行为固化为断言。
status, frames, _ = _chat(ctx, "你好", f"{IDEM_PREFIX}-nonexistent", h)
expect_true(G, "11-07 不存在的 session_id → 404(fail-closed)", status == 404,
"HTTP 404", f"HTTP {status}",
"chat.py:132-136 _guard_session:未知会话不静默新建,避免串号")
# 11-08 顾问线回归:同一端点另一 agent_type 不得报错
status, frames, _ = _chat(ctx, "你好", None, trade_h(ctx.h["advisor"], "advisor"))
expect_true(G, "11-08 顾问线流式对话不回归(跨线回归)", status == 200,
"HTTP 200", f"HTTP {status}", _stream_text(frames)[:120])
# --------------------------------------------------------------------------
# CT12 变更清单与副作用留痕
# --------------------------------------------------------------------------
WRITE_TABLES = ("core_trade", "core_holding", "core_share_lot",
"core_convert_request", "core_convert_lot_detail")
def ct12(ctx: Ctx) -> None:
G = "CT12"
cur = {t: db_one(f"SELECT COUNT(*) FROM {t}") for t in WRITE_TABLES}
base = ctx.state.get("baseline_counts") or {}
lines = []
for t in WRITE_TABLES:
b = base.get(t)
delta = (cur[t] or 0) - (b or 0) if b is not None else None
lines.append(f"{t}: {b} → {cur[t]} (Δ{delta:+d})" if delta is not None else f"{t}: {cur[t]}")
info(G, "12-01 五张写表首尾计数差(供还原核对)", " | ".join(lines),
"还原后应回到 before 值;Δ 非零即本轮写侧副作用范围")
n_test = db_one("SELECT COUNT(*) FROM core_convert_request WHERE client_request_id LIKE %s",
params=(f"{IDEM_PREFIX}%",))
info(G, "12-02 本轮受理单可识别性", f"{n_test} 行带前缀 {IDEM_PREFIX}",
"幂等键前缀确保测试数据可识别、可清理(与 tests/conftest.py 的 TRD-TEST-% 惯例并列)")
n_trades = db_one("SELECT COUNT(*) FROM core_trade WHERE traded_at >= DATE_SUB(NOW(), INTERVAL 60 MINUTE)")
info(G, "12-03 近 60 分钟 core_trade 行数(含本轮前手工演示)", f"{n_trades} 行",
"本轮交易的 trade_id 为 TRD-<date>-<uuid8>(生产口径),不可按前缀识别 —— "
"故还原依赖 mysqldump 快照而非前缀删除")
GROUPS = {
"CT1": ct1, "CT2": ct2, "CT3": ct3, "CT4": ct4, "CT5": ct5, "CT6": ct6,
"CT7": ct7, "CT8": ct8, "CT9": ct9, "CT10": ct10, "CT11": ct11, "CT12": ct12,
}
#: 执行顺序(有依赖,勿按字母序跑):
#: CT1 契约 → CT2 闸门 → CT3 申购 → CT4 阻断 → CT5 校验 → CT6 赎回
#: → CT7 受理 → **CT8 撤单(必须早于 CT9)** → CT9 管理端点 → CT10 读侧对账
#: → CT11 对话 → CT12 变更清单
ORDER = ["CT1", "CT2", "CT3", "CT4", "CT5", "CT6", "CT7", "CT8", "CT9", "CT10", "CT11", "CT12"]
# --------------------------------------------------------------------------
# 主流程
# --------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="客户线交易端到端 API 冒烟")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--report", default=None, help="可选:把结果矩阵落成 Markdown 文件")
parser.add_argument("--only", action="append", default=None, help="只跑指定分组(如 --only CT2)")
parser.add_argument("--timeout", type=float, default=180.0, help="单请求超时(对话线较慢)")
args = parser.parse_args()
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
print(f"== 客户线交易端到端冒烟 @ {args.base_url} ==")
print(f"== 幂等键前缀 {IDEM_PREFIX} ==")
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
ctx.state["baseline_counts"] = {t: db_one(f"SELECT COUNT(*) FROM {t}") for t in WRITE_TABLES}
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:
GROUPS[name](ctx)
finally:
ctx.client.close()
counts = {s: sum(1 for r in 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 RESULTS:
if r.status == FAIL:
print(f" - {r.group} · {r.name}: 期望 {r.expected} / 实际 {r.actual}")
if args.report:
write_report(Path(args.report))
print(f"\n报告已写入 {args.report}")
else:
print("\n(未落盘;如需矩阵加 --report <path>)")
return 1 if counts[FAIL] else 0
def write_report(path: Path) -> None:
lines = ["| 分组 | 用例 | 状态 | 期望 | 实际 | 证据 |", "| --- | --- | --- | --- | --- | --- |"]
for r in RESULTS:
ev = r.evidence.replace("|", "\\|").replace("\n", " ")[:300]
act = r.actual.replace("|", "\\|").replace("\n", " ")[:200]
exp = r.expected.replace("|", "\\|").replace("\n", " ")[:200]
lines.append(f"| {r.group} | {r.name} | {r.status} | {exp} | {act} | {ev} |")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
sys.exit(main())