Files
group_fqcd_jr/tools/portal_api_check.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

627 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""门户接口体检:按前端的方式调用每个端点,核对状态、信封与字段。
## 为什么需要它
2026-09-13 做过一轮"照着接口内容把前端测一遍",靠它查出 6 个真缺陷:
- `K002`/`K003` 是**裸信封**,前端按 `payload.data` 取 ⇒ 知识库页显示"已入库 0 块 / 为空";
- 委托与成交详情页照着**建表字段**写,而接口返回视图没带那些列 ⇒ 5 行永远显示 `--`;
- 配置项/路由规则的 `PUT` 要求 `If-Match`,却没有端点能返回该 digest ⇒ **首次编辑必然 409**;
- 回复模板 `scene` 只校验长度、不校验枚举 ⇒ 非法值撞数据库 CHECK、**冒成 500**;
- 路由规则表单固定 `max_attempts=2` 且 `fallbacks` 为空 ⇒ 后端要求"重试次数不超过端点数量"、**必然 422**;
- 模型端点手填 ID ⇒ 未激活的 ID 直接 422。
**那些验证原本只存在于一次会话里** —— 下次改动没人会重跑,同样的坑会再踩一遍。
本脚本把它们固化下来。
## 与 `e2e_smoke_test.py` 的分工
| 脚本 | 回答的问题 |
|---|---|
| `e2e_smoke_test.py` | **业务链路通不通**:登录→下单→成交、风控扫描→处置闭环、客服问答 |
| `portal_api_check.py`(本脚本) | **接口契约对不对**:字段名是否与前端一致、`data` 是 list 还是 dict、错误码是 422 还是 500、参数边界 |
两者互补,都跑一遍最稳。**前置与冒烟相同**(见 `e2e_smoke_test.py` 的"前置"一节,
其中 `sync_market_prices.py` 与常驻 Worker 尤其关键)。
## 已作废用例(2026-09-20)
投顾模块整体清除后,原 `AD003` / `AD005` / `AD011` / `A047` 四条用例与 `advisor_t` 登录
**一并作废**(端点与账号都已不存在)。本脚本改为显式 `skip`,不再报假红。
**别把这几条的消失当成"少测了"** —— 它们对应的产品表面已经不在了。
## 三档(默认只跑第一档)
python tools/portal_api_check.py # 只读:GET 与只读分析,不动数据
python tools/portal_api_check.py --write # 加测写操作(会产生数据)
python tools/portal_api_check.py --dangerous # 再加**会改生效配置**的操作
⚠️ `--dangerous` 那一档包括**激活配置版本**。2026-09-13 有一次就是激活了空版本,
`config_release` 是整版本替换语义 ⇒ 所有配置项(客服/风控的工具白名单)失效 ⇒
**Agent 全线失败关闭**。跑之前请确认你知道怎么恢复(用 `tools/publish_*.py` 重新发布,
或把上一个版本的配置项复制进新版本 —— 注意 `rollbacks` **不复制配置项**,救不回来)。
## 退出码
0 = 全部通过;1 = 有 FAIL(`NOTE` 是已知问题,不影响退出码)。
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
BASE = "http://127.0.0.1:8000"
DEMO_ACCOUNTS = {
"customer": ("cust_t", "123456"),
"risk": ("risk_t", "666666"),
"operator": ("offsite_t", "offsite123"),
"admin": ("admin_t", "88888888"),
}
#: **已作废角色(2026-09-20)**:投顾模块已于 2026-09-17 整体清除,`advisor_t` 账号
#: **已不存在**(`D4.4` / `D4.5` 清除报告;`客服agent\D2.5` §2.1 亦标注"不要念它")。
#: 本脚本写于 2026-09-13,早于该清除 ⇒ 此前它会稳定报 3 条 FAIL
#: (`advisor_t` 登录 / `AD011` 已发布方案 / `A047` 投顾待审)。
#: 那 3 条不是缺陷,是**脚本过期**:对应端点已随模块删除,前端 `api-client.js`
#: 的端点表也早已不含它们(由 `tests/unit/api/test_frontend_boundaries.py` 的
#: 端点表 ↔ OpenAPI 对照守住)。这里按"作废"处理并显式 skip,避免制造假红。
PURGED_ROLES: dict[str, str] = {
"advisor": "投顾模块已整体清除(D4.4/D4.5),账号与端点均不存在",
}
RESULTS: list[tuple[str, str, str]] = []
TOKENS: dict[str, str] = {}
CONTEXT: dict[str, Any] = {}
DO_WRITE = False
DO_DANGEROUS = False
# ---------------------------------------------------------------- 基础设施
def call(
method: str,
path: str,
token: str | None = None,
body: Any = None,
*,
headers: dict[str, str] | None = None,
expect_json: bool = True,
timeout: int = 60,
) -> tuple[int, Any, dict[str, str]]:
"""发一个与前端行为一致的请求(写操作自动带 Idempotency-Key)。"""
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(BASE + path, data=data, method=method)
request.add_header("Accept", "application/json")
if data is not None:
request.add_header("Content-Type", "application/json")
if token:
request.add_header("Authorization", f"Bearer {token}")
for key, value in (headers or {}).items():
request.add_header(key, value)
if method != "GET":
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")
payload = json.loads(raw) if (expect_json and raw.strip()) else raw
return response.status, payload, dict(response.headers)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
try:
return exc.code, json.loads(raw) if raw.strip() else {}, dict(exc.headers)
except Exception:
return exc.code, {"_raw": raw[:200]}, dict(exc.headers)
except Exception as exc: # noqa: BLE001
return 0, {"error": {"code": type(exc).__name__, "message": str(exc)}}, {}
def login(username: str, password: str) -> str | None:
status, payload, _ = call(
"POST", "/api/v1/auth/tokens", body={"username": username, "password": password}
)
return (payload.get("data") or {}).get("access_token") if status == 200 else None
def record(name: str, ok: bool, detail: str) -> None:
RESULTS.append(("PASS" if ok else "FAIL", name, detail))
def note(name: str, detail: str) -> None:
"""已知问题或设计如此:既不算通过也不算失败。"""
RESULTS.append(("NOTE", name, detail))
def skip(name: str, detail: str) -> None:
RESULTS.append(("SKIP", name, detail))
# ---------------------------------------------------------------- data 形状
def unwrap(payload: Any) -> Any:
"""前端 `request()` 取的是 `payload.data`(端点标了 `raw` 的除外)。"""
return payload["data"] if isinstance(payload, dict) and "data" in payload else payload
def resolve_row(
payload: Any, *, nested: str | None = None, list_key: str | None = None
) -> dict[str, Any]:
"""定位"被渲染的那一行"。
`data` 在本平台有三种形状,前端三种都要处理:**裸 list**、**dict[list_key]**、
**dict(单对象)**。把它们区分开是本脚本的第一价值 —— 曾经就是这里判断错,
把三个正常接口报成了"缺字段"。
"""
data = unwrap(payload)
if nested:
return (data or {}).get(nested) or {}
if isinstance(data, list):
return data[0] if data else {}
if isinstance(data, dict):
if list_key:
rows = data.get(list_key)
if isinstance(rows, list):
return rows[0] if rows else {}
return data
if list_key and isinstance(payload, dict):
rows = payload.get(list_key)
if isinstance(rows, list):
return rows[0] if rows else {}
return {}
def check(
name: str,
method: str,
path: str,
role: str | None = None,
*,
expect: int | tuple[int, ...] = 200,
fields: tuple[str, ...] = (),
nested: str | None = None,
list_key: str | None = None,
) -> Any:
"""调用并核对:状态码 +(可选)前端 render 真正读取的字段。"""
token = TOKENS.get(role) if role else TOKENS.get("guest")
status, payload, _ = call(method, path, token)
expected = (expect,) if isinstance(expect, int) else expect
ok = status in expected
detail = f"HTTP={status}"
if not ok:
code = (payload.get("error") or {}).get("code") if isinstance(payload, dict) else None
detail += f" {code or ''} 期望 {expected}"
elif fields:
row = resolve_row(payload, nested=nested, list_key=list_key)
missing = [f for f in fields if f not in (row or {})]
if missing:
ok = False
detail += f" 缺字段={missing}"
else:
detail += f" 字段齐全({len(fields)})"
data = unwrap(payload)
if isinstance(data, list):
detail += f" 共{len(data)}条"
elif list_key and isinstance(data, dict) and isinstance(data.get(list_key), list):
detail += f" 共{len(data[list_key])}条"
record(name, ok, detail)
return payload
# ---------------------------------------------------------------- 只读组
def check_guest_readonly() -> None:
check("访客:公开产品 P001", "GET", "/api/v1/products", list_key="products",
fields=("product_code", "product_name", "exchange_code", "product_category",
"risk_level", "current_nav", "change_pct"))
# 列表接口的 data 是 list(前端 `state.products = data`),不是 {products: [...]}
listing = unwrap(call("GET", "/api/v1/products?limit=20")[1])
rows = listing.get("products") if isinstance(listing, dict) else listing
product_code = (rows or [{}])[0].get("product_code") or "510300"
check("访客:净值走势 P002", "GET", f"/api/v1/products/{product_code}/nav-history?days=120",
list_key="points", fields=("nav_date", "nav"))
def check_customer_readonly() -> None:
check("客户:资产看板 T001·account", "GET", "/api/v1/users/me/account/dashboard", "customer",
nested="account", fields=("available_cash",))
check("客户:资产看板 T001·summary", "GET", "/api/v1/users/me/account/dashboard", "customer",
nested="summary", fields=("total_asset", "total_market_value"))
check("客户:持仓 T006", "GET", "/api/v1/users/me/holdings", "customer", list_key="holdings",
fields=("product_code", "product_name", "total_quantity", "average_cost", "market_value"))
check("客户:委托列表 T003", "GET", "/api/v1/users/me/orders?limit=20", "customer",
fields=("order_no", "product_name", "product_code", "order_side", "quantity",
"average_executed_price", "quote_price", "status", "submitted_at"))
check("客户:成交列表 T007", "GET", "/api/v1/users/me/transactions?limit=20", "customer",
list_key="transactions",
fields=("transaction_no", "order_no", "product_name", "product_code", "order_side",
"executed_quantity", "executed_price", "gross_amount", "fee_amount",
"net_amount", "executed_at"))
check("客户:资金流水 T009", "GET", "/api/v1/users/me/cash-ledger?limit=20", "customer",
list_key="entries")
check("客户:风险测评 ONB001", "GET", "/api/v1/onboarding/risk-questionnaire", "customer")
# 详情类:需要真实单据号,从列表里取
orders = resolve_row(
call("GET", "/api/v1/users/me/orders?limit=1", TOKENS.get("customer"))[1]
)
order_no = orders.get("order_no")
if order_no:
check("客户:委托详情 T004", "GET", f"/api/v1/users/me/orders/{order_no}", "customer",
fields=("order_no", "product_name", "product_code", "order_side", "price_type",
"quantity", "filled_quantity", "quote_price", "average_executed_price",
"status", "submitted_at", "quote_at"))
else:
skip("客户:委托详情 T004", "库里没有委托,先跑一次下单或 e2e_smoke_test")
txns = unwrap(call("GET", "/api/v1/users/me/transactions?limit=1", TOKENS.get("customer"))[1])
txn_no = (txns.get("transactions") or [{}])[0].get("transaction_no") if isinstance(txns, dict) else None
if txn_no:
check("客户:成交详情 T008", "GET", f"/api/v1/users/me/transactions/{txn_no}", "customer",
fields=("transaction_no", "order_no", "product_name", "product_code", "order_side",
"executed_price", "executed_quantity", "gross_amount", "fee_amount",
"net_amount", "quote_at", "executed_at"))
else:
skip("客户:成交详情 T008", "库里没有成交记录")
def check_risk_readonly() -> None:
check("风控:概览 RK001", "GET", "/api/v1/risk/overview", "risk",
fields=("total", "pending", "levels"))
# ⚠️ limit 上限是 5(RiskAlertPageQuery),传大了会 422 —— 前端传的正是 5
check("风控:预警列表 RK002(limit=5)", "GET", "/api/v1/risk/alerts?limit=5", "risk",
fields=("alert_no", "risk_level", "customer_no", "product_name", "alert_type", "status"))
alerts = call("GET", "/api/v1/risk/alerts?limit=1", TOKENS.get("risk"))[1]
rows = unwrap(alerts) if isinstance(unwrap(alerts), list) else []
alert_no = rows[0].get("alert_no") if rows else None
if alert_no:
check("风控:预警详情 RK003", "GET", f"/api/v1/risk/alerts/{alert_no}", "risk")
# ⚠️ source 是**枚举**:customers/products/transactions/capital_flows/holdings/
# login_records/alerts/notifications —— 传别的会 422
check("风控:证据 RK004(source=customers)", "GET",
f"/api/v1/risk/evidence/customers?alert_no={alert_no}&limit=10", "risk")
else:
skip("风控:预警详情 RK003 / 证据 RK004", "库里没有预警")
check("风控:通知 RK005(limit=10)", "GET", "/api/v1/risk/notifications?limit=10", "risk")
def check_admin_readonly() -> None:
check("管理:配置发布 A002", "GET", "/api/v1/admin/config-releases?limit=20", "admin",
fields=("id", "release_no", "title", "status", "created_by", "updated_at"))
check("管理:模型端点 A012", "GET", "/api/v1/admin/model-endpoints?limit=20", "admin")
check("管理:审计 A033", "GET", "/api/v1/admin/audit-records?limit=20", "admin")
check("管理:角色列表 A035", "GET", "/api/v1/admin/roles", "admin")
check("管理:角色权限 A037", "GET", "/api/v1/admin/roles/customer/permissions", "admin")
check("管理:用户身份 A038", "GET", "/api/v1/admin/users/9001/roles", "admin")
check("管理:画像候选 A039", "GET", "/api/v1/admin/customer-profile-candidates?limit=20", "admin")
check("管理:转人工工单", "GET",
"/api/v1/admin/customer-service/handover-tickets?limit=20", "admin")
# ⚠️ K003 是**裸信封**({items, count}),这是前端最容易搞错的一处
status, payload, _ = call("GET", "/api/v1/knowledge/list?limit=50", TOKENS.get("admin"))
bare = isinstance(payload, dict) and "items" in payload and "data" not in payload
record("管理:知识库 K003(裸信封)", status == 200 and bare,
f"HTTP={status} {'顶层 items:符合裸信封' if bare else '信封形状变了,前端按裸体取会拿不到数据'}")
# 配置项/路由规则列表:顺带取一个 release_id 给写入档用
releases = unwrap(call("GET", "/api/v1/admin/config-releases?limit=20", TOKENS.get("admin"))[1])
if isinstance(releases, list) and releases:
CONTEXT["release_id"] = releases[0].get("id")
check("管理:配置项列表 A009", "GET",
f"/api/v1/admin/config-releases/{CONTEXT['release_id']}/platform-config-items", "admin")
check("管理:路由规则列表 A019", "GET",
f"/api/v1/admin/config-releases/{CONTEXT['release_id']}/model-routing-rules", "admin")
def check_operator_readonly() -> None:
check("运营:场外邮件列表", "GET", "/api/v1/offsite-fund/mails?limit=20", "operator")
check("运营:邮箱状态", "GET", "/api/v1/offsite-fund/mailbox-status", "operator")
# ---------------------------------------------------------------- 写入档
def check_write() -> None:
admin = TOKENS.get("admin")
# ---- 配置版本 + 配置项(含 If-Match 链路)----
status, payload, _ = call("POST", "/api/v1/admin/config-releases", admin, {
"release_no": f"CHECK-{uuid.uuid4().hex[:8]}", "title": "接口体检版本",
"change_summary": "由 portal_api_check.py --write 创建,用于验证配置项与路由规则的增改链路。"})
release_id = (payload.get("data") or {}).get("id")
record("写入:新建配置版本 A001", status == 201 and bool(release_id),
f"HTTP={status} id={release_id}")
if not release_id:
return
CONTEXT["check_release_id"] = release_id
# 配置项的 key 必须是「已注册 agent_type:声明的意图」,而且白名单**不能超出
# 该 Agent 的代码上限** —— `admin_service` 会拿 `AgentFactory.definition()` 校验
# `allowed_tools`,手编一个工具名必然 422「配置超出 Agent 工具上限」。
# (我第一版就写死了 `search_knowledge`,结果选中的是风控 Agent,直接被打回。)
# 正确做法:从接口声明的意图配置里取工具,保证落在上限内 —— 这同时也验证了
# 「意图配置的 allowed_tools 能被配置项接口接受」这条跨接口一致性。
raw_intents = unwrap(call("GET", "/api/v1/admin/agent-intent-configs?limit=100", admin)[1]) or []
if isinstance(raw_intents, dict):
raw_intents = (raw_intents.get("items") or raw_intents.get("intent_configs")
or raw_intents.get("records") or [])
usable = [
row for row in raw_intents
if isinstance(row, dict) and row.get("agent_type") and row.get("intent_code")
and row.get("allowed_tools")
]
if usable:
seed = usable[0]
agent = str(seed["agent_type"])
intent = str(seed["intent_code"])
tools = [str(t) for t in seed["allowed_tools"]][:1]
else:
agent, intent, tools = "customer_service", "faq", []
item_key = f"{agent}:{intent}"
if not tools:
skip("写入:新增配置项 A008", "没有声明了工具的白名单意图,无法构造合法配置项")
else:
status, payload, _ = call(
"POST", f"/api/v1/admin/config-releases/{release_id}/platform-config-items", admin,
{"namespace": "agent_tools", "item_key": item_key,
"value_json": {"allowed_tools": tools}, "schema_version": "1"})
item_id = (payload.get("data") or {}).get("id")
error = (payload.get("error") or {})
record("写入:新增配置项 A008", status == 201 and bool(item_id),
f"HTTP={status} id={item_id} key={item_key} tools={tools} "
f"{error.get('code') or ''} {error.get('message') or ''}")
if item_id:
# 更新必须带 If-Match;etag 只能从**详情端点**取(列表的 meta 没有它)
detail = call("GET",
f"/api/v1/admin/config-releases/{release_id}/platform-config-items/{item_id}",
admin)[1]
etag = ((detail.get("meta") or {}).get("etag")) if isinstance(detail, dict) else None
record("写入:配置项详情取 ETag A048", bool(etag),
f"etag={'有' if etag else '没有'}(没有就说明 PUT 链路是死的)")
if etag:
status, payload, _ = call(
"PUT",
f"/api/v1/admin/config-releases/{release_id}/platform-config-items/{item_id}",
admin,
{"namespace": "agent_tools", "item_key": item_key,
"value_json": {"allowed_tools": tools},
"schema_version": "1"},
)
# 注意:这里**故意不带 If-Match** —— 期望 409,证明乐观并发没被削弱
record("写入:不带 If-Match 应 409", status == 409, f"HTTP={status}")
# ---- 路由规则:max_attempts 不能超过端点数量 ----
endpoints = unwrap(call("GET", "/api/v1/admin/model-endpoints?limit=20", admin)[1]) or []
active = [e for e in endpoints if e.get("status") == "active"]
if active:
endpoint_id = active[0]["id"]
status, payload, _ = call(
"POST", f"/api/v1/admin/config-releases/{release_id}/model-routing-rules", admin,
{"rule_code": f"check-{uuid.uuid4().hex[:8]}", "agent_type": agent,
"task_type": "intent_classify", "model_policy": "primary_only",
"primary_endpoint_id": endpoint_id, "fallbacks": [], "max_attempts": 1,
"latency_budget_ms": 15000, "priority": 100})
record("写入:新增路由规则 A018", status == 201,
f"HTTP={status} endpoint={endpoint_id} {(payload.get('error') or {}).get('message') or ''}")
status, payload, _ = call(
"POST", f"/api/v1/admin/config-releases/{release_id}/model-routing-rules", admin,
{"rule_code": f"check-bad-{uuid.uuid4().hex[:8]}", "agent_type": agent,
"task_type": "intent_classify", "model_policy": "primary_only",
"primary_endpoint_id": endpoint_id, "fallbacks": [], "max_attempts": 2,
"latency_budget_ms": 15000, "priority": 100})
# fallbacks 为空时 max_attempts=2 必须被拒 —— 前端曾固定写 2,导致表单必然失败
record("写入:max_attempts 超端点数量应 422", status == 422, f"HTTP={status}")
else:
skip("写入:路由规则 A018", "没有已激活的模型端点")
# ---- 知识库(裸信封)----
text = "# 接口体检文档\n\n## 一、目的\n\n" + ("这用于确认上传能被切分并写入知识表。" * 12) + "\n"
status, payload, _ = call("POST", "/api/v1/knowledge/upload", admin, {
"filename": f"check-{uuid.uuid4().hex[:8]}.md",
"content_base64": base64.b64encode(text.encode()).decode(),
"knowledge_type": "faq"})
ids = (payload.get("knowledge_ids") or []) if isinstance(payload, dict) else []
record("写入:知识库上传 K002(裸信封)", status == 201 and bool(ids),
f"HTTP={status} 切分 {len(ids)} 块")
for knowledge_id in ids:
status, _, _ = call("DELETE", f"/api/v1/knowledge/{knowledge_id}", admin)
record(f"写入:知识库失效 K004(id={knowledge_id})", status == 200, f"HTTP={status}")
# ---- 回复模板:scene 是枚举,非法值必须是 422 而不是 500 ----
status, payload, _ = call("POST", "/api/v1/admin/reply-templates", admin, {
"template_code": f"ZZCHECK-{uuid.uuid4().hex[:6]}", "scene": "not_a_scene",
"title": "体检用", "content_text": "不应被创建"})
record("写入:回复模板非法 scene 应 422(非 500)", status == 422,
f"HTTP={status} ← 500 表示 schema 漏了枚举校验")
# ---- 敏感词(用绝不会被说出的词)----
status, payload, _ = call("POST", "/api/v1/admin/negative-word-rules", admin, {
"rule_code": f"ZZCHECK-{uuid.uuid4().hex[:6]}",
"word_pattern": f"ZZCHECKTOKEN{uuid.uuid4().hex[:6]}",
"match_type": "contains", "category": "体检", "severity": "warn"})
record("写入:创建敏感词规则", status == 201,
f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}")
# ---- 推广物料:完整链路(合规会拦不合规输入,这里用合规的最小输入)----
status, payload, _ = call("POST", "/api/v1/fund-promotion-materials", admin, {
"product_name": "红利低波50ETF南方", "product_code": "515450",
"material_title": f"接口体检材料 {uuid.uuid4().hex[:6]}",
"style_code": "steady_professional", "output_formats": ["pptx"]})
task_no = (payload.get("data") or {}).get("task_no")
record("写入:创建推广任务", bool(task_no), f"HTTP={status} task_no={task_no}")
if task_no:
na = "不适用"
status, payload, _ = call(
"PUT", f"/api/v1/fund-promotion-materials/{task_no}/inputs", admin, {
"product_info": {"fund_type": "股票型-指数", "operation_mode": "契约型开放式",
"product_status": "new_product",
"investment_objective": "紧密跟踪标的指数。",
"benchmark": "红利低波50指数", "risk_level": "R3"},
"manager_info": {"manager_name": "张三",
"management_company": "南方基金管理股份有限公司",
"registration_code": "F0000000000000"},
"team_info": {"team_description": "指数投资团队。"},
"strategy_info": {"investment_scope": "标的指数成份股。",
"strategy": "完全复制法。", "restrictions": "遵守合同约定。"},
"fee_structure": {"subscription_fee": na, "purchase_fee": na, "redemption_fee": na,
"sales_service_fee": na, "management_fee": "0.50%/年",
"custody_fee": "0.10%/年", "client_maintenance_fee": na},
"performance_info": {"show_product_performance": False,
"show_manager_performance": False},
"risk_disclosure": {"special_risks": ["指数跟踪偏离风险"]},
"source_notes": {}})
record("写入:更新材料输入", status == 200,
f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}")
status, payload, _ = call(
"POST", f"/api/v1/fund-promotion-materials/{task_no}/generations", admin,
{"output_formats": ["pptx"]})
version_id = (payload.get("data") or {}).get("material_version_id") or \
(payload.get("data") or {}).get("id")
record("写入:生成推介材料", status in (200, 201) and bool(version_id),
f"HTTP={status} version_id={version_id}")
status, payload, _ = call(
"GET", f"/api/v1/fund-promotion-materials/{task_no}/compliance-checks", admin)
findings = ((unwrap(payload) or {}).get("findings") or []) if isinstance(unwrap(payload), dict) else []
record("写入:合规检查", status == 200,
f"HTTP={status} findings={len(findings)}"
+ (f"({findings[0].get('rule_code')})" if findings else ""))
# ---------------------------------------------------------------- 危险档
def check_dangerous() -> None:
"""会改**生效配置**的操作。默认不跑。"""
admin = TOKENS.get("admin")
release_id = CONTEXT.get("check_release_id")
if not release_id:
skip("危险:配置发布四态", "没有可用的测试版本(先跑 --write)")
return
base = f"/api/v1/admin/config-releases/{release_id}"
def etag_headers() -> dict[str, str]:
_, _, headers = call("GET", base, admin)
value = headers.get("ETag") or headers.get("etag") or ""
return {"If-Match": value} if value else {}
# ⚠️ 这一档会把该版本**激活**。若该版本内容不全,生效配置会被替换掉,
# 客服/风控的工具白名单可能随之失效(config_release 是整版本替换语义)。
status, payload, _ = call("POST", f"{base}/validations", admin, {})
record("危险:提交校验 A004", status in (200, 422),
f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}")
if status != 200:
note("危险:后续审核/激活", "校验未通过,按设计不再继续")
return
status, payload, _ = call("POST", f"{base}/reviews", admin,
{"decision": "approved", "comment": "接口体检"}, headers=etag_headers())
record("危险:审核 A005", status == 200,
f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}")
if status != 200:
return
note("危险:激活 A006", "**故意不执行** —— 激活会替换生效配置。"
"需要时手工调,或先确认该版本已继承全部既有配置项。")
# ---------------------------------------------------------------- 主流程
def main() -> int:
global DO_WRITE, DO_DANGEROUS
parser = argparse.ArgumentParser(description="门户接口体检")
parser.add_argument("--write", action="store_true", help="加测写操作(会产生数据)")
parser.add_argument("--dangerous", action="store_true", help="再加测会改生效配置的操作")
args = parser.parse_args()
DO_WRITE = args.write or args.dangerous
DO_DANGEROUS = args.dangerous
print("=" * 100)
print("门户接口体检:按前端的方式调用端点,核对状态、信封与字段")
print(f"档位:{'危险(含写)' if DO_DANGEROUS else '写入(含只读)' if DO_WRITE else '只读'}")
print("=" * 100)
# 前置:健康检查 + 访客令牌 + 五个角色登录
status, _, _ = call("GET", "/internal/health/ready")
if status != 200:
print(f"\n[失败] API 未就绪(GET /internal/health/ready -> {status})。请先起服务。")
return 1
status, payload, _ = call("POST", "/api/v1/visitor-tokens")
# 访客令牌端点是 `raw` 形状:令牌在顶层,没有 data 信封
TOKENS["guest"] = (payload.get("data") or payload).get("access_token") if status == 201 else None
record("前置:访客令牌 V001", bool(TOKENS["guest"]), f"HTTP={status}")
for role, (username, password) in DEMO_ACCOUNTS.items():
token = login(username, password)
TOKENS[role] = token
record(f"前置:{role} 登录({username})", bool(token), "" if token else "登录失败,该角色全部用例会红")
# 作废角色:显式 skip(见 PURGED_ROLES 的说明),不计入失败
for role, reason in PURGED_ROLES.items():
skip(f"前置:{role} 登录({DEMO_ACCOUNTS.get(role, ('—',))[0]})", reason)
print()
print("--- 只读:访客 / 客户 ---")
check_guest_readonly()
check_customer_readonly()
print("--- 只读:风控 ---")
check_risk_readonly()
skip("投顾线(AD003 / AD005 / AD011 / A047)", "投顾模块已整体清除,用例随端点一并作废")
print("--- 只读:管理员 / 运营 ---")
check_admin_readonly()
check_operator_readonly()
if DO_WRITE:
print("--- 写入档(会产生数据)---")
check_write()
else:
skip("写入档", "未加 --write,跳过")
if DO_DANGEROUS:
print("--- 危险档(会改生效配置)---")
check_dangerous()
else:
skip("危险档", "未加 --dangerous,跳过")
# 汇总
print()
print("=" * 100)
for verdict, name, detail in RESULTS:
print(f"{verdict:<6}{name:<40}{detail}")
print("=" * 100)
failed = [r for r in RESULTS if r[0] == "FAIL"]
passed = [r for r in RESULTS if r[0] == "PASS"]
noted = [r for r in RESULTS if r[0] == "NOTE"]
skipped = [r for r in RESULTS if r[0] == "SKIP"]
print(f"合计 {len(RESULTS)} 项:通过 {len(passed)},失败 {len(failed)},"
f"已知问题 {len(noted)},跳过 {len(skipped)}")
if failed:
print("\n失败项:")
for _, name, detail in failed:
print(f" · {name} {detail}")
print("\n提示:字段缺失通常是「前端按建表字段写、接口返回视图没带」;")
print(" 500 通常是「表上有 CHECK 而 schema 只做长度校验」;")
print(" 422 通常是「前端传的参数超出接口约束(如 limit 上限)」。")
return 1
print("\n全部通过。")
if DO_WRITE:
print("\n注意:写入档会留下测试数据(配置版本、敏感词等**没有删除端点**)。"
"\n 命名前缀 CHECK-/ZZCHECK- 便于识别;知识库条目已在脚本内自动失效。")
return 0
if __name__ == "__main__":
sys.exit(main())