"""平台功能测试台:一个能点到**所有**接口的简易前端。 **为什么是这个形态** - **端点清单不手写**:从 `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")), ) 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""" 平台功能测试台
平台功能测试台 连接中… 未登录

选一个接口开始

左侧是 docs/05 §19 登记的全部端点;参数与请求体来自平台 OpenAPI。
只读冒烟只把 5xx 判为缺陷 —— 403/400/404/409 都是有效响应(金融系统要求失败关闭)。
""" 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()