- public_platform_service: 创建 ConversationSession 时显式赋值四个 server_default 时间列,
否则 flush() 后需回读数据库生成值,在 async session 里以同步属性访问触发
MissingGreenlet,接口 500,连带转人工一直报会话不存在
- portal: 客服改用 C001 真实建会话;转人工改传 reason_code/reason_detail(原 reason 属额外字段 422)
- portal: 投顾查客户投资目标走 customers/{id} 变体
- seed/grant: 补 investment-goal:{read,write,confirm}:customer 三个动态拼出的权限码
(data_scope=own_customers,投顾只看名下客户)
1145 lines
52 KiB
Python
1145 lines
52 KiB
Python
"""统一登录门户:一个登录入口,按角色分流到不同的工作台。
|
||
|
||
**和 `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 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"""<!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: #eef1f6; color: #1f2d3d; }
|
||
/* 登录 */
|
||
#login { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||
.card { background: #fff; padding: 30px 34px; border-radius: 10px; width: 380px;
|
||
box-shadow: 0 6px 26px rgba(31,45,61,.12); }
|
||
.card h1 { margin: 0 0 4px; font-size: 19px; }
|
||
.card p.sub { margin: 0 0 20px; color: #6b7c93; font-size: 12.5px; }
|
||
.card label { display: block; font-size: 12.5px; color: #5a6b7f; margin: 12px 0 4px; }
|
||
.card input { width: 100%; padding: 9px 11px; border: 1px solid #c9d2dd; border-radius: 5px; font: inherit; }
|
||
.card button { width: 100%; margin-top: 18px; padding: 10px; font: inherit; font-size: 14.5px;
|
||
background: #1f6feb; color: #fff; border: 0; border-radius: 5px; cursor: pointer; }
|
||
.card button:hover { background: #1558c0; }
|
||
.quick { margin-top: 18px; border-top: 1px solid #eef2f7; padding-top: 14px; }
|
||
.quick span { font-size: 12px; color: #6b7c93; }
|
||
.quick button { width: auto; display: inline-block; margin: 6px 6px 0 0; padding: 5px 10px;
|
||
font-size: 12.5px; background: #f2f5f9; color: #33445c; border: 1px solid #d8e0ea; }
|
||
.quick button:hover { background: #e6edf6; }
|
||
.err { margin-top: 14px; padding: 9px 11px; background: #fdecea; color: #a8342a;
|
||
border-radius: 5px; font-size: 12.5px; white-space: pre-wrap; }
|
||
/* 主界面 */
|
||
#app { display: none; min-height: 100vh; flex-direction: column; }
|
||
header { background: #1f2d3d; color: #fff; padding: 11px 18px; display: flex; align-items: center;
|
||
gap: 14px; flex-wrap: wrap; font-size: 13.5px; }
|
||
header b { font-size: 15px; }
|
||
header .who { color: #cfe0f5; }
|
||
header .role { background: #2d4059; padding: 3px 9px; border-radius: 4px; font-size: 12px; }
|
||
header .env { color: #8fa6c0; font-size: 11.5px; font-family: Consolas, monospace; }
|
||
header .spacer { flex: 1; }
|
||
header button { font: inherit; padding: 4px 11px; border: 1px solid #44597a; background: #26374d;
|
||
color: #dfe9f5; border-radius: 4px; cursor: pointer; }
|
||
header button:hover { background: #33475f; }
|
||
nav { background: #fff; border-bottom: 1px solid #dde3ea; padding: 0 18px; display: flex; gap: 4px; }
|
||
nav button { font: inherit; font-size: 13.5px; padding: 11px 15px; border: 0; background: none;
|
||
color: #5a6b7f; cursor: pointer; border-bottom: 2px solid transparent; }
|
||
nav button.active { color: #1f6feb; border-bottom-color: #1f6feb; font-weight: 600; }
|
||
main { flex: 1; padding: 18px; max-width: 1180px; width: 100%; margin: 0 auto; }
|
||
.panel { background: #fff; border-radius: 8px; padding: 18px 20px;
|
||
box-shadow: 0 1px 3px rgba(31,45,61,.07); margin-bottom: 16px; }
|
||
.panel h2 { margin: 0 0 4px; font-size: 16px; }
|
||
.panel p.hint { margin: 0 0 14px; color: #6b7c93; font-size: 12.5px; line-height: 1.7; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; }
|
||
.stat { background: #f7f9fc; border: 1px solid #e6ecf3; border-radius: 6px; padding: 12px 14px; }
|
||
.stat .n { font-size: 22px; font-weight: 600; color: #1f2d3d; }
|
||
.stat .l { font-size: 12px; color: #6b7c93; margin-top: 2px; }
|
||
button.act { font: inherit; padding: 5px 11px; border: 1px solid #c9d2dd; background: #fff;
|
||
border-radius: 4px; cursor: pointer; font-size: 12.5px; }
|
||
button.act:hover { background: #eef2f7; }
|
||
button.act.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||
button.act.primary:hover { background: #1558c0; }
|
||
button.act.warn { background: #fff8e6; border-color: #e0c080; color: #8a5a00; }
|
||
table { border-collapse: collapse; width: 100%; font-size: 12.8px; }
|
||
th, td { border-bottom: 1px solid #e9eef4; padding: 7px 9px; text-align: left; vertical-align: top; }
|
||
th { background: #f7f9fc; color: #5a6b7f; font-weight: 600; }
|
||
tr:hover td { background: #fafcfe; }
|
||
code { font-family: Consolas, monospace; font-size: 12px; background: #f2f5f9; padding: 1px 5px;
|
||
border-radius: 3px; }
|
||
pre { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px; overflow: auto;
|
||
font-size: 12.4px; max-height: 320px; line-height: 1.5; }
|
||
.pill { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11.5px;
|
||
background: #e8eef6; color: #44536a; margin: 2px 3px 2px 0; }
|
||
.pill.hot { background: #fdecea; color: #a8342a; }
|
||
.pill.ok { background: #e7f6ec; color: #1a7f37; }
|
||
.msg { display: flex; margin-bottom: 10px; }
|
||
.msg.me { justify-content: flex-end; }
|
||
.bubble { max-width: 74%; padding: 10px 13px; border-radius: 9px; background: #fff;
|
||
border: 1px solid #e2e8f0; white-space: pre-wrap; line-height: 1.65; font-size: 13.5px; }
|
||
.msg.me .bubble { background: #1f6feb; color: #fff; border-color: #1f6feb; }
|
||
.tag { display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 3px;
|
||
font-size: 11px; background: #e8eef6; color: #44536a; }
|
||
.tag.warn { background: #fff8e6; color: #8a5a00; }
|
||
.chatlog { height: 380px; overflow: auto; padding: 4px; background: #f7f9fc; border-radius: 6px; }
|
||
.composer { display: flex; gap: 8px; margin-top: 10px; }
|
||
.composer input { flex: 1; padding: 10px 12px; border: 1px solid #c9d2dd; border-radius: 5px; font: inherit; }
|
||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||
input.q { padding: 6px 9px; border: 1px solid #c9d2dd; border-radius: 4px; font: inherit; font-size: 13px; }
|
||
.muted { color: #8fa0b5; font-size: 12.5px; }
|
||
.hidden { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="login">
|
||
<div class="card">
|
||
<h1>基金智能服务平台</h1>
|
||
<p class="sub">请使用账号登录,系统会按你的角色进入对应界面。</p>
|
||
<label>用户名</label>
|
||
<input id="u" autocomplete="username" placeholder="例如 cust_t">
|
||
<label>密码</label>
|
||
<input id="p" type="password" autocomplete="current-password" placeholder="演示口令">
|
||
<button onclick="doLogin()">登录</button>
|
||
<div class="quick">
|
||
<span>演示账号(点击填入):</span><br>
|
||
<span id="quick"></span>
|
||
</div>
|
||
<div id="login-err" class="err hidden"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="app">
|
||
<header>
|
||
<b>基金智能服务平台</b>
|
||
<span class="who" id="who"></span>
|
||
<span class="role" id="role"></span>
|
||
<span class="spacer"></span>
|
||
<span class="env" id="env"></span>
|
||
<button onclick="doLogout()">退出登录</button>
|
||
</header>
|
||
<nav id="nav"></nav>
|
||
<main id="main"></main>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
const pretty = (o) => { try { return JSON.stringify(o, null, 2); } catch { return String(o); } };
|
||
|
||
let ME = null;
|
||
// 客服会话:必须用 C001 `POST /api/v1/conversations` 真实创建后再用 ——
|
||
// `agent-runs` 的 session_id 不会替你建会话,转人工时后端会查 `conversation` 表,
|
||
// 拿一个自己编的 id 去调就会得到 404「会话不存在」。
|
||
let CONV = null;
|
||
// 门户会话号:每个标签页一个,存在 sessionStorage;令牌本身始终留在服务端 ——
|
||
// 于是可以同时开两个窗口分别用客户与管理员身份,互不干扰。
|
||
let SID = sessionStorage.getItem('portal-sid');
|
||
if (!SID) { SID = "s-" + Math.random().toString(16).slice(2, 12); sessionStorage.setItem('portal-sid', SID); }
|
||
const HEAD = () => ({ 'Content-Type': 'application/json', 'X-Session': SID });
|
||
|
||
async function jpost(url, body) {
|
||
const r = await fetch(url, { method: 'POST', headers: HEAD(), body: JSON.stringify(body ?? {}) });
|
||
return r.json();
|
||
}
|
||
|
||
// 统一调用平台接口:令牌在服务端,前端只传方法与路径
|
||
async function api(method, path, body, query) {
|
||
return jpost('/api/call', { method, path, body, query });
|
||
}
|
||
const GET = (path, query) => api('GET', path, null, query);
|
||
|
||
async function boot() {
|
||
const me = await (await fetch('/api/me', { headers: HEAD() })).json();
|
||
$('quick').innerHTML = (me.accounts || []).map((a) =>
|
||
`<button onclick="fill('${esc(a.username)}','${esc(a.password)}')">${esc(a.label)} · ${esc(a.username)}</button>`
|
||
).join('');
|
||
if (me.logged_in) { ME = me; showApp(); }
|
||
}
|
||
|
||
function fill(u, p) { $('u').value = u; $('p').value = p; $('login-err').classList.add('hidden'); }
|
||
|
||
async function doLogin() {
|
||
const err = $('login-err');
|
||
err.classList.add('hidden');
|
||
const r = await jpost('/api/login', { username: $('u').value.trim(), password: $('p').value });
|
||
if (!r.ok) {
|
||
const msg = (r.body && (r.body.error?.message || r.body.message)) || pretty(r.body || r);
|
||
err.textContent = '登录失败(HTTP ' + (r.status || '?') + '):' + msg;
|
||
err.classList.remove('hidden');
|
||
return;
|
||
}
|
||
ME = r.me;
|
||
showApp();
|
||
}
|
||
|
||
async function doLogout() { await jpost('/api/logout'); ME = null; location.reload(); }
|
||
|
||
let VIEW = null;
|
||
function showApp() {
|
||
$('login').style.display = 'none';
|
||
$('app').style.display = 'flex';
|
||
$('who').textContent = ME.username + '(' + ME.user_id + ')';
|
||
$('role').textContent = ME.roles.join(' / ') || '无角色';
|
||
$('env').textContent = (ME.environment?.mode || '') + ' · ' + (ME.environment?.mysql || ME.environment?.target || '');
|
||
VIEW = ME.view;
|
||
const tabs = [{ id: VIEW, label: ME.view_label }];
|
||
// 多角色时允许手动切到其他已具备的界面,便于一次演示
|
||
const others = new Set(ME.roles.map((r) => ({ super_admin:'admin', admin:'admin',
|
||
risk_operator:'staff', operator:'offsite', advisor:'advisor', customer:'customer' }[r])));
|
||
others.delete(VIEW);
|
||
for (const v of others) if (v) tabs.push({ id: v, label: { customer:'客服', staff:'风控工作台',
|
||
offsite:'运营工作台', admin:'权限管理', advisor:'投顾工作台' }[v] });
|
||
$('nav').innerHTML = tabs.map((t, i) =>
|
||
`<button class="${i === 0 ? 'active' : ''}" onclick="switchView('${t.id}', this)">${esc(t.label)}</button>`
|
||
).join('');
|
||
render(VIEW);
|
||
}
|
||
|
||
function switchView(v, el) {
|
||
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
render(v);
|
||
}
|
||
|
||
function render(view) {
|
||
const box = $('main');
|
||
if (view === 'customer') return renderCustomer(box);
|
||
if (view === 'staff') return renderStaff(box);
|
||
if (view === 'offsite') return renderOffsite(box);
|
||
if (view === 'admin') return renderAdmin(box);
|
||
if (view === 'advisor') return renderAdvisor(box);
|
||
box.innerHTML = `<div class="panel"><h2>未分配界面</h2>
|
||
<p class="hint">当前角色 <code>${esc(ME.roles.join(', ') || '无')}</code> 没有对应的工作台。</p>
|
||
<div>已授予的权限:${(ME.permissions || []).map((x) => `<span class="pill">${esc(x)}</span>`).join('') || '<span class="muted">无</span>'}</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------------- 客户 · 客服 ---------------- */
|
||
function renderCustomer(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>智能客服</h2>
|
||
<p class="hint">走真实链路:受理 <code>POST /api/v1/agent-runs</code> → 本进程驱动 Worker → 读取运行结果。
|
||
遇到答不了的问题,Agent 会引导你拨打客服热线。</p>
|
||
<div class="chatlog" id="log"></div>
|
||
<div class="composer">
|
||
<input id="msg" placeholder="例如:我要赎回基金需要多久到账?" onkeydown="if(event.key==='Enter')sendChat()">
|
||
<button class="act primary" onclick="sendChat()">发送</button>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="act" onclick="myProfile()">我的画像</button>
|
||
<button class="act" onclick="myCandidates()">我的画像候选</button>
|
||
<button class="act warn" onclick="askHandover()">转人工</button>
|
||
<span class="muted" id="chat-meta"></span>
|
||
</div>
|
||
</div>
|
||
<div class="panel" id="extra"></div>`;
|
||
addMsg('bot', '你好,我是智能客服助手。可以问我基金、理财、账户规则相关的问题。');
|
||
}
|
||
|
||
function addMsg(who, text, meta) {
|
||
const log = $('log');
|
||
const row = document.createElement('div');
|
||
row.className = 'msg ' + (who === 'me' ? 'me' : '');
|
||
const b = document.createElement('div');
|
||
b.className = 'bubble';
|
||
b.textContent = text;
|
||
row.appendChild(b);
|
||
if (meta) { const m = document.createElement('div'); m.className = 'muted'; m.style.marginLeft = '8px'; m.innerHTML = meta; row.appendChild(m); }
|
||
log.appendChild(row);
|
||
log.scrollTop = log.scrollHeight;
|
||
return row;
|
||
}
|
||
|
||
async function ensureSession() {
|
||
if (CONV) return CONV;
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/conversations',
|
||
body:{ agent_type:'customer_service' } });
|
||
const d = (r.body || {}).data || {};
|
||
CONV = d.session_id || d.id || null;
|
||
if (!CONV) { alert('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
|
||
return CONV;
|
||
}
|
||
|
||
async function sendChat() {
|
||
const box = $('msg');
|
||
const text = box.value.trim();
|
||
if (!text) return;
|
||
addMsg('me', text);
|
||
box.value = '';
|
||
const pending = addMsg('bot', '正在查询资料…');
|
||
$('chat-meta').textContent = '处理中…';
|
||
const sid = await ensureSession();
|
||
if (!sid) { pending.remove(); $('chat-meta').textContent = ''; return; }
|
||
const r = await jpost('/api/chat', { message: text, session_id: sid });
|
||
pending.remove();
|
||
$('chat-meta').textContent = '';
|
||
if (!r.ok) { addMsg('bot', '请求失败:' + pretty(r.body)); return; }
|
||
const tags = `<span class="tag">意图 ${esc(r.intent || '?')}</span>`
|
||
+ (r.transfer ? `<span class="tag warn">已转人工</span>` : '');
|
||
addMsg('bot', r.answer || '(空回答)', tags);
|
||
if (r.transfer) addMsg('bot', '转接原因:' + (r.transfer_reason || '未说明') + '\n如需人工服务,请拨打客服热线。');
|
||
}
|
||
|
||
async function myProfile() {
|
||
const r = await GET('/api/v1/users/me/memory-profile');
|
||
showExtra('我的画像 / 记忆', r);
|
||
}
|
||
|
||
async function myCandidates() {
|
||
const r = await GET('/api/v1/users/me/memory-candidates');
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
let html = `<h2>我的画像候选</h2>
|
||
<p class="hint">客服在对话中识别到的长期偏好会先成为「候选」,必须由你确认(或拒绝)后才会生效。</p>`;
|
||
if (!Array.isArray(items) || !items.length) {
|
||
html += `<div class="muted">暂无候选(HTTP ${r.status})。</div>`;
|
||
} else {
|
||
html += `<table><thead><tr><th>候选 ID</th><th>内容</th><th>状态</th><th>操作</th></tr></thead><tbody>`;
|
||
for (const c of items) {
|
||
html += `<tr><td><code>${esc(c.candidate_id ?? c.id)}</code></td>
|
||
<td>${esc(pretty(c.value ?? c.content ?? c))}</td>
|
||
<td>${esc(c.status)}</td>
|
||
<td>
|
||
<button class="act primary" onclick="decide('${esc(c.candidate_id ?? c.id)}','confirmed')">确认</button>
|
||
<button class="act" onclick="decide('${esc(c.candidate_id ?? c.id)}','rejected')">拒绝</button>
|
||
</td></tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
$('extra').innerHTML = html;
|
||
}
|
||
|
||
async function decide(id, decision) {
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path: `/api/v1/users/me/memory-candidates/${id}/decisions`, body: { decision } });
|
||
alert(`HTTP ${r.status}\n` + pretty(r.body));
|
||
myCandidates();
|
||
}
|
||
|
||
async function askHandover() {
|
||
const sid = await ensureSession();
|
||
if (!sid) return;
|
||
// 该接口只接受 `reason_code`(固定 user_requested)+ `reason_detail`(可选、≤500 字);
|
||
// 传 `reason` 会被 `additionalProperties: false` 判为额外字段,直接 422。
|
||
const detail = prompt('转人工说明(可留空,最多 500 字):', '需要人工协助');
|
||
if (detail === null) return;
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path: `/api/v1/conversations/${sid}/handover-requests`,
|
||
body: { reason_code: 'user_requested', reason_detail: detail.slice(0, 500) } });
|
||
showExtra('转人工结果', r);
|
||
}
|
||
|
||
function showExtra(title, r) {
|
||
$('extra').innerHTML = `<h2>${esc(title)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 当前角色权限不足(平台按设计 fail closed)' : ''}</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 员工 · 风控工作台 ---------------- */
|
||
function renderStaff(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>风控工作台</h2>
|
||
<p class="hint">只对 <code>risk_operator</code> / <code>operator</code> 开放。数据范围 <code>all</code>:
|
||
能看到全部客户的预警。处置类操作会写审计。</p>
|
||
<div class="grid" id="overview"><div class="stat"><div class="n">…</div><div class="l">加载中</div></div></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="act primary" onclick="loadRisk()">刷新总览</button>
|
||
<button class="act warn" onclick="scanRisk()">触发一次扫描</button>
|
||
<button class="act" onclick="dailyReport()">生成日报</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>预警列表</h2>
|
||
<p class="hint">点「处置」可执行确认 / 升级 / 解决 —— 这些是真实写操作,会在审计里留痕。</p>
|
||
<div id="alerts"><div class="muted">加载中…</div></div>
|
||
</div>
|
||
<div class="panel" id="staff-extra"></div>`;
|
||
loadRisk();
|
||
loadAlerts();
|
||
}
|
||
|
||
async function loadRisk() {
|
||
const r = await GET('/api/v1/risk/overview');
|
||
const d = r.body?.data || {};
|
||
const entries = Object.entries(d).filter(([, v]) => typeof v !== 'object');
|
||
$('overview').innerHTML = entries.length
|
||
? entries.map(([k, v]) => `<div class="stat"><div class="n">${esc(v)}</div><div class="l">${esc(k)}</div></div>`).join('')
|
||
: `<div class="stat"><div class="n">${r.status}</div><div class="l">总览返回(详见下方)</div></div>`;
|
||
if (!entries.length) $('staff-extra').innerHTML =
|
||
`<h2>总览原始返回</h2><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
const r = await GET('/api/v1/risk/alerts', { limit: 20 });
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
const box = $('alerts');
|
||
if (!Array.isArray(items) || !items.length) {
|
||
box.innerHTML = `<div class="muted">没有预警数据(HTTP ${r.status})。可先点「触发一次扫描」。</div>`;
|
||
return;
|
||
}
|
||
box.innerHTML = `<table><thead><tr><th>预警号</th><th>客户</th><th>规则</th><th>等级</th><th>状态</th><th>操作</th></tr></thead><tbody>`
|
||
+ items.map((a) => `<tr>
|
||
<td><code>${esc(a.alert_no ?? a.alert_id)}</code></td>
|
||
<td>${esc(a.customer_id)}</td>
|
||
<td>${esc(a.rule_code ?? a.rule ?? '')}</td>
|
||
<td>${esc(a.level ?? a.severity ?? '')}</td>
|
||
<td>${esc(a.status)}</td>
|
||
<td>
|
||
<button class="act" onclick="ack('${esc(a.alert_no ?? a.alert_id)}')">确认</button>
|
||
<button class="act warn" onclick="esc_( '${esc(a.alert_no ?? a.alert_id)}')">升级</button>
|
||
<button class="act primary" onclick="resolve('${esc(a.alert_no ?? a.alert_id)}')">解决</button>
|
||
</td></tr>`).join('')
|
||
+ '</tbody></table>';
|
||
}
|
||
|
||
async function scanRisk() {
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/alerts/scan', body:{} });
|
||
showExtra2('扫描结果', r);
|
||
loadAlerts();
|
||
}
|
||
|
||
async function dailyReport() {
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report', body:{} });
|
||
showExtra2('日报结果', r);
|
||
}
|
||
|
||
async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
|
||
async function esc_(no) { await act_(no, 'escalations', '升级'); }
|
||
async function resolve(no) { await act_(no, 'resolutions', '解决'); }
|
||
|
||
async function act_(no, action, label) {
|
||
if (!confirm(`对预警 ${no} 执行「${label}」?这会写审计。`)) return;
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path: `/api/v1/risk/alerts/${no}/${action}`, body: {} });
|
||
showExtra2(`${label} ${no}`, r);
|
||
loadAlerts();
|
||
}
|
||
|
||
function showExtra2(title, r) {
|
||
$('staff-extra').innerHTML = `<h2>${esc(title)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 权限不足(fail closed)' : ''}</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 员工 · 运营工作台(场外基金) ---------------- */
|
||
function renderOffsite(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>运营工作台 · 场外基金</h2>
|
||
<p class="hint">面向 <code>operator</code>。场外线的服务层用**角色门槛**
|
||
<code>{"operator","risk_operator","admin","super_admin"}</code> 判断,所以主体功能靠角色就通;
|
||
另外给了 <code>financial:nl2sql:read</code>,用于单据字段识别。</p>
|
||
<div class="grid" id="mailbox"><div class="stat"><div class="n">…</div><div class="l">邮箱状态加载中</div></div></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="act primary" onclick="loadMailbox()">刷新邮箱状态</button>
|
||
<button class="act" onclick="loadMails()">拉取邮件列表</button>
|
||
<button class="act warn" onclick="recoverMailbox()">触发邮箱恢复</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>邮件与识别</h2>
|
||
<div id="mails"><div class="muted">未加载</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>单据处理</h2>
|
||
<p class="hint">填单据号(task_id)后可查看识别字段与规则判定,并执行确认 / 重试 / 通知。</p>
|
||
<div class="row">
|
||
<input class="q" id="task" placeholder="单据号 task_id">
|
||
<button class="act" onclick="docFields()">识别字段</button>
|
||
<button class="act" onclick="docRules()">规则结果</button>
|
||
<button class="act primary" onclick="docConfirm()">确认单据</button>
|
||
<button class="act warn" onclick="docRetry()">重试识别</button>
|
||
<button class="act" onclick="docNotify()">创建通知</button>
|
||
</div>
|
||
<div id="offsite-extra"></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>结算统计</h2>
|
||
<div class="row"><button class="act warn" onclick="settle()">重算结算统计</button></div>
|
||
</div>`;
|
||
loadMailbox();
|
||
}
|
||
|
||
async function loadMailbox() {
|
||
const r = await GET('/api/v1/offsite-fund/mailbox-status');
|
||
const d = r.body?.data || {};
|
||
const entries = Object.entries(d).filter(([, v]) => typeof v !== 'object');
|
||
$('mailbox').innerHTML = entries.length
|
||
? entries.map(([k, v]) => `<div class="stat"><div class="n">${esc(v)}</div><div class="l">${esc(k)}</div></div>`).join('')
|
||
: `<div class="stat"><div class="n">${r.status}</div><div class="l">邮箱状态</div></div>`;
|
||
if (!entries.length) showOffsite('邮箱状态原始返回', r);
|
||
}
|
||
|
||
async function loadMails() {
|
||
const r = await GET('/api/v1/offsite-fund/mails', { limit: 20 });
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
$('mails').innerHTML = Array.isArray(items) && items.length
|
||
? `<table><thead><tr><th>邮件 ID</th><th>主题</th><th>状态</th><th>操作</th></tr></thead><tbody>`
|
||
+ items.map((m) => `<tr><td><code>${esc(m.mail_id ?? m.id)}</code></td>
|
||
<td>${esc(m.subject ?? '')}</td><td>${esc(m.status ?? '')}</td>
|
||
<td><button class="act" onclick="mailFields('${esc(m.mail_id ?? m.id)}')">识别字段</button>
|
||
<button class="act warn" onclick="mailDelete('${esc(m.mail_id ?? m.id)}')">删除</button></td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: `<div class="muted">HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}</div><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function mailFields(id) {
|
||
showOffsite('邮件识别字段 ' + id, await GET(`/api/v1/offsite-fund/mails/${id}/recognition-fields`));
|
||
}
|
||
async function mailDelete(id) {
|
||
if (!confirm('删除邮件 ' + id + '?这是写操作。')) return;
|
||
showOffsite('删除邮件 ' + id, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/mails/${id}/deletions`, body:{} }));
|
||
loadMails();
|
||
}
|
||
async function recoverMailbox() {
|
||
if (!confirm('触发邮箱恢复?这是写操作。')) return;
|
||
showOffsite('邮箱恢复', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries', body:{} }));
|
||
}
|
||
async function docFields() {
|
||
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
|
||
showOffsite('识别字段 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/nl2sql-fields`));
|
||
}
|
||
async function docRules() {
|
||
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
|
||
showOffsite('规则结果 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/rule-results`));
|
||
}
|
||
async function docConfirm() {
|
||
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
|
||
if (!confirm('确认单据 ' + t + '?这是写操作,会进审计。')) return;
|
||
showOffsite('确认单据 ' + t, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`, body:{} }));
|
||
}
|
||
async function docRetry() {
|
||
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
|
||
showOffsite('重试识别 ' + t, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`, body:{} }));
|
||
}
|
||
async function docNotify() {
|
||
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
|
||
if (!confirm('为单据 ' + t + ' 创建通知?')) return;
|
||
showOffsite('创建通知 ' + t, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`, body:{} }));
|
||
}
|
||
async function settle() {
|
||
if (!confirm('重算结算统计?这是写操作。')) return;
|
||
showOffsite('结算重算', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate', body:{} }));
|
||
}
|
||
function showOffsite(title, r) {
|
||
$('offsite-extra').innerHTML = `<h2>${esc(title)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 权限不足(fail closed)' : ''}</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 管理员 · 权限界面 ---------------- */
|
||
function renderAdmin(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>角色与权限</h2>
|
||
<p class="hint">这是平台真实的 RBAC 数据(<code>GET /api/v1/admin/roles</code> 等四个只读接口)。
|
||
平台**只提供只读查询**:改权限要发布新的 <code>config_release</code>,不提供直接写接口 —— 这是设计,不是缺失。</p>
|
||
<div class="grid" id="roles"><div class="stat"><div class="n">…</div><div class="l">加载中</div></div></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<input class="q" id="userid" placeholder="按用户 ID 查角色,例如 9001">
|
||
<button class="act primary" onclick="userRoles()">查询该用户的角色</button>
|
||
</div>
|
||
<div id="user-roles"></div>
|
||
</div>
|
||
<div class="panel" id="role-detail"></div>
|
||
<div class="panel">
|
||
<h2>审计流水</h2>
|
||
<p class="hint">谁做了什么、有没有被拒,都记在这里(<code>GET /api/v1/admin/audit-records</code>)。</p>
|
||
<div class="row"><input class="q" id="audit-limit" value="20" style="width:80px">
|
||
<button class="act" onclick="loadAudit()">刷新</button></div>
|
||
<div id="audit"><div class="muted">加载中…</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>客服转人工工单</h2>
|
||
<p class="hint">只读队列;接口不返回客户标识与原始会话正文。</p>
|
||
<div class="row"><button class="act" onclick="loadTickets()">刷新工单</button></div>
|
||
<div id="tickets"><div class="muted">未加载</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>知识库</h2>
|
||
<div class="row"><button class="act" onclick="loadKnowledge()">列出知识文档</button></div>
|
||
<div id="knowledge"><div class="muted">未加载</div></div>
|
||
</div>`;
|
||
loadRoles();
|
||
loadAudit();
|
||
}
|
||
|
||
async function loadRoles() {
|
||
const r = await GET('/api/v1/admin/roles');
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
if (!Array.isArray(items) || !items.length) {
|
||
$('roles').innerHTML = `<div class="stat"><div class="n">${r.status}</div><div class="l">角色查询返回</div></div>`;
|
||
$('role-detail').innerHTML = `<h2>原始返回</h2><pre>${esc(pretty(r.body))}</pre>`;
|
||
return;
|
||
}
|
||
$('roles').innerHTML = items.map((x) => `<div class="stat" style="cursor:pointer"
|
||
onclick="roleDetail('${esc(x.role_code)}')">
|
||
<div class="n">${esc(x.permission_count ?? '?')}</div>
|
||
<div class="l">${esc(x.role_name || x.role_code)} · <code>${esc(x.role_code)}</code>
|
||
· ${esc(x.user_count ?? 0)} 人</div></div>`).join('');
|
||
$('role-detail').innerHTML = '<h2>点上面的角色卡片看它的权限</h2>';
|
||
}
|
||
|
||
async function roleDetail(code) {
|
||
const [detail, perms] = await Promise.all([
|
||
GET(`/api/v1/admin/roles/${code}`),
|
||
GET(`/api/v1/admin/roles/${code}/permissions`),
|
||
]);
|
||
const items = perms.body?.data?.items || perms.body?.data || [];
|
||
const list = Array.isArray(items) ? items : [];
|
||
$('role-detail').innerHTML = `
|
||
<h2>角色 <code>${esc(code)}</code> 的权限(${list.length} 项)</h2>
|
||
<p class="hint">HTTP ${perms.status}。下面是这个角色实际能通过的权限码 ——
|
||
客户点开风控接口会 403,就是因为这里没有对应的码。</p>
|
||
<div>${list.map((p) => `<span class="pill">${esc(p.permission_code ?? p.code ?? p)}</span>`).join('') || '<span class="muted">无</span>'}</div>
|
||
<details style="margin-top:12px"><summary class="muted">角色详情原始返回</summary>
|
||
<pre>${esc(pretty(detail.body))}</pre></details>`;
|
||
}
|
||
|
||
async function userRoles() {
|
||
const id = $('userid').value.trim() || '9001';
|
||
const r = await GET(`/api/v1/admin/users/${id}/roles`);
|
||
$('user-roles').innerHTML = `<p class="hint">HTTP ${r.status}</p><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function loadAudit() {
|
||
const limit = $('audit-limit').value.trim() || '20';
|
||
const r = await GET('/api/v1/admin/audit-records', { limit });
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
const box = $('audit');
|
||
if (!Array.isArray(items) || !items.length) {
|
||
box.innerHTML = `<div class="muted">暂无记录(HTTP ${r.status})。</div><pre>${esc(pretty(r.body))}</pre>`;
|
||
return;
|
||
}
|
||
box.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>操作者</th><th>结果</th></tr></thead><tbody>`
|
||
+ items.map((a) => `<tr><td>${esc(a.created_at)}</td><td><code>${esc(a.action_type)}</code></td>
|
||
<td>${esc(a.actor_id ?? a.actor_type)}</td><td>${esc(a.result ?? '')}</td></tr>`).join('')
|
||
+ '</tbody></table>';
|
||
}
|
||
|
||
async function loadTickets() {
|
||
const r = await GET('/api/v1/admin/customer-service/handover-tickets', { limit: 20 });
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
const box = $('tickets');
|
||
box.innerHTML = Array.isArray(items) && items.length
|
||
? `<table><thead><tr><th>工单号</th><th>来源</th><th>优先级</th><th>原因</th><th>状态</th></tr></thead><tbody>`
|
||
+ items.map((t) => `<tr><td><code>${esc(t.ticket_no)}</code></td><td>${esc(t.source_agent)}</td>
|
||
<td>${esc(t.priority)}</td><td>${esc(t.reason ?? '')}</td><td>${esc(t.status)}</td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: `<div class="muted">HTTP ${r.status}${r.status === 403 ? ' —— 需要 handover:read(admin 已授予)' : ''}</div>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function loadKnowledge() {
|
||
const r = await GET('/api/v1/knowledge/list');
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
$('knowledge').innerHTML = Array.isArray(items) && items.length
|
||
? `<table><thead><tr><th>ID</th><th>标题</th><th>状态</th></tr></thead><tbody>`
|
||
+ items.slice(0, 30).map((k) => `<tr><td><code>${esc(k.knowledge_id ?? k.id)}</code></td>
|
||
<td>${esc(k.title ?? '')}</td><td>${esc(k.status ?? '')}</td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: `<div class="muted">HTTP ${r.status}</div><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 投顾 ---------------- */
|
||
function renderAdvisor(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>投顾工作台</h2>
|
||
<p class="hint">面向 <code>advisor</code>。权限已补齐:工作流 + 行情/知识检索/客户画像
|
||
+ 推广材料 + NL2SQL。方案类接口需要真实产品披露文件与合同数据,缺数据时会明确报错而不是编造。</p>
|
||
<div class="row">
|
||
<input class="q" id="cust" placeholder="客户 ID,例如 9001">
|
||
<button class="act primary" onclick="goal()">查投资目标</button>
|
||
<button class="act" onclick="published()">已发布方案</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>组合分析 / 资产配置</h2>
|
||
<p class="hint">这两个接口的请求体是**空 JSON 对象** <code>{}</code>(schema 里
|
||
<code>additionalProperties: false</code>),多传字段会被判为报文非法。</p>
|
||
<div class="row">
|
||
<button class="act primary" onclick="portfolioAnalysis()">跑组合分析</button>
|
||
<button class="act" onclick="assetAllocation()">生成资产配置</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>推广材料</h2>
|
||
<p class="hint">这条线此前因为 <code>promotion:*</code> 四个权限码在库里根本不存在而整体 403,现已补齐。
|
||
创建任务需要产品名、材料标题与风格代码。</p>
|
||
<div class="row">
|
||
<input class="q" id="promo-name" placeholder="产品名称" value="南方科技稳健增利">
|
||
<input class="q" id="promo-title" placeholder="材料标题" value="稳健增利推介材料">
|
||
<input class="q" id="promo-style" placeholder="风格代码" value="steady_professional" style="width:170px">
|
||
<button class="act primary" onclick="promoCreate()">创建推广材料</button>
|
||
</div>
|
||
<div class="row">
|
||
<input class="q" id="promo-no" placeholder="按单号查询,例如 PM-...">
|
||
<button class="act" onclick="promoGet()">查询</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel" id="advisor-extra"></div>`;
|
||
goal();
|
||
}
|
||
|
||
async function promoCreate() {
|
||
const name = $('promo-name').value.trim();
|
||
const title = $('promo-title').value.trim();
|
||
const style = $('promo-style').value.trim();
|
||
if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
|
||
if (!confirm(`创建推广材料任务?\n产品:${name}\n标题:${title}\n风格:${style}`)) return;
|
||
showAdv('创建推广材料', await jpost('/api/call', {
|
||
method:'POST', path:'/api/v1/fund-promotion-materials',
|
||
body:{ product_name: name, material_title: title, style_code: style } }));
|
||
}
|
||
|
||
async function promoGet() {
|
||
const no = $('promo-no').value.trim();
|
||
if (!no) return alert('请先填单号');
|
||
showAdv('推广材料 ' + no, await GET(`/api/v1/fund-promotion-materials/${no}`));
|
||
}
|
||
|
||
async function portfolioAnalysis() {
|
||
if (!confirm('跑一次组合分析?(请求体为空对象)')) return;
|
||
showAdv('组合分析', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/advisor/portfolio-analysis', body:{} }));
|
||
}
|
||
|
||
async function assetAllocation() {
|
||
if (!confirm('生成一次资产配置?(请求体为空对象)')) return;
|
||
showAdv('资产配置', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/advisor/asset-allocation', body:{} }));
|
||
}
|
||
|
||
function showAdv(title, r) {
|
||
$('advisor-extra').innerHTML = `<h2>${esc(title)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 权限不足' : ''}
|
||
${r.status === 422 ? ' —— 报文格式不对(看下面 error 的字段提示)' : ''}</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function goal() {
|
||
// 投顾要看的是**客户**的目标,所以走 `customers/{id}` 变体 —— 服务层会按
|
||
// `customer_id == 自己` 决定拼 `:self` 还是 `:customer` 权限码,后者要求
|
||
// `data_scope=own_customers` 且该客户确实在这个投顾名下,否则返回 404。
|
||
const id = ($('cust')?.value.trim() || '9001');
|
||
const path = `/api/v1/advisor/customers/${id}/investment-goals/current`;
|
||
const r = await GET(path);
|
||
const hint = r.status === 403
|
||
? ' —— 需要 investment-goal:read:customer(scope=own_customers)'
|
||
: (r.status === 404 ? ' —— 该客户不在你名下,或还没有投资目标' : '');
|
||
$('advisor-extra').innerHTML = `<h2>投资目标</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${hint} · <code>${esc(path)}</code></p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function published() {
|
||
const r = await GET('/api/v1/advisor/recommendations/published');
|
||
$('advisor-extra').innerHTML = `<h2>已发布方案</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b></p><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="统一登录门户(按角色分流)")
|
||
parser.add_argument("--port", type=int, default=8101)
|
||
parser.add_argument("--base-url", default=None, help="指向已在运行的服务;省略则进程内直挂平台 app")
|
||
args = parser.parse_args()
|
||
|
||
state = Portal(args.base_url)
|
||
mode = args.base_url or "进程内(走真实中间件与鉴权栈)"
|
||
print(f"登录门户:http://127.0.0.1:{args.port} 模式={mode}")
|
||
print("演示账号:cust_t/123456 · risk_t/666666 · admin_t/88888888 · advisor_t/abc12345")
|
||
print("客服对话前请停掉常驻 Worker;账号需先跑 seed_test_rbac.py + set_user_password.py。")
|
||
uvicorn.run(build_portal(state), host="127.0.0.1", port=args.port, log_level="warning")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|