868 lines
35 KiB
Python
868 lines
35 KiB
Python
"""平台功能测试台:一个能点到**所有**接口的简易前端。
|
||
|
||
**为什么是这个形态**
|
||
|
||
- **端点清单不手写**:从 `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) => ({'&':'&','<':'<','>':'>','"':'"'}[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()
|