## 问题 `svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了 (pending → assigned → processing → resolved → closed,未解决可 cancelled), 但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to / accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**, 于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。 ## 改了什么 后端: - 新增 `app/service/customer_service_handover_action_service.py`:五个动作 (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态; 接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态); 每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled); 非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。 - 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"), 但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。 - `app/api/controllers/admin.py`:五个 action 端点 A049–A053 (assignments / acceptances / resolutions / closures / cancellations), 走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。 - 权限:新增 `handover:write`(9069,只授 admin),已并进种子 `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。 前端(管理员工作台 · 转人工工单页): - 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、 未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。 - api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版, 避免浏览器拿旧缓存(旧缓存里没有这些端点)。 冒烟与测试: - `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾 —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。 总数 40 → 44 项,实测 44/44 全绿。 - 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识) 与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。 - 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。 ## 验证 - `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed - 新增集成用例通过;`tests/integration` 全量跑时 `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过, 是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker) - `tools/portal_api_check.py` → 41 项通过 39、失败 0 - `tools/e2e_smoke_test.py` → 44/44 全通过 - `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限) - 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空; 同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403 ## 文档 `docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与 A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、 `AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
474 lines
20 KiB
Python
474 lines
20 KiB
Python
"""平台端到端冒烟:按角色分线跑一遍真实请求,报告哪一环坏了。
|
||
|
||
## 为什么要有它
|
||
|
||
`docs/40` 的验收清单是**人工步骤 + 预期值**;本脚本把这些步骤里"能用接口判定"的部分
|
||
自动化,用于**部署后、演示前**快速确认底座是活的。它不做断言式测试(那是 pytest 的活),
|
||
而是**打真实 HTTP、打印每条的结果**,让人一眼看到哪条线断了。
|
||
|
||
## 覆盖范围(6 条线)
|
||
|
||
A 访客 令牌 → 提问 → 收到回答
|
||
B 客户 登录 → 看板/持仓/流水 → **真实下单成交** → 会话与转人工
|
||
C 风控 概览/队列/详情/**八类证据**/通知/扫描/日报 → **处置闭环**(确认→调查→结案)
|
||
D 投顾 已发布方案
|
||
E 运营 场外邮件与邮箱状态
|
||
F 管理员 角色/权限/身份/配置发布/模型端点/审计/转人工工单/画像候选
|
||
|
||
## 前置(缺一项就有整条线是红的)
|
||
|
||
python tools/seed_test_rbac.py # 账号与权限
|
||
python tools/set_user_password.py # 演示口令(非幂等)
|
||
python -m tools.seed_sim_account_demo # 虚拟资金账户
|
||
python tools/publish_risk_agent_config.py # 风控工具白名单
|
||
python tools/sync_market_prices.py # **下单前置**:没有行情会全线 503
|
||
python -m app.worker # **客服对话前置**:没有它 run 永远 queued
|
||
|
||
## 用法
|
||
|
||
python tools/e2e_smoke_test.py # 全跑(含写操作:下单、风控处置)
|
||
python tools/e2e_smoke_test.py --read-only # 跳过所有写操作,只看读链路
|
||
|
||
写操作都会带唯一 `Idempotency-Key`;风控处置会在需要时**自动造一条待处理预警**
|
||
(否则该环节只能跳过 —— 库里的预警常常都已被处置过)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
if str(PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
||
from sqlalchemy import func, select # noqa: E402
|
||
|
||
from app.infrastructure.db import SessionFactory # noqa: E402
|
||
from app.model.fund import FundRiskAlert # noqa: E402
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||
|
||
BASE = "http://127.0.0.1:8000"
|
||
DEMO_CUSTOMER = ("cust_t", "123456")
|
||
DEMO_RISK = ("risk_t", "666666")
|
||
DEMO_ADVISOR = ("advisor_t", "abc12345")
|
||
DEMO_OPERATOR = ("offsite_t", "offsite123")
|
||
DEMO_ADMIN = ("admin_t", "88888888")
|
||
|
||
RESULTS: list[tuple[str, bool, str]] = []
|
||
#: 跨段传递的小状态(目前只有 B 段建的工单号,交给 F 段处置收尾)。
|
||
CONTEXT: dict[str, str] = {}
|
||
READ_ONLY = False
|
||
|
||
|
||
def call(
|
||
method: str,
|
||
path: str,
|
||
token: str | None = None,
|
||
body: dict[str, Any] | None = None,
|
||
*,
|
||
idem: bool = False,
|
||
timeout: int = 90,
|
||
) -> tuple[int, dict[str, Any]]:
|
||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||
request = urllib.request.Request(BASE + path, data=data, method=method)
|
||
if data is not None:
|
||
request.add_header("Content-Type", "application/json")
|
||
if token:
|
||
request.add_header("Authorization", "Bearer " + token)
|
||
if idem:
|
||
request.add_header("Idempotency-Key", uuid.uuid4().hex)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||
raw = response.read().decode("utf-8", errors="replace")
|
||
return response.status, (json.loads(raw) if raw else {})
|
||
except urllib.error.HTTPError as exc:
|
||
raw = exc.read().decode("utf-8", errors="replace")
|
||
try:
|
||
return exc.code, json.loads(raw)
|
||
except ValueError:
|
||
return exc.code, {"_raw": raw[:250]}
|
||
except Exception as exc: # noqa: BLE001 - 网络层失败也要报出来,而不是崩掉整轮
|
||
return 0, {"_err": repr(exc)}
|
||
|
||
|
||
def check(label: str, ok: bool, detail: str = "") -> bool:
|
||
RESULTS.append((label, ok, detail))
|
||
print(f"{'PASS' if ok else 'FAIL'} {label}" + (f" —— {detail}" if detail else ""))
|
||
return ok
|
||
|
||
|
||
def info(label: str, detail: str) -> None:
|
||
print(f"INFO {label} —— {detail}")
|
||
|
||
|
||
def section(title: str) -> None:
|
||
print()
|
||
print("=" * 96)
|
||
print(title)
|
||
print("=" * 96)
|
||
|
||
|
||
def body_of(payload: dict[str, Any]) -> dict[str, Any]:
|
||
data = payload.get("data")
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
|
||
def listed(payload: dict[str, Any], *keys: str) -> list[Any]:
|
||
"""从响应里取列表。
|
||
|
||
⚠️ 本平台各端点的 `data` 形状**不统一**:角色列表是裸数组、持仓在 `holdings`、
|
||
成交明细在 `transactions`、**资金流水在 `entries`**。所以这里按候选键依次尝试 ——
|
||
写联调脚本时别猜字段名,会连续猜错(实测踩过三次)。
|
||
"""
|
||
data = payload.get("data")
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict):
|
||
for key in keys:
|
||
value = data.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
for key in keys:
|
||
value = payload.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
return []
|
||
|
||
|
||
def ok_code(payload: dict[str, Any]) -> bool:
|
||
return payload.get("code") in (0, None)
|
||
|
||
|
||
def login(credentials: tuple[str, str]) -> str | None:
|
||
status, payload = call(
|
||
"POST", "/api/v1/auth/tokens",
|
||
body={"username": credentials[0], "password": credentials[1]},
|
||
)
|
||
return body_of(payload).get("access_token") if status == 200 else None
|
||
|
||
|
||
def create_pending_alert() -> str | None:
|
||
"""造一条「待处理」预警,供处置闭环验证(库里的常常都已被处置过)。"""
|
||
|
||
async def run() -> str:
|
||
# 前缀**刻意避开 `ALDEMO`**:那是 `seed_risk_alert_demo_data.py` 的演示样本编号
|
||
# (ALDEMO0001-0003)。撞号过一次 —— 本脚本"已存在则跳过"的检查会把创建挡掉,
|
||
# 随后处置全 409,看起来像"结案没写 closed_at",其实是操作了别人的样本。
|
||
alert_no = f"E2E{datetime.now(UTC).strftime('%H%M%S')}"
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
async with SessionFactory() as session:
|
||
async with session.begin():
|
||
max_id = await session.scalar(
|
||
select(func.coalesce(func.max(FundRiskAlert.id), 0))
|
||
)
|
||
session.add(FundRiskAlert(
|
||
id=int(max_id or 0) + 1, alert_no=alert_no, customer_id=9001,
|
||
alert_type="频繁交易", alert_level="高",
|
||
trigger_rule_codes=["RW-DEMO-01"],
|
||
evidence_summary="端到端冒烟脚本创建的演示预警,可安全处置。",
|
||
evidence_snapshot={"demo": True},
|
||
priority_score=90, event_status="待处理", status="待处理",
|
||
ack_status="未确认", is_escalated=0,
|
||
due_at=now + timedelta(hours=24), created_at=now, updated_at=now,
|
||
))
|
||
return alert_no
|
||
|
||
try:
|
||
import asyncio
|
||
|
||
return asyncio.run(run())
|
||
except Exception as exc: # noqa: BLE001
|
||
info("造预警失败", f"{type(exc).__name__}: {exc}")
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- A 访客
|
||
|
||
def section_guest() -> None:
|
||
section("A. 访客线")
|
||
status, payload = call("POST", "/api/v1/visitor-tokens")
|
||
data = payload if isinstance(payload.get("access_token"), str) else payload.get("data")
|
||
token = (data or {}).get("access_token") if isinstance(data, dict) else None
|
||
check("A1 访客令牌 V001", bool(token), f"HTTP={status}")
|
||
if not token:
|
||
return
|
||
started = time.perf_counter()
|
||
status, accepted = call("POST", "/api/v1/agent-runs", token, {
|
||
"agent_type": "customer_service", "session_id": str(uuid.uuid4()),
|
||
"message": "场内基金的管理费率是多少", "idempotency_key": uuid.uuid4().hex,
|
||
})
|
||
run_id = body_of(accepted).get("run_id")
|
||
check("A2 提问受理 R001", status == 202 and bool(run_id), f"HTTP={status}")
|
||
if not run_id:
|
||
return
|
||
state, snapshot = None, {}
|
||
for attempt in range(40):
|
||
time.sleep(0.5 if attempt else 0.3)
|
||
_, snapshot = call("GET", f"/api/v1/agent-runs/{run_id}", token)
|
||
state = body_of(snapshot).get("status")
|
||
if state in {"succeeded", "failed", "cancelled"}:
|
||
break
|
||
elapsed = time.perf_counter() - started
|
||
check("A3 问答完成", state == "succeeded", f"status={state} 用时 {elapsed:.2f}s")
|
||
if state != "succeeded":
|
||
info("A3 提示", "若 status=queued:Agent Worker 没在跑(python -m app.worker)")
|
||
return
|
||
result = body_of(snapshot).get("result") or {}
|
||
check("A4 知识库覆盖(未转人工)", result.get("transfer_required") is False,
|
||
f"transfer_required={result.get('transfer_required')}")
|
||
|
||
|
||
# ---------------------------------------------------------------- B 客户
|
||
|
||
def section_customer() -> None:
|
||
section("B. 客户线")
|
||
token = login(DEMO_CUSTOMER)
|
||
check("B1 客户登录 A034", bool(token))
|
||
if not token:
|
||
return
|
||
|
||
status, payload = call("GET", "/api/v1/onboarding/risk-questionnaire", token)
|
||
check("B2 风险测评问卷 ONB001", status == 200 and ok_code(payload), f"HTTP={status}")
|
||
|
||
status, payload = call("GET", "/api/v1/users/me/account/dashboard", token)
|
||
data = body_of(payload)
|
||
account, summary = data.get("account") or {}, data.get("summary") or {}
|
||
check("B3 账户看板 T001", status == 200 and bool(account), f"HTTP={status}")
|
||
if account:
|
||
info("B3 账户", json.dumps({
|
||
"可用资金": account.get("available_cash"), "总资产": summary.get("total_asset"),
|
||
"持仓市值": summary.get("total_market_value")}, ensure_ascii=False))
|
||
|
||
status, payload = call("GET", "/api/v1/users/me/holdings", token)
|
||
holdings = listed(payload, "holdings")
|
||
check("B4 持仓 T006", status == 200 and ok_code(payload), f"条数={len(holdings)}")
|
||
|
||
status, payload = call("GET", "/api/v1/users/me/cash-ledger", token)
|
||
ledger = listed(payload, "entries", "cash_ledger")
|
||
check("B5 资金流水 T009", status == 200 and ok_code(payload), f"条数={len(ledger)}")
|
||
|
||
print()
|
||
print("--- 下单链路 ---")
|
||
if READ_ONLY:
|
||
info("B6 下单", "跳过(--read-only)")
|
||
else:
|
||
status, payload = call("POST", "/api/v1/users/me/orders", token,
|
||
{"product_code": "510300", "order_side": "buy", "quantity": 100},
|
||
idem=True)
|
||
order = body_of(payload)
|
||
check("B6 买入下单 T002", status in (200, 201) and ok_code(payload),
|
||
f"HTTP={status} {payload.get('message') or ''}")
|
||
if not order:
|
||
info("B6 提示", "503 通常是行情过期:跑 python tools/sync_market_prices.py")
|
||
else:
|
||
info("B6 成交", json.dumps({
|
||
"委托号": order.get("order_no"), "状态": order.get("status"),
|
||
"成交价": order.get("executed_price"), "金额": order.get("gross_amount")},
|
||
ensure_ascii=False))
|
||
status, _ = call("GET", f"/api/v1/users/me/orders/{order.get('order_no')}", token)
|
||
check("B7 委托详情 T004", status == 200, f"HTTP={status}")
|
||
|
||
status, payload = call("GET", "/api/v1/users/me/transactions", token)
|
||
check("B8 成交明细 T007", status == 200 and ok_code(payload),
|
||
f"条数={len(listed(payload, 'transactions'))}")
|
||
|
||
print()
|
||
print("--- 客服会话与转人工 ---")
|
||
status, payload = call("POST", "/api/v1/conversations", token,
|
||
{"agent_type": "customer_service"}, idem=True)
|
||
session_id = body_of(payload).get("session_id")
|
||
check("B9 创建会话 C001", status in (200, 201) and bool(session_id),
|
||
f"HTTP={status} session_id={session_id}")
|
||
if not session_id:
|
||
return
|
||
status, _ = call("GET", f"/api/v1/conversations/{session_id}", token)
|
||
check("B10 会话详情 C002", status == 200, f"HTTP={status}")
|
||
status, _ = call("GET", f"/api/v1/conversations/{session_id}/messages", token)
|
||
check("B11 会话消息 C003", status == 200, f"HTTP={status}")
|
||
if READ_ONLY:
|
||
info("B12 转人工", "跳过(--read-only)")
|
||
else:
|
||
status, payload = call(
|
||
"POST", f"/api/v1/conversations/{session_id}/handover-requests", token,
|
||
{"reason_code": "user_requested", "reason_detail": "端到端冒烟:客户主动请求人工"},
|
||
idem=True)
|
||
check("B12 转人工 C005", status in (200, 201, 202) and ok_code(payload),
|
||
f"HTTP={status}")
|
||
# 记下这次建的工单,交给 F 段(那里已经拿着管理员令牌)走完整处置闭环。
|
||
#
|
||
# 为什么必须收尾:本脚本每跑一次就建一张 `pending` 工单,此前平台**只有只读队列、
|
||
# 没有任何入口能推进它**,于是演示库里攒了 30+ 张一模一样的"端到端冒烟"测试件,
|
||
# 管理员打开转人工工单页看到全是它。现在有了处置端点(A049-A053),F11 会把这张
|
||
# 单子走完 分配→接单→解决→关闭:既清掉测试件,也让每次冒烟都覆盖一遍状态机。
|
||
CONTEXT["handover_id"] = str(body_of(payload).get("handover_id") or "")
|
||
info("B12b 待 F11 处置", CONTEXT["handover_id"] or "(未拿到 handover_id)")
|
||
|
||
|
||
# ---------------------------------------------------------------- C 风控
|
||
|
||
def section_risk() -> None:
|
||
section("C. 风控线")
|
||
token = login(DEMO_RISK)
|
||
check("C1 风控登录", bool(token))
|
||
if not token:
|
||
return
|
||
|
||
status, payload = call("GET", "/api/v1/risk/overview", token)
|
||
overview = body_of(payload)
|
||
check("C2 风险概览 RK001", status == 200 and ok_code(payload),
|
||
f"total={overview.get('total')} pending={overview.get('pending')} "
|
||
f"levels={overview.get('levels')}")
|
||
|
||
status, payload = call("GET", "/api/v1/risk/alerts", token)
|
||
alerts = listed(payload, "items", "alerts")
|
||
check("C3 预警队列 RK002", status == 200 and ok_code(payload), f"条数={len(alerts)}")
|
||
|
||
sources = ("customers", "products", "transactions", "capital_flows",
|
||
"holdings", "login_records", "alerts", "notifications")
|
||
passed = sum(
|
||
1 for source in sources
|
||
if ok_code(call("GET", f"/api/v1/risk/evidence/{source}", token)[1])
|
||
)
|
||
check("C4 八类证据 RK004", passed == len(sources), f"{passed}/{len(sources)} 通过")
|
||
|
||
status, payload = call("GET", "/api/v1/risk/notifications", token)
|
||
check("C5 通知记录 RK005", status == 200 and ok_code(payload), f"HTTP={status}")
|
||
|
||
if READ_ONLY:
|
||
info("C6 手动扫描 RK006", "跳过(--read-only)")
|
||
info("C7 处置闭环", "跳过(--read-only)")
|
||
return
|
||
|
||
status, payload = call("POST", "/api/v1/risk/alerts/scan", token, {}, idem=True)
|
||
check("C6 手动扫描 RK006", status == 200 and ok_code(payload),
|
||
f"新建 {body_of(payload).get('created_count')} 条")
|
||
|
||
print()
|
||
print("--- 风控处置闭环 ---")
|
||
target = next(
|
||
(item.get("alert_no") for item in alerts if item.get("status") == "待处理"), None
|
||
)
|
||
if target is None:
|
||
target = create_pending_alert()
|
||
info("造预警", f"{target}(库里没有待处理预警,现造一条)")
|
||
if target is None:
|
||
info("C7 处置闭环", "无法获得待处理预警,跳过")
|
||
return
|
||
for label, path, body in (
|
||
("C7 确认接收 RK007", f"/api/v1/risk/alerts/{target}/acknowledgements", {}),
|
||
("C8 进入调查 RK008", f"/api/v1/risk/alerts/{target}/investigations", {}),
|
||
("C9 完成结案 RK010", f"/api/v1/risk/alerts/{target}/resolutions",
|
||
{"resolution": "端到端冒烟:已核实为演示数据并完成处置"}),
|
||
):
|
||
status, payload = call("POST", path, token, body, idem=True)
|
||
check(label, status == 200 and ok_code(payload),
|
||
f"{target} HTTP={status} {payload.get('message') or ''}")
|
||
|
||
|
||
# ---------------------------------------------------------------- D/E/F
|
||
|
||
def section_advisor_operator_admin() -> None:
|
||
section("D. 投顾线")
|
||
advisor = login(DEMO_ADVISOR)
|
||
check("D1 投顾登录", bool(advisor))
|
||
if advisor:
|
||
status, payload = call("GET", "/api/v1/advisor/recommendations/published", advisor)
|
||
check("D2 已发布方案", status == 200 and ok_code(payload),
|
||
f"条数={len(listed(payload))}")
|
||
|
||
section("E. 运营线")
|
||
operator = login(DEMO_OPERATOR)
|
||
check("E1 运营登录", bool(operator))
|
||
if operator:
|
||
status, payload = call("GET", "/api/v1/offsite-fund/mails?page=1&page_size=8", operator)
|
||
check("E2 场外邮件", status == 200 and ok_code(payload),
|
||
f"total={body_of(payload).get('total')}")
|
||
status, payload = call("GET", "/api/v1/offsite-fund/mailbox-status", operator)
|
||
check("E3 邮箱状态", status == 200 and ok_code(payload),
|
||
f"status={body_of(payload).get('status')}")
|
||
|
||
section("F. 管理员线")
|
||
admin = login(DEMO_ADMIN)
|
||
check("F1 管理员登录", bool(admin))
|
||
if not admin:
|
||
return
|
||
for label, path in (
|
||
("F2 角色列表 A035", "/api/v1/admin/roles"),
|
||
("F3 角色详情 A036", "/api/v1/admin/roles/customer"),
|
||
("F4 角色权限 A037", "/api/v1/admin/roles/customer/permissions"),
|
||
("F5 用户身份 A038", "/api/v1/admin/users/9001/roles"),
|
||
("F6 配置发布 A002", "/api/v1/admin/config-releases"),
|
||
("F7 模型端点 A012", "/api/v1/admin/model-endpoints"),
|
||
("F8 审计记录 A033", "/api/v1/admin/audit-records"),
|
||
("F9 转人工工单", "/api/v1/admin/customer-service/handover-tickets"),
|
||
("F10 画像候选 A039", "/api/v1/admin/customer-profile-candidates"),
|
||
):
|
||
status, payload = call("GET", path, admin)
|
||
data = payload.get("data")
|
||
shape = (f"list[{len(data)}]" if isinstance(data, list)
|
||
else f"dict[{len(data)}]" if isinstance(data, dict) else "?")
|
||
check(label, status == 200 and ok_code(payload), f"HTTP={status} {shape}")
|
||
|
||
# F11 用 B 段那张测试工单走完整处置闭环:分配 → 接单 → 解决 → 关闭。
|
||
#
|
||
# 这一步有两个作用:① 收尾,不把冒烟测试件留在 pending 队列里;
|
||
# ② 每次冒烟都真机覆盖一遍工单状态机(`docs/02` §7.2)与它的权限、审计。
|
||
ticket_no = CONTEXT.get("handover_id", "")
|
||
if READ_ONLY:
|
||
info("F11 工单处置闭环", "跳过(--read-only)")
|
||
return
|
||
if not ticket_no:
|
||
info("F11 工单处置闭环", "跳过(B12 没拿到 handover_id)")
|
||
return
|
||
steps = (
|
||
("A049 分配", "assignments", {"assignee_id": 9003}, "assigned"),
|
||
("A050 接单", "acceptances", {}, "processing"),
|
||
("A051 解决", "resolutions", {"resolution": "端到端冒烟:已回访并给出结论"}, "resolved"),
|
||
("A052 关闭", "closures", {"note": "冒烟收尾"}, "closed"),
|
||
)
|
||
base = f"/api/v1/admin/customer-service/handover-tickets/{ticket_no}"
|
||
for label, action, body, expected in steps:
|
||
status, payload = call("POST", f"{base}/{action}", admin, body, idem=True)
|
||
actual = body_of(payload).get("status")
|
||
check(f"F11 {label}", status == 200 and actual == expected,
|
||
f"HTTP={status} status={actual}(期望 {expected})")
|
||
|
||
|
||
def main() -> int:
|
||
global READ_ONLY # noqa: PLW0603 - 单进程脚本,命令行开关就设一次
|
||
parser = argparse.ArgumentParser(description="平台端到端冒烟")
|
||
parser.add_argument("--read-only", action="store_true",
|
||
help="跳过所有写操作(下单、转人工、风控扫描与处置)")
|
||
args = parser.parse_args()
|
||
READ_ONLY = args.read_only
|
||
|
||
print(f"目标 {BASE}" + ("(只读模式)" if READ_ONLY else "(含写操作)"))
|
||
section_guest()
|
||
section_customer()
|
||
section_risk()
|
||
section_advisor_operator_admin()
|
||
|
||
passed = sum(1 for _, ok, _ in RESULTS if ok)
|
||
section(f"汇总:{passed}/{len(RESULTS)} 通过")
|
||
for label, ok, detail in RESULTS:
|
||
if not ok:
|
||
print(f" FAIL {label} {detail}")
|
||
if passed == len(RESULTS):
|
||
print("全部通过。")
|
||
return 0 if passed == len(RESULTS) else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|