"""统一登录门户(**调试/联调工具**,不是产品前端)。 > ⚠️ **定位说明(2026-09-13)**:正式前端已由组员交付并合入主干,位于 > `app/static/portal/`,通过 **`/portal/`** 访问(访客 / 客户 / 员工 / 风控四套页面; > 端点表见 `app/static/portal/common/api-client.js`)。 > **本文件不是产品的一部分**,它是平台侧保留的**跨角色快速联调工具**: > 一个页面里用五个演示账号登录、直接点各模块接口、看原始响应与错误码。 > 涉及具体页面与交互的工作请改正式前端,不要再往这里加功能。 --- 一个登录入口,按角色分流到不同的工作台。 **和 `tools/chat_console.py` 的区别**:那个控制台没有登录、身份写死在命令行里; 本门户**走真实的 `POST /api/v1/auth/tokens`**,令牌只存活在本进程,浏览器拿不到, 然后按登录者的**真实角色**决定进哪个界面 —— 也就是照着"客户 / 员工 / 管理员"三种 使用者的实际路径走一遍,而不是逐个接口点着测。 | 登录者的角色 | 进入的界面 | 主要能力 | |---|---|---| | `customer` | **客服** | 与客服 Agent 对话(真实受理 + 进程内驱动 Worker)、转人工、查看并确认自己的画像候选 | | `risk_operator` / `operator` | **员工工作台** | 风控总览、预警列表与详情、预警扫描、确认/升级/解决等处置、生成日报 | | `admin` / `super_admin` | **权限界面** | 角色清单、每个角色的权限明细、按用户查角色、审计流水、客服转人工工单、知识库清单 | | `advisor` | **投顾工作台** | 当前投资目标、已发布方案、组合分析 | 角色从 `IdentityService.resolve()` 现查(不是从令牌里读)—— 所以改了库里角色,重登即生效。 跑法: ```powershell D:\\conda\\envs\\jr_py313\\python.exe tools\\portal.py # 浏览器打开 http://127.0.0.1:8101 ``` **前置**: 1. MySQL / Redis 可用(平台启动时会连); 2. 演示账号能登录:先 `python tools/seed_test_rbac.py`,再 `python tools/set_user_password.py`; 3. **客服对话前请停掉常驻 Worker**(`python -m app.worker`)—— 本门户自己驱动这一条 run, 常驻 Worker 会与它抢 `agent_run` 队列,表现为页面一直转圈。 **权限问题的表现是设计如此**:某个按钮点了返回 403,说明这个角色的权限**本来就不够** (例如客户点风控、员工点权限管理)。平台一律 fail closed,门户把 403 原样显示出来, 不做任何前端隐藏式"代为授权"。 """ from __future__ import annotations import argparse import sys import uuid from datetime import datetime from pathlib import Path from typing import Any import httpx import uvicorn from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse sys.stdout.reconfigure(errors="replace") ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) CUSTOMER_AGENT = "customer_service" DEMO_ACCOUNTS: tuple[tuple[str, str, str], ...] = ( ("cust_t", "123456", "客户"), ("risk_t", "666666", "员工(风控专员)"), ("admin_t", "88888888", "管理员"), ("advisor_t", "abc12345", "投资顾问"), ("offsite_t", "offsite123", "员工(运营专员)"), ) #: 角色 → 界面。按优先级从高到低匹配,多角色者进权限最高的那个界面。 VIEW_BY_ROLE: tuple[tuple[str, str], ...] = ( ("super_admin", "admin"), ("admin", "admin"), ("risk_operator", "staff"), ("operator", "offsite"), # 运营走场外基金这条线,不是风控那个台 ("advisor", "advisor"), ("customer", "customer"), ) VIEW_LABELS = { "customer": "客服", "staff": "风控工作台", "offsite": "运营工作台", "admin": "权限管理", "advisor": "投顾工作台", "unknown": "未分配界面", } def view_for(roles: tuple[str, ...] | list[str]) -> str: for role, view in VIEW_BY_ROLE: if role in roles: return view return "unknown" class Platform: """共享的平台连接:一个 app、一个 client、一个 lifespan,所有会话共用。 分开的理由:每个会话各自 `create_app()` 会把平台实例化好几遍(连接池、后台任务都翻倍); 而"多个浏览器标签用不同身份"又是演示时的刚需 —— 于是把**连接**共享、把**令牌**按会话分开。 """ 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.environment: dict[str, Any] = {} async def client(self) -> httpx.AsyncClient: if self._client is not None: return self._client if self.base_url: self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0) self.environment = {"mode": "外部服务", "target": 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__() self._client = httpx.AsyncClient( transport=httpx.ASGITransport(app=application), base_url="http://platform", timeout=120.0 ) dsn = get_settings().mysql_dsn self.environment = {"mode": "进程内", "mysql": dsn.split("@")[-1] if "@" in dsn else dsn} 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 request( self, method: str, path: str, *, token: str | None, query: dict[str, Any] | None = None, body: Any = None, ) -> dict[str, Any]: if not token: return {"status": 401, "body": {"error": {"code": "NOT_LOGGED_IN", "message": "请先登录"}}} client = await self.client() headers = {"Authorization": f"Bearer {token}"} # 幂等头:平台的写接口要求 `Idempotency-Key`(16-128 位 ASCII), # 漏了会被 `AGENT_INPUT_INVALID: 必须提供 16-128 位 ASCII Idempotency-Key` 拒绝。 if method.upper() in {"POST", "PUT", "PATCH", "DELETE"}: headers["Idempotency-Key"] = uuid.uuid4().hex try: response = await client.request( method.upper(), path, params=query or None, json=body, headers=headers, ) except Exception as exc: return {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}} return {"status": response.status_code, "body": _json(response)} class Session: """一次登录会话:只持有令牌与身份。""" def __init__(self, username: str, user_id: str, token: str) -> None: self.username = username self.user_id = user_id self.token = token self.roles: tuple[str, ...] = () self.permissions: tuple[str, ...] = () def as_dict(self, environment: dict[str, Any]) -> dict[str, Any]: view = view_for(self.roles) return { "username": self.username, "user_id": self.user_id, "roles": list(self.roles), "permissions": list(self.permissions), "view": view, "view_label": VIEW_LABELS[view], "logged_in": True, "environment": environment, } class Portal: """多会话门户:`X-Session` 头区分标签页,令牌始终留在服务端。""" def __init__(self, base_url: str | None) -> None: self.platform = Platform(base_url) self.sessions: dict[str, Session] = {} self.error: str | None = None async def login(self, session_id: str, username: str, password: str) -> dict[str, Any]: client = await self.platform.client() response = await client.post( "/api/v1/auth/tokens", json={"username": username, "password": password} ) payload = _json(response) if response.status_code != 200: return {"ok": False, "status": response.status_code, "body": payload} data = (payload.get("data") or {}) if isinstance(payload, dict) else {} token = data.get("access_token") or data.get("token") if not token: return {"ok": False, "status": response.status_code, "body": payload} session = Session(username, str(data.get("user_id") or _sub_of(token) or ""), token) await self._resolve(session) self.sessions[session_id] = session return {"ok": True, "me": session.as_dict(self.platform.environment)} async def _resolve(self, session: Session) -> None: """角色与权限现查库 —— 与平台每请求解析的口径一致(令牌里只有 sub)。""" from app.core.contracts import RequestContext from app.service.identity_service import IdentityService context = await IdentityService().resolve( RequestContext(user_id=session.user_id, trace_id=str(uuid.uuid4())) ) session.roles = tuple(context.roles) session.permissions = tuple(sorted(context.permissions)) def logout(self, session_id: str) -> None: self.sessions.pop(session_id, None) def me(self, session_id: str) -> dict[str, Any]: session = self.sessions.get(session_id) data = ( session.as_dict(self.platform.environment) if session else {"logged_in": False, "environment": self.platform.environment} ) data["accounts"] = [ {"username": u, "password": p, "label": label} for u, p, label in DEMO_ACCOUNTS ] data["startup_error"] = self.error return data async def call( self, session_id: str, method: str, path: str, query: dict[str, Any] | None = None, body: Any = None, ) -> dict[str, Any]: session = self.sessions.get(session_id) return await self.platform.request( method, path, token=session.token if session else None, query=query, body=body ) async def chat(self, session_id: str, message: str, chat_session: str) -> dict[str, Any]: """客服对话:真实受理 → 本进程驱动这一条 run → 取结果。""" from app.worker.runtime import WorkerRuntime accepted = await self.call( session_id, "POST", "/api/v1/agent-runs", body={ "agent_type": CUSTOMER_AGENT, "message": message, "session_id": chat_session, "idempotency_key": uuid.uuid4().hex, }, ) if accepted["status"] != 202: return {"ok": False, "status": accepted["status"], "body": accepted["body"]} run_id = ((accepted["body"] or {}).get("data") or {}).get("run_id") if not run_id: return {"ok": False, "status": accepted["status"], "body": accepted["body"]} await WorkerRuntime().execute(run_id) detail = await self.call(session_id, "GET", f"/api/v1/agent-runs/{run_id}") data = (detail["body"] or {}).get("data") or {} result = data.get("result") or {} raw_intent = result.get("intent") intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent return { "ok": True, "run_id": run_id, "status": data.get("status"), "answer": str(result.get("content") or ""), "intent": intent or "", "transfer": bool(result.get("transfer_required")), "transfer_reason": result.get("transfer_reason") or "", } def _json(response: httpx.Response) -> Any: try: return response.json() except Exception: return {"_raw": response.text[:3000]} def _sub_of(token: str) -> str | None: import jwt try: return str(jwt.decode(token, options={"verify_signature": False}).get("sub")) except Exception: return None def build_portal(state: Portal) -> FastAPI: # 注意:`Request` 必须在**模块顶层**导入。本文件启用了 # `from __future__ import annotations`,注解在此处是字符串,FastAPI 解析 # `request: Request` 时只在模块全局里找类型名 —— 写成函数内 import 会被 # 当成"必填 query 参数 request",所有请求直接 422,且报错完全看不出是这个原因。 portal = FastAPI(title="统一登录门户") def sid(request: Request) -> str: return request.headers.get("X-Session") or "default" @portal.get("/", response_class=HTMLResponse) async def index() -> HTMLResponse: return HTMLResponse(PAGE) @portal.get("/api/me") async def me(request: Request) -> JSONResponse: return JSONResponse(state.me(sid(request))) @portal.post("/api/login") async def login(request: Request, payload: dict[str, Any]) -> JSONResponse: try: result = await state.login( sid(request), str(payload.get("username") or ""), str(payload.get("password") or "") ) state.error = None except Exception as exc: state.error = f"{type(exc).__name__}: {exc}" result = {"ok": False, "status": 0, "body": {"error": {"message": state.error}}} return JSONResponse(result) @portal.post("/api/logout") async def logout(request: Request) -> JSONResponse: state.logout(sid(request)) return JSONResponse({"ok": True}) @portal.post("/api/call") async def call(request: Request, payload: dict[str, Any]) -> JSONResponse: try: result = await state.call( sid(request), str(payload.get("method") or "GET"), str(payload.get("path") or "/"), payload.get("query"), payload.get("body"), ) except Exception as exc: result = {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}} return JSONResponse(result) @portal.post("/api/chat") async def chat(request: Request, payload: dict[str, Any]) -> JSONResponse: message = str(payload.get("message") or "").strip() chat_session = str(payload.get("session_id") or f"portal-{uuid.uuid4().hex[:8]}") if not message: return JSONResponse({"ok": False, "body": {"error": {"message": "请输入内容"}}}) try: return JSONResponse(await state.chat(sid(request), message, chat_session)) except Exception as exc: return JSONResponse( {"ok": False, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}} ) return portal PAGE = r"""
请使用账号登录,系统会按你的角色进入对应界面。