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.
2233 lines
112 KiB
Python
2233 lines
112 KiB
Python
"""客户线交易端到端 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())
|