Files
group_fqcd_jr/tools/api_console.py
T

868 lines
35 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.
"""平台功能测试台:一个能点到**所有**接口的简易前端。
**为什么是这个形态**
- **端点清单不手写**:从 `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",)),
("平台与运维", ("/api/v1/health", "/internal", "/customer-service-test")),
)
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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()