2026-09-12 14:57:17 +08:00
|
|
|
|
"""平台功能测试台:一个能点到**所有**接口的简易前端。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么是这个形态**
|
|
|
|
|
|
|
|
|
|
|
|
- **端点清单不手写**:从 `docs/05-接口文档.md` §19「接口总目录」解析(那是接口的唯一权威),
|
|
|
|
|
|
再用平台的 `app.openapi()` 补上真实的参数与请求体 schema。手抄一份清单必然漂移;
|
|
|
|
|
|
顺带还能反向查出「OpenAPI 里有、§19 没登记」的端点。
|
|
|
|
|
|
- **进程内直挂平台 ASGI**:默认不要求你另起 `uvicorn app.main`。控制台自己
|
|
|
|
|
|
`create_app()` 后把请求打进去,**走的是真实的中间件与鉴权栈**(不是绕过鉴权的直调服务层),
|
|
|
|
|
|
所以 401/403/幂等/审计这些都测得到。也可以用 `--base-url` 指向已经跑着的服务。
|
|
|
|
|
|
- **令牌不出本进程**:浏览器只跟本控制台说话,登录由本进程完成、令牌存服务端,
|
|
|
|
|
|
与 `tools/chat_console.py` 同一个安全口径。不新增任何后门路由。
|
|
|
|
|
|
- **只读冒烟**:一键把全部只读端点跑一遍,**只把 5xx 判为缺陷** ——
|
|
|
|
|
|
403(权限不足)与 400/404/409(缺参数、缺数据、状态不符)都是**有效响应**,
|
|
|
|
|
|
在金融系统里"失败关闭"是正确行为,不该被算成失败。
|
|
|
|
|
|
|
|
|
|
|
|
跑法:
|
|
|
|
|
|
|
|
|
|
|
|
```powershell
|
|
|
|
|
|
D:\\conda\\envs\\jr_py313\\python.exe tools\\api_console.py
|
|
|
|
|
|
# 浏览器打开 http://127.0.0.1:8100
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**前置**(不满足会在页面上直接显示原因,不会静默):
|
|
|
|
|
|
|
|
|
|
|
|
1. 需要 MySQL / Redis 可用 —— 平台启动时会连它们;
|
|
|
|
|
|
2. 演示账号要能登录:先 `python tools/seed_test_rbac.py`,
|
|
|
|
|
|
再 `python tools/set_user_password.py`(后者**非幂等**,重复执行等于重设密码);
|
|
|
|
|
|
3. 涉及 Agent 的接口**先停掉常驻 Worker**(`python -m app.worker`),否则它会抢走 run。
|
|
|
|
|
|
|
|
|
|
|
|
**只测不改**:页面把写操作标红并要求二次确认;顶部始终显示当前连接的环境
|
|
|
|
|
|
(DSN 的 host/db),避免误在错误的库上写入。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import time
|
|
|
|
|
|
from collections import Counter
|
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
|
|
|
|
|
|
|
|
sys.stdout.reconfigure(errors="replace")
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
INTERFACE_DOC = ROOT / "docs" / "05-接口文档.md"
|
|
|
|
|
|
|
|
|
|
|
|
#: 演示账号(`sys_user.username`,**不是**用户 id)。密码由 tools/set_user_password.py 设置。
|
|
|
|
|
|
DEMO_ACCOUNTS: tuple[tuple[str, str, str], ...] = (
|
|
|
|
|
|
("cust_t", "123456", "客户"),
|
|
|
|
|
|
("risk_t", "666666", "风控专员"),
|
|
|
|
|
|
("admin_t", "88888888", "平台管理员"),
|
|
|
|
|
|
("advisor_t", "abc12345", "投资顾问"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
#: 业务模块分组:按路径前缀匹配,先匹配到的先用。
|
|
|
|
|
|
GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
|
|
|
|
("认证与身份", ("/api/v1/auth", "/api/v1/onboarding", "/api/v1/users")),
|
|
|
|
|
|
("客服与对话", ("/api/v1/agent", "/api/v1/conversations", "/api/v1/conversation-messages")),
|
|
|
|
|
|
("知识库与 RAG", ("/api/v1/knowledge",)),
|
|
|
|
|
|
("记忆与画像", ("/api/v1/memory", "/api/v1/customers")),
|
|
|
|
|
|
("风控", ("/api/v1/risk",)),
|
|
|
|
|
|
("投顾", ("/api/v1/advisor",)),
|
|
|
|
|
|
("场外基金", ("/api/v1/offsite",)),
|
|
|
|
|
|
("推广材料", ("/api/v1/promotion",)),
|
|
|
|
|
|
("管理面", ("/api/v1/admin",)),
|
2026-09-20 14:33:30 +08:00
|
|
|
|
("平台与运维", ("/api/v1/health", "/internal")),
|
2026-09-12 14:57:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
ROW = re.compile(r"^\|\s*([A-Z]{1,4}\d{3,4})\s*\|(.+)\|\s*$")
|
|
|
|
|
|
METHOD_PATH = re.compile(r"(GET|POST|PUT|PATCH|DELETE)\s+(/\S+)")
|
|
|
|
|
|
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
|
|
|
|
#: 这些路径即便用 POST 也只是"查询/生成",不写业务数据。
|
|
|
|
|
|
BENIGN_WRITE = ("/query", "/search", "/classify", "/preview", "/evaluate", "/simulate", "/compare")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_section19(path: Path = INTERFACE_DOC) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""解析 §19 表格 → 端点清单(编号/方法/路径/权限/幂等/状态/说明)。"""
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
raise SystemExit(f"缺少接口权威文档:{path}")
|
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
|
lines = text.splitlines()
|
|
|
|
|
|
|
|
|
|
|
|
start = next((i for i, line in enumerate(lines) if re.match(r"^##\s*19\.", line)), None)
|
|
|
|
|
|
if start is None:
|
|
|
|
|
|
raise SystemExit("docs/05 里找不到 §19 章节")
|
|
|
|
|
|
end = next(
|
|
|
|
|
|
(i for i in range(start + 1, len(lines)) if re.match(r"^##\s*20\.", lines[i])),
|
|
|
|
|
|
len(lines),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
endpoints: list[dict[str, Any]] = []
|
|
|
|
|
|
for line in lines[start + 1 : end]:
|
|
|
|
|
|
match = ROW.match(line.strip())
|
|
|
|
|
|
if match is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
code = match.group(1)
|
|
|
|
|
|
cells = [cell.strip() for cell in match.group(2).split("|")]
|
|
|
|
|
|
if len(cells) < 5:
|
|
|
|
|
|
continue
|
|
|
|
|
|
found = METHOD_PATH.search(cells[0])
|
|
|
|
|
|
if found is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
endpoints.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"code": code,
|
|
|
|
|
|
"method": found.group(1),
|
|
|
|
|
|
"path": found.group(2).strip("`"),
|
|
|
|
|
|
"permission": cells[1].strip("`") or "—",
|
|
|
|
|
|
"idempotent": cells[2],
|
|
|
|
|
|
"status": cells[3].strip("`"),
|
|
|
|
|
|
"audit": cells[4] if len(cells) > 4 else "—",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return endpoints
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def openapi_index(spec: dict[str, Any] | None) -> dict[tuple[str, str], dict[str, Any]]:
|
|
|
|
|
|
"""`(method, 归一化路径) → 参数与请求体 schema`。"""
|
|
|
|
|
|
if not spec:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
schemas = ((spec.get("components") or {}).get("schemas")) or {}
|
|
|
|
|
|
|
|
|
|
|
|
def deref(node: Any, depth: int = 0) -> Any:
|
|
|
|
|
|
"""展开 `$ref`。
|
|
|
|
|
|
|
|
|
|
|
|
FastAPI 生成的 `requestBody.schema` 通常是指向 `components.schemas` 的**引用**,
|
|
|
|
|
|
不展开的话前端拿到的请求体模板会是空的 —— 用户就得自己猜字段名。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if depth > 6 or not isinstance(node, dict):
|
|
|
|
|
|
return node
|
|
|
|
|
|
ref = node.get("$ref")
|
|
|
|
|
|
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
|
|
|
|
|
|
target = schemas.get(ref.rsplit("/", 1)[-1])
|
|
|
|
|
|
if isinstance(target, dict):
|
|
|
|
|
|
merged = deref(target, depth + 1)
|
|
|
|
|
|
extra = {key: value for key, value in node.items() if key != "$ref"}
|
|
|
|
|
|
return {**merged, **extra} if extra else merged
|
|
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
index: dict[tuple[str, str], dict[str, Any]] = {}
|
|
|
|
|
|
for raw_path, operations in (spec.get("paths") or {}).items():
|
|
|
|
|
|
normalised = re.sub(r"\{[^}]+\}", "{}", raw_path)
|
|
|
|
|
|
for method, operation in (operations or {}).items():
|
|
|
|
|
|
if method.lower() not in {"get", "post", "put", "patch", "delete"}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
params = []
|
|
|
|
|
|
for param in operation.get("parameters") or []:
|
|
|
|
|
|
schema = param.get("schema") or {}
|
|
|
|
|
|
params.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": param.get("name"),
|
|
|
|
|
|
"in": param.get("in"),
|
|
|
|
|
|
"required": bool(param.get("required")),
|
|
|
|
|
|
"example": schema.get("example", schema.get("default")),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
body_schema = None
|
|
|
|
|
|
content = (operation.get("requestBody") or {}).get("content") or {}
|
|
|
|
|
|
if "application/json" in content:
|
|
|
|
|
|
body_schema = deref(content["application/json"].get("schema"))
|
|
|
|
|
|
index[(method.upper(), normalised)] = {
|
|
|
|
|
|
"parameters": params,
|
|
|
|
|
|
"body_schema": body_schema,
|
|
|
|
|
|
"summary": operation.get("summary") or "",
|
|
|
|
|
|
"raw_path": raw_path,
|
|
|
|
|
|
}
|
|
|
|
|
|
return index
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def endpoint_key(method: str, path: str) -> tuple[str, str]:
|
|
|
|
|
|
return (method.upper(), re.sub(r"\{[^}]+\}", "{}", path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_catalog(spec: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
|
|
|
"""合并 §19 与 OpenAPI → 分组好的功能清单。"""
|
|
|
|
|
|
declared = parse_section19()
|
|
|
|
|
|
spec_index = openapi_index(spec)
|
|
|
|
|
|
|
|
|
|
|
|
seen: set[tuple[str, str]] = set()
|
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
|
for entry in declared:
|
|
|
|
|
|
key = endpoint_key(entry["method"], entry["path"])
|
|
|
|
|
|
seen.add(key)
|
|
|
|
|
|
spec = spec_index.get(key, {})
|
|
|
|
|
|
is_write = entry["method"] in WRITE_METHODS
|
|
|
|
|
|
benign = any(token in entry["path"] for token in BENIGN_WRITE)
|
|
|
|
|
|
entry.update(
|
|
|
|
|
|
{
|
|
|
|
|
|
"parameters": spec.get("parameters", []),
|
|
|
|
|
|
"body_schema": spec.get("body_schema"),
|
|
|
|
|
|
"summary": spec.get("summary", ""),
|
|
|
|
|
|
"in_openapi": key in spec_index,
|
|
|
|
|
|
"kind": "读" if not is_write else ("查询型写" if benign else "写"),
|
|
|
|
|
|
"dangerous": is_write and not benign,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
items.append(entry)
|
|
|
|
|
|
|
|
|
|
|
|
# OpenAPI 里有、§19 没登记的:这类最该被看见(接口文档漏登记)。
|
|
|
|
|
|
# 做成与其他端点同构的条目 —— 于是它们一样能点、能发请求,而不只是被列出来。
|
|
|
|
|
|
undeclared: list[dict[str, Any]] = []
|
|
|
|
|
|
for method, normalised in sorted(spec_index):
|
|
|
|
|
|
if (method, normalised) in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
spec = spec_index[(method, normalised)]
|
|
|
|
|
|
path = str(spec.get("raw_path", normalised))
|
|
|
|
|
|
is_write = method in WRITE_METHODS
|
|
|
|
|
|
benign = any(token in path for token in BENIGN_WRITE)
|
|
|
|
|
|
undeclared.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"code": "未登记",
|
|
|
|
|
|
"method": method,
|
|
|
|
|
|
"path": path,
|
|
|
|
|
|
"permission": "—",
|
|
|
|
|
|
"idempotent": "—",
|
|
|
|
|
|
"status": "—",
|
|
|
|
|
|
"audit": "—",
|
|
|
|
|
|
"parameters": spec.get("parameters", []),
|
|
|
|
|
|
"body_schema": spec.get("body_schema"),
|
|
|
|
|
|
"summary": spec.get("summary", ""),
|
|
|
|
|
|
"in_openapi": True,
|
|
|
|
|
|
"kind": "读" if not is_write else ("查询型写" if benign else "写"),
|
|
|
|
|
|
"dangerous": is_write and not benign,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
grouped: dict[str, list[dict[str, Any]]] = {name: [] for name, _ in GROUPS}
|
|
|
|
|
|
grouped["其他"] = []
|
|
|
|
|
|
grouped["未登记(OpenAPI 有、§19 无)"] = list(undeclared)
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
for name, prefixes in GROUPS:
|
|
|
|
|
|
if item["path"].startswith(prefixes):
|
|
|
|
|
|
grouped[name].append(item)
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
grouped["其他"].append(item)
|
|
|
|
|
|
|
|
|
|
|
|
groups = [
|
|
|
|
|
|
{"name": name, "endpoints": grouped[name]}
|
|
|
|
|
|
for name, _ in GROUPS + (("其他", ()), ("未登记(OpenAPI 有、§19 无)", ()))
|
|
|
|
|
|
if grouped.get(name)
|
|
|
|
|
|
]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"groups": groups,
|
|
|
|
|
|
"total": len(items),
|
|
|
|
|
|
"undeclared": undeclared,
|
|
|
|
|
|
"accounts": [{"username": u, "password": p, "label": label} for u, p, label in DEMO_ACCOUNTS],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PlatformState:
|
|
|
|
|
|
"""懒初始化的平台连接:初始化失败也要能在页面上说清原因。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, base_url: str | None) -> None:
|
|
|
|
|
|
self.base_url = base_url
|
|
|
|
|
|
self.application: FastAPI | None = None
|
|
|
|
|
|
self._client: httpx.AsyncClient | None = None
|
|
|
|
|
|
self._lifespan: Any = None
|
|
|
|
|
|
self.token: str | None = None
|
|
|
|
|
|
self.identity: dict[str, Any] = {}
|
|
|
|
|
|
self.error: str | None = None
|
|
|
|
|
|
self._lock = asyncio.Lock()
|
|
|
|
|
|
self.environment: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def client(self) -> httpx.AsyncClient:
|
|
|
|
|
|
async with self._lock:
|
|
|
|
|
|
if self._client is not None:
|
|
|
|
|
|
return self._client
|
|
|
|
|
|
if self.base_url:
|
|
|
|
|
|
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=60.0)
|
|
|
|
|
|
self.environment = {"mode": "外部服务", "base_url": self.base_url}
|
|
|
|
|
|
return self._client
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
from app.main import create_app
|
|
|
|
|
|
|
|
|
|
|
|
application = create_app()
|
|
|
|
|
|
self.application = application
|
|
|
|
|
|
self._lifespan = application.router.lifespan_context(application)
|
|
|
|
|
|
await self._lifespan.__aenter__()
|
|
|
|
|
|
transport = httpx.ASGITransport(app=application)
|
|
|
|
|
|
self._client = httpx.AsyncClient(transport=transport, base_url="http://platform", timeout=60.0)
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
dsn = settings.mysql_dsn
|
|
|
|
|
|
safe = re.sub(r"//[^@]*@", "//***@", dsn)
|
|
|
|
|
|
self.environment = {"mode": "进程内", "mysql": safe}
|
|
|
|
|
|
return self._client
|
|
|
|
|
|
|
|
|
|
|
|
async def aclose(self) -> None:
|
|
|
|
|
|
if self._client is not None:
|
|
|
|
|
|
await self._client.aclose()
|
|
|
|
|
|
self._client = None
|
|
|
|
|
|
if self._lifespan is not None:
|
|
|
|
|
|
await self._lifespan.__aexit__(None, None, None)
|
|
|
|
|
|
self._lifespan = None
|
|
|
|
|
|
|
|
|
|
|
|
async def openapi_spec(self) -> dict[str, Any] | None:
|
|
|
|
|
|
"""进程内直接问 app 要;外部模式则拉 `/openapi.json`。"""
|
|
|
|
|
|
if self.application is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return self.application.openapi()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"[warn] 读取 openapi 失败:{exc}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
if self.base_url:
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await (await self.client()).get("/openapi.json")
|
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"[warn] 拉取 /openapi.json 失败:{exc}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def login(self, username: str, password: str) -> dict[str, Any]:
|
|
|
|
|
|
client = await self.client()
|
|
|
|
|
|
response = await client.post(
|
|
|
|
|
|
"/api/v1/auth/tokens", json={"username": username, "password": password}
|
|
|
|
|
|
)
|
|
|
|
|
|
payload = _payload(response)
|
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
|
data = payload.get("data") or {}
|
|
|
|
|
|
self.token = data.get("access_token")
|
|
|
|
|
|
self.identity = {
|
|
|
|
|
|
"username": username,
|
|
|
|
|
|
"user_id": data.get("user_id") or _user_id_from_token(self.token),
|
|
|
|
|
|
"expires_in": data.get("expires_in"),
|
|
|
|
|
|
}
|
|
|
|
|
|
return {"status": response.status_code, "body": payload}
|
|
|
|
|
|
|
|
|
|
|
|
async def call(
|
|
|
|
|
|
self,
|
|
|
|
|
|
method: str,
|
|
|
|
|
|
path: str,
|
|
|
|
|
|
query: dict[str, Any] | None = None,
|
|
|
|
|
|
body: Any = None,
|
|
|
|
|
|
with_token: bool = True,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
client = await self.client()
|
|
|
|
|
|
headers: dict[str, str] = {}
|
|
|
|
|
|
if with_token and self.token:
|
|
|
|
|
|
headers["Authorization"] = f"Bearer {self.token}"
|
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await client.request(
|
|
|
|
|
|
method.upper(), path, params=query or None, json=body, headers=headers
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
"elapsed_ms": round((time.perf_counter() - started) * 1000, 1),
|
|
|
|
|
|
"error": f"{type(exc).__name__}: {exc}",
|
|
|
|
|
|
"body": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
elapsed = round((time.perf_counter() - started) * 1000, 1)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": response.status_code,
|
|
|
|
|
|
"elapsed_ms": elapsed,
|
|
|
|
|
|
"body": _payload(response),
|
|
|
|
|
|
"content_type": response.headers.get("content-type", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _payload(response: httpx.Response) -> Any:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {"_raw": response.text[:4000]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user_id_from_token(token: str | None) -> str | None:
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
return None
|
|
|
|
|
|
import jwt
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return str(jwt.decode(token, options={"verify_signature": False}).get("sub"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_console(state: PlatformState) -> FastAPI:
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def lifespan(_: FastAPI):
|
|
|
|
|
|
yield
|
|
|
|
|
|
await state.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
console = FastAPI(title="平台功能测试台", lifespan=lifespan)
|
|
|
|
|
|
|
|
|
|
|
|
@console.get("/", response_class=HTMLResponse)
|
|
|
|
|
|
async def index() -> HTMLResponse: # pragma: no cover - 静态页
|
|
|
|
|
|
return HTMLResponse(PAGE)
|
|
|
|
|
|
|
|
|
|
|
|
@console.get("/api/catalog")
|
|
|
|
|
|
async def catalog() -> JSONResponse:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await state.client()
|
|
|
|
|
|
state.error = None
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
state.error = f"{type(exc).__name__}: {exc}"
|
|
|
|
|
|
data = build_catalog(await state.openapi_spec())
|
|
|
|
|
|
data["environment"] = state.environment
|
|
|
|
|
|
data["startup_error"] = state.error
|
|
|
|
|
|
return JSONResponse(data)
|
|
|
|
|
|
|
|
|
|
|
|
@console.post("/api/login")
|
|
|
|
|
|
async def login(payload: dict[str, Any]) -> JSONResponse:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await state.login(payload.get("username", ""), payload.get("password", ""))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
return JSONResponse({"status": 0, "error": f"{type(exc).__name__}: {exc}"}, status_code=200)
|
|
|
|
|
|
return JSONResponse({**result, "identity": state.identity})
|
|
|
|
|
|
|
|
|
|
|
|
@console.post("/api/logout")
|
|
|
|
|
|
async def logout() -> JSONResponse:
|
|
|
|
|
|
state.token = None
|
|
|
|
|
|
state.identity = {}
|
|
|
|
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
|
|
|
|
|
|
@console.get("/api/context")
|
|
|
|
|
|
async def context() -> JSONResponse:
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
{
|
|
|
|
|
|
"environment": state.environment,
|
|
|
|
|
|
"identity": state.identity,
|
|
|
|
|
|
"has_token": bool(state.token),
|
|
|
|
|
|
"startup_error": state.error,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@console.post("/api/call")
|
|
|
|
|
|
async def call(payload: dict[str, Any]) -> JSONResponse:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await state.call(
|
|
|
|
|
|
payload.get("method", "GET"),
|
|
|
|
|
|
payload.get("path", "/"),
|
|
|
|
|
|
payload.get("query"),
|
|
|
|
|
|
payload.get("body"),
|
|
|
|
|
|
bool(payload.get("with_token", True)),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
result = {"status": 0, "error": f"{type(exc).__name__}: {exc}", "body": None}
|
|
|
|
|
|
return JSONResponse(result)
|
|
|
|
|
|
|
|
|
|
|
|
@console.post("/api/smoke")
|
|
|
|
|
|
async def smoke() -> JSONResponse:
|
|
|
|
|
|
"""把只读端点全打一遍。判据:只有 5xx 算缺陷。
|
|
|
|
|
|
|
|
|
|
|
|
**只打 GET**:写操作一律不进批量,避免"冒烟本身改了数据"。
|
|
|
|
|
|
"""
|
|
|
|
|
|
data = build_catalog(await state.openapi_spec())
|
|
|
|
|
|
targets = [
|
|
|
|
|
|
item
|
|
|
|
|
|
for group in data["groups"]
|
|
|
|
|
|
for item in group["endpoints"]
|
|
|
|
|
|
if item["method"] == "GET"
|
|
|
|
|
|
]
|
|
|
|
|
|
results = []
|
|
|
|
|
|
for item in targets:
|
|
|
|
|
|
path = _fill_path(item)
|
|
|
|
|
|
outcome = await state.call("GET", path, _sample_query(item), None)
|
|
|
|
|
|
status = int(outcome.get("status") or 0)
|
|
|
|
|
|
results.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"code": item["code"],
|
|
|
|
|
|
"path": item["path"],
|
|
|
|
|
|
"called": path,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"elapsed_ms": outcome.get("elapsed_ms"),
|
|
|
|
|
|
"verdict": _verdict(status),
|
|
|
|
|
|
"detail": _detail(outcome),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
summary = Counter(item["verdict"] for item in results)
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
{
|
|
|
|
|
|
"total": len(results),
|
|
|
|
|
|
"summary": dict(summary),
|
|
|
|
|
|
"defects": [r for r in results if r["verdict"] == "缺陷"],
|
|
|
|
|
|
"results": results,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return console
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fill_path(item: dict[str, Any]) -> str:
|
|
|
|
|
|
"""把 path 占位符换成示例值:优先用 OpenAPI 给的 example,否则用启发式。"""
|
|
|
|
|
|
path = item["path"]
|
|
|
|
|
|
examples = {
|
|
|
|
|
|
param["name"]: param.get("example")
|
|
|
|
|
|
for param in item.get("parameters", [])
|
|
|
|
|
|
if param.get("in") == "path"
|
|
|
|
|
|
}
|
|
|
|
|
|
fallback = {
|
|
|
|
|
|
"role_code": "customer",
|
|
|
|
|
|
"user_id": "9001",
|
|
|
|
|
|
"customer_id": "9001",
|
|
|
|
|
|
"candidate_id": "1",
|
|
|
|
|
|
"ticket_no": "T-1",
|
|
|
|
|
|
"knowledge_id": "1",
|
|
|
|
|
|
"reference_token": "x",
|
|
|
|
|
|
"message_id": "1",
|
|
|
|
|
|
"conversation_id": "1",
|
|
|
|
|
|
"run_id": "1",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def replace(match: re.Match[str]) -> str:
|
|
|
|
|
|
name = match.group(1)
|
|
|
|
|
|
return str(examples.get(name) or fallback.get(name) or "1")
|
|
|
|
|
|
|
|
|
|
|
|
return re.sub(r"\{([^}]+)\}", replace, path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sample_query(item: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
|
|
|
query = {
|
|
|
|
|
|
param["name"]: param.get("example")
|
|
|
|
|
|
for param in item.get("parameters", [])
|
|
|
|
|
|
if param.get("in") == "query" and param.get("example") is not None
|
|
|
|
|
|
}
|
|
|
|
|
|
return query or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verdict(status: int) -> str:
|
|
|
|
|
|
if 200 <= status < 300:
|
|
|
|
|
|
return "通过"
|
|
|
|
|
|
if status == 0:
|
|
|
|
|
|
return "缺陷"
|
|
|
|
|
|
if 500 <= status < 600:
|
|
|
|
|
|
return "缺陷"
|
|
|
|
|
|
if status in (401, 403):
|
|
|
|
|
|
return "权限不足"
|
|
|
|
|
|
if status in (400, 404, 409, 422):
|
|
|
|
|
|
return "需参数或数据"
|
|
|
|
|
|
return "其他"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detail(outcome: dict[str, Any]) -> str:
|
|
|
|
|
|
body = outcome.get("body")
|
|
|
|
|
|
if isinstance(body, dict):
|
|
|
|
|
|
message = body.get("message") or body.get("_raw")
|
|
|
|
|
|
if message:
|
|
|
|
|
|
return str(message)[:200]
|
|
|
|
|
|
error = body.get("error")
|
|
|
|
|
|
if isinstance(error, dict) and error.get("message"):
|
|
|
|
|
|
return str(error["message"])[:200]
|
|
|
|
|
|
if outcome.get("error"):
|
|
|
|
|
|
return str(outcome["error"])[:200]
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PAGE = r"""<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
|
<title>平台功能测试台</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
|
body { margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
|
|
|
|
background: #f2f4f7; color: #1f2d3d; height: 100vh; display: flex; flex-direction: column; }
|
|
|
|
|
|
header { background: #1f2d3d; color: #fff; padding: 10px 16px; display: flex;
|
|
|
|
|
|
align-items: center; gap: 14px; flex-wrap: wrap; font-size: 13px; }
|
|
|
|
|
|
header b { font-size: 15px; }
|
|
|
|
|
|
header .env { background: #2d4059; padding: 3px 9px; border-radius: 4px; font-family: Consolas, monospace; }
|
|
|
|
|
|
header .warn { background: #a8342a; padding: 3px 9px; border-radius: 4px; }
|
|
|
|
|
|
header .spacer { flex: 1; }
|
|
|
|
|
|
button { font: inherit; padding: 4px 10px; border: 1px solid #c9d2dd; background: #fff;
|
|
|
|
|
|
border-radius: 4px; cursor: pointer; }
|
|
|
|
|
|
button:hover { background: #eef2f7; }
|
|
|
|
|
|
button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
|
|
|
|
|
button.primary:hover { background: #1558c0; }
|
|
|
|
|
|
button.danger { background: #c0392b; border-color: #c0392b; color: #fff; }
|
|
|
|
|
|
main { flex: 1; display: flex; min-height: 0; }
|
|
|
|
|
|
aside { width: 350px; border-right: 1px solid #dde3ea; background: #fff; display: flex; flex-direction: column; }
|
|
|
|
|
|
aside .tools { padding: 8px; border-bottom: 1px solid #eef2f7; display: flex; gap: 6px; }
|
|
|
|
|
|
aside input[type=search] { flex: 1; padding: 5px 8px; border: 1px solid #c9d2dd; border-radius: 4px; font: inherit; }
|
|
|
|
|
|
.list { overflow: auto; flex: 1; }
|
|
|
|
|
|
.group { padding: 6px 10px; background: #f8fafc; font-size: 12px; color: #5a6b7f;
|
|
|
|
|
|
border-top: 1px solid #eef2f7; position: sticky; top: 0; }
|
|
|
|
|
|
.item { padding: 6px 10px; cursor: pointer; border-bottom: 1px solid #f4f7fa; font-size: 13px; }
|
|
|
|
|
|
.item:hover { background: #eef6ff; }
|
|
|
|
|
|
.item.active { background: #1f6feb; color: #fff; }
|
|
|
|
|
|
.item.active .badge { background: rgba(255,255,255,.25); color: #fff; }
|
|
|
|
|
|
.item code { font-family: Consolas, monospace; font-size: 12px; }
|
|
|
|
|
|
.badge { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 11px;
|
|
|
|
|
|
background: #e8eef6; color: #44536a; margin-right: 4px; }
|
|
|
|
|
|
.badge.w { background: #fdecea; color: #a8342a; }
|
|
|
|
|
|
.badge.q { background: #fff4e5; color: #8a5a00; }
|
|
|
|
|
|
section { flex: 1; overflow: auto; padding: 16px 20px; }
|
|
|
|
|
|
h2 { margin: 0 0 4px; font-size: 17px; }
|
|
|
|
|
|
.meta { color: #5a6b7f; font-size: 12px; margin-bottom: 12px; line-height: 1.7; }
|
|
|
|
|
|
.field { margin-bottom: 10px; }
|
|
|
|
|
|
.field label { display: block; font-size: 12px; color: #5a6b7f; margin-bottom: 3px; }
|
|
|
|
|
|
.field input, .field textarea { width: 100%; padding: 6px 8px; border: 1px solid #c9d2dd;
|
|
|
|
|
|
border-radius: 4px; font-family: Consolas, monospace; font-size: 13px; }
|
|
|
|
|
|
.field textarea { min-height: 120px; resize: vertical; }
|
|
|
|
|
|
.row { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
|
|
|
|
|
|
pre { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px; overflow: auto;
|
|
|
|
|
|
font-size: 12.5px; line-height: 1.5; max-height: 420px; }
|
|
|
|
|
|
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
|
|
|
|
|
|
th, td { border-bottom: 1px solid #e6ecf3; padding: 5px 8px; text-align: left; }
|
|
|
|
|
|
th { background: #f8fafc; color: #5a6b7f; font-weight: 600; }
|
|
|
|
|
|
.ok { color: #1a7f37; } .bad { color: #c0392b; font-weight: 600; } .mid { color: #8a5a00; }
|
|
|
|
|
|
.note { background: #fff8e6; border-left: 3px solid #e0a800; padding: 8px 12px;
|
|
|
|
|
|
font-size: 12.5px; border-radius: 3px; margin-bottom: 12px; }
|
|
|
|
|
|
select { padding: 5px 8px; border: 1px solid #c9d2dd; border-radius: 4px; font: inherit; }
|
|
|
|
|
|
.hidden { display: none; }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<b>平台功能测试台</b>
|
|
|
|
|
|
<span class="env" id="env">连接中…</span>
|
|
|
|
|
|
<span id="identity">未登录</span>
|
|
|
|
|
|
<select id="accounts"></select>
|
|
|
|
|
|
<button class="primary" onclick="login()">登录</button>
|
|
|
|
|
|
<button onclick="logout()">退出</button>
|
|
|
|
|
|
<span class="spacer"></span>
|
|
|
|
|
|
<span id="startup-error" class="warn hidden"></span>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<main>
|
|
|
|
|
|
<aside>
|
|
|
|
|
|
<div class="tools">
|
|
|
|
|
|
<input type="search" id="filter" placeholder="过滤路径 / 编号…" oninput="renderList()">
|
|
|
|
|
|
<button onclick="runSmoke()" title="把全部只读端点打一遍,只有 5xx 算缺陷">只读冒烟</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="list" id="list">加载中…</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
<section id="detail">
|
|
|
|
|
|
<h2>选一个接口开始</h2>
|
|
|
|
|
|
<div class="meta">
|
|
|
|
|
|
左侧是 <code>docs/05</code> §19 登记的全部端点;参数与请求体来自平台 OpenAPI。<br>
|
|
|
|
|
|
<b>只读冒烟</b>只把 <b>5xx 判为缺陷</b> —— 403/400/404/409 都是有效响应(金融系统要求失败关闭)。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="smoke-result"></div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
let CATALOG = { groups: [], total: 0, undeclared: [] };
|
|
|
|
|
|
let CURRENT = null;
|
|
|
|
|
|
|
|
|
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
|
|
|
|
const pretty = (o) => { try { return JSON.stringify(o, null, 2); } catch { return String(o); } };
|
|
|
|
|
|
|
|
|
|
|
|
async function jpost(url, body) {
|
|
|
|
|
|
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body ?? {}) });
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function boot() {
|
|
|
|
|
|
const meta = await (await fetch('/api/catalog')).json();
|
|
|
|
|
|
CATALOG = meta;
|
|
|
|
|
|
document.getElementById('env').textContent =
|
|
|
|
|
|
meta.environment && meta.environment.mode ? (meta.environment.mode + ' · ' + (meta.environment.mysql || meta.environment.base_url || '')) : '未知';
|
|
|
|
|
|
if (meta.startup_error) {
|
|
|
|
|
|
const el = document.getElementById('startup-error');
|
|
|
|
|
|
el.textContent = '平台初始化失败:' + meta.startup_error.slice(0, 120);
|
|
|
|
|
|
el.classList.remove('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('accounts').innerHTML = (meta.accounts || [])
|
|
|
|
|
|
.map((a) => `<option value="${esc(a.username)}|${esc(a.password)}">${esc(a.label)}(${esc(a.username)})</option>`).join('');
|
|
|
|
|
|
await refreshContext();
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshContext() {
|
|
|
|
|
|
const ctx = await (await fetch('/api/context')).json();
|
|
|
|
|
|
const id = ctx.identity && ctx.identity.username
|
|
|
|
|
|
? `已登录:${ctx.identity.username}(sub=${ctx.identity.user_id ?? '?'})` : '未登录';
|
|
|
|
|
|
document.getElementById('identity').textContent = id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function login() {
|
|
|
|
|
|
const [username, password] = document.getElementById('accounts').value.split('|');
|
|
|
|
|
|
const r = await jpost('/api/login', { username, password });
|
|
|
|
|
|
if (r.status === 200) { await refreshContext(); }
|
|
|
|
|
|
else { alert('登录失败(HTTP ' + r.status + '):' + pretty(r.body ?? r.error)); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function logout() { await jpost('/api/logout'); await refreshContext(); }
|
|
|
|
|
|
|
|
|
|
|
|
function renderList() {
|
|
|
|
|
|
const q = document.getElementById('filter').value.trim().toLowerCase();
|
|
|
|
|
|
const box = document.getElementById('list');
|
|
|
|
|
|
let html = '';
|
|
|
|
|
|
for (const g of CATALOG.groups) {
|
|
|
|
|
|
const items = g.endpoints.filter((e) =>
|
|
|
|
|
|
!q || (e.path + ' ' + e.code + ' ' + e.method).toLowerCase().includes(q));
|
|
|
|
|
|
if (!items.length) continue;
|
|
|
|
|
|
html += `<div class="group">${esc(g.name)} · ${items.length}</div>`;
|
|
|
|
|
|
for (const e of items) {
|
|
|
|
|
|
const badge = e.dangerous ? '<span class="badge w">写</span>'
|
|
|
|
|
|
: (e.kind === '查询型写' ? '<span class="badge q">查询</span>' : '');
|
|
|
|
|
|
html += `<div class="item" data-key="${esc(e.code + e.method + e.path)}" onclick='pick(${JSON.stringify(e.code + e.method + e.path)})'>`
|
|
|
|
|
|
+ `${badge}<code>${esc(e.method)} ${esc(e.path)}</code>`
|
|
|
|
|
|
+ `<div style="color:#7a8a9d;font-size:11.5px">${esc(e.code)} · ${esc(e.permission)}</div></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
box.innerHTML = html || '<div class="item">没有匹配的接口</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findEndpoint(key) {
|
|
|
|
|
|
for (const g of CATALOG.groups) for (const e of g.endpoints)
|
|
|
|
|
|
if (e.code + e.method + e.path === key) return e;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pick(key) {
|
|
|
|
|
|
const e = findEndpoint(key);
|
|
|
|
|
|
if (!e) return;
|
|
|
|
|
|
CURRENT = e;
|
|
|
|
|
|
document.querySelectorAll('.item').forEach((el) => {
|
|
|
|
|
|
el.classList.toggle('active', el.dataset.key === key);
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('smoke-result').innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
const pathParams = (e.parameters || []).filter((p) => p.in === 'path');
|
|
|
|
|
|
const queryParams = (e.parameters || []).filter((p) => p.in === 'query');
|
|
|
|
|
|
const example = e.body_schema ? sampleFromSchema(e.body_schema) : null;
|
|
|
|
|
|
|
|
|
|
|
|
let html = `<h2>${esc(e.method)} ${esc(e.path)}</h2>
|
|
|
|
|
|
<div class="meta">
|
|
|
|
|
|
编号 <b>${esc(e.code)}</b> · 权限 <code>${esc(e.permission)}</code> ·
|
|
|
|
|
|
幂等 ${esc(e.idempotent)} · 期望 ${esc(e.status)} ·
|
|
|
|
|
|
${e.dangerous ? '<b class="bad">写操作,会改数据</b>' : '只读'} ·
|
|
|
|
|
|
${e.in_openapi ? 'OpenAPI 已登记' : 'OpenAPI 未找到(可能不在运行时)'}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
if (pathParams.length) {
|
|
|
|
|
|
html += '<div class="field"><label>路径参数</label>';
|
|
|
|
|
|
for (const p of pathParams) {
|
|
|
|
|
|
html += `<input id="p-${esc(p.name)}" placeholder="${esc(p.name)}${p.required ? '(必填)' : ''}" value="${esc(p.example ?? '')}">`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (queryParams.length) {
|
|
|
|
|
|
html += '<div class="field"><label>查询参数</label>';
|
|
|
|
|
|
for (const p of queryParams) {
|
|
|
|
|
|
html += `<input id="q-${esc(p.name)}" placeholder="${esc(p.name)}${p.required ? '(必填)' : ''}" value="${esc(p.example ?? '')}">`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.body_schema) {
|
|
|
|
|
|
html += `<div class="field"><label>请求体(JSON)</label>
|
|
|
|
|
|
<textarea id="body">${esc(pretty(example ?? {}))}</textarea></div>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
html += '<div class="meta">该端点无请求体。</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `<div class="row">
|
|
|
|
|
|
<button class="primary" onclick="send()">发送</button>
|
|
|
|
|
|
<button onclick="copyCurl()">复制 curl</button>
|
|
|
|
|
|
<label style="font-size:12.5px;color:#5a6b7f">
|
|
|
|
|
|
<input type="checkbox" id="with-token" checked> 带令牌</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="response"></div>`;
|
|
|
|
|
|
document.getElementById('detail').innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sampleFromSchema(schema, depth = 0) {
|
|
|
|
|
|
if (!schema || depth > 4) return null;
|
|
|
|
|
|
if (schema.example !== undefined) return schema.example;
|
|
|
|
|
|
const type = schema.type || (schema.anyOf ? 'anyOf' : undefined);
|
|
|
|
|
|
if (type === 'object' || schema.properties) {
|
|
|
|
|
|
const out = {};
|
|
|
|
|
|
for (const [k, v] of Object.entries(schema.properties || {})) out[k] = sampleFromSchema(v, depth + 1);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'array') return [sampleFromSchema(schema.items || {}, depth + 1)];
|
|
|
|
|
|
if (type === 'integer' || type === 'number') return 0;
|
|
|
|
|
|
if (type === 'boolean') return false;
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collect() {
|
|
|
|
|
|
const e = CURRENT;
|
|
|
|
|
|
let path = e.path;
|
|
|
|
|
|
for (const p of (e.parameters || []).filter((x) => x.in === 'path')) {
|
|
|
|
|
|
const v = document.getElementById('p-' + p.name)?.value || '1';
|
|
|
|
|
|
path = path.replace('{' + p.name + '}', encodeURIComponent(v));
|
|
|
|
|
|
}
|
|
|
|
|
|
const query = {};
|
|
|
|
|
|
for (const p of (e.parameters || []).filter((x) => x.in === 'query')) {
|
|
|
|
|
|
const v = document.getElementById('q-' + p.name)?.value;
|
|
|
|
|
|
if (v !== undefined && v !== '') query[p.name] = v;
|
|
|
|
|
|
}
|
|
|
|
|
|
let body = null;
|
|
|
|
|
|
const ta = document.getElementById('body');
|
|
|
|
|
|
if (ta && ta.value.trim()) { try { body = JSON.parse(ta.value); } catch (err) { alert('请求体不是合法 JSON:' + err.message); return null; } }
|
|
|
|
|
|
return { method: e.method, path, query, body, with_token: document.getElementById('with-token').checked };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function send() {
|
|
|
|
|
|
const req = collect();
|
|
|
|
|
|
if (!req) return;
|
|
|
|
|
|
if (CURRENT.dangerous && !confirm('这是写操作:' + CURRENT.method + ' ' + CURRENT.path + '\n确认发送?')) return;
|
|
|
|
|
|
const box = document.getElementById('response');
|
|
|
|
|
|
box.innerHTML = '<div class="meta">请求中…</div>';
|
|
|
|
|
|
const r = await jpost('/api/call', req);
|
|
|
|
|
|
const cls = r.status >= 200 && r.status < 300 ? 'ok' : (r.status >= 500 || r.status === 0 ? 'bad' : 'mid');
|
|
|
|
|
|
box.innerHTML = `<div class="meta">HTTP <b class="${cls}">${r.status || 'ERR'}</b> · ${r.elapsed_ms} ms</div>
|
|
|
|
|
|
<pre>${esc(pretty(r.body ?? r.error ?? r))}</pre>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function copyCurl() {
|
|
|
|
|
|
const req = collect();
|
|
|
|
|
|
if (!req) return;
|
|
|
|
|
|
const parts = ['curl -X ' + req.method, "'http://127.0.0.1:8000" + req.path + "'"];
|
|
|
|
|
|
if (req.with_token) parts.push("-H 'Authorization: Bearer <token>'");
|
|
|
|
|
|
if (req.body) parts.push("-H 'Content-Type: application/json' -d '" + JSON.stringify(req.body) + "'");
|
|
|
|
|
|
navigator.clipboard.writeText(parts.join(' '));
|
|
|
|
|
|
alert('curl 已复制(令牌请自行替换)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runSmoke() {
|
|
|
|
|
|
const box = document.getElementById('smoke-result');
|
|
|
|
|
|
box.innerHTML = '<div class="meta">正在逐个调用只读端点(可能需要十几秒)…</div>';
|
|
|
|
|
|
const r = await jpost('/api/smoke', { only_readonly: true });
|
|
|
|
|
|
const rows = (r.results || []).map((x) => {
|
|
|
|
|
|
const cls = x.verdict === '缺陷' ? 'bad' : (x.verdict === '通过' ? 'ok' : 'mid');
|
|
|
|
|
|
return `<tr><td>${esc(x.code)}</td><td><code>${esc(x.path)}</code></td>
|
|
|
|
|
|
<td class="${cls}">${x.status || 'ERR'}</td><td class="${cls}">${esc(x.verdict)}</td>
|
|
|
|
|
|
<td>${x.elapsed_ms ?? ''}</td><td>${esc(x.detail || '')}</td></tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
const summary = Object.entries(r.summary || {}).map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
|
|
|
|
box.innerHTML = `<h2>只读冒烟结果</h2>
|
|
|
|
|
|
<div class="note">共 ${r.total} 个只读端点:${esc(summary)}。
|
|
|
|
|
|
<b>只有 5xx 算缺陷</b>;403 表示当前身份权限不足(换管理员再试),
|
|
|
|
|
|
400/404/409 表示缺参数或缺数据 —— 都属有效响应。</div>
|
|
|
|
|
|
<table><thead><tr><th>编号</th><th>路径</th><th>状态</th><th>判定</th><th>耗时ms</th><th>说明</th></tr></thead>
|
|
|
|
|
|
<tbody>${rows}</tbody></table>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
boot();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="平台功能测试台")
|
|
|
|
|
|
parser.add_argument("--port", type=int, default=8100)
|
|
|
|
|
|
parser.add_argument("--base-url", default=None, help="指向已在运行的服务;省略则进程内直挂平台 app")
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
state = PlatformState(args.base_url)
|
|
|
|
|
|
mode = args.base_url or "进程内(直挂 app,走真实中间件与鉴权)"
|
|
|
|
|
|
print(f"功能测试台:http://127.0.0.1:{args.port} 模式={mode}")
|
|
|
|
|
|
print("提示:涉及 Agent 的接口请先停掉常驻 Worker;演示账号需先跑 seed_test_rbac.py + set_user_password.py。")
|
|
|
|
|
|
uvicorn.run(build_console(state), host="127.0.0.1", port=args.port, log_level="warning")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|