一、客服 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 生成的本地产物
627 lines
32 KiB
Python
627 lines
32 KiB
Python
"""门户接口体检:按前端的方式调用每个端点,核对状态、信封与字段。
|
||
|
||
## 为什么需要它
|
||
|
||
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())
|