Files
group_fqcd_jr/tools/portal.py
T
lzf_0626 2525fbdad9 feat(portal): 推广材料补成完整流程向导,并改为按 body.code 判成败
- 投顾视图:创建 -> 补结构化输入(含七项费率) -> 生成 -> 投递 -> 查询
  生成后自动把 material_version_id 填进投递框
- 管理员视图:新增推广材料审核(promotion:review 只给管理员,职责分离)
- 统一结果渲染改看 body.code:本平台业务失败也返回 HTTP 200
  (生成失败是 HTTP 200 + code=422 + '材料内容未通过合规校验')
- 输入骨架刻意不预填业绩(performance_info 有 show_* 开关可关),
  费率填占位文本以便跑通流程;真实材料必须换成真实值
2026-09-12 15:45:09 +08:00

1332 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一登录门户:一个登录入口,按角色分流到不同的工作台。
**和 `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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
<div class="panel">
<h2>推广材料审核</h2>
<p class="hint">审核需要 <code>promotion:review</code>,只给管理员 —— 投顾没有这个码(职责分离),
所以"生成"之后必须由管理员在这里通过,投顾才能投递。<br>
<b>版本号从哪来</b>:投顾执行「生成」后,响应里的 <code>material_version_id</code> 就是它
(状态 <code>pending_review</code>)。注意本平台<b>失败时也返回 HTTP 200</b>,
判断成败要看 <code>body.code</code>。</p>
<div class="row">
<input class="q" id="promo-task" placeholder="任务单号 PM-..." style="width:220px">
<button class="act" onclick="admPromoLookup()">查任务(自动填版本号)</button>
</div>
<div class="row">
<input class="q" id="adm-version" placeholder="material_version_id" style="width:150px">
<input class="q" id="adm-comment" placeholder="审核意见(可选)" style="width:240px">
<button class="act primary" onclick="admReview('approved')">通过</button>
<button class="act warn" onclick="admReview('revision_requested')">退回修改</button>
<button class="act warn" onclick="admReview('rejected')">拒绝</button>
</div>
<div id="promo-admin-extra"></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>`;
}
async function admPromoLookup() {
const t = $('promo-task').value.trim();
if (!t) return alert('请填任务单号');
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
const mv = ((r.body || {}).data || {}).material_version || {};
if (mv.id) $('adm-version').value = mv.id;
$('promo-admin-extra').innerHTML = `<h2>任务 ${esc(t)}</h2>
<p class="hint">HTTP <b>${r.status}</b>(管理员不受"已投递"限制,能直接看到版本)</p>
<pre>${esc(pretty(r.body))}</pre>`;
}
async function admReview(decision) {
const t = $('promo-task').value.trim();
const vid = parseInt($('adm-version').value.trim(), 10);
if (!t) return alert('请填任务单号');
if (!vid) return alert('请填 material_version_id(可先点「查任务」自动填)');
const comment = $('adm-comment').value.trim() || null;
if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return;
const r = await jpost('/api/call', { method:'POST',
path:`/api/v1/fund-promotion-materials/${t}/reviews`,
body:{ material_version_id: vid, decision, comment } });
$('promo-admin-extra').innerHTML = `<h2>审核结果(${esc(decision)})</h2>
<p class="hint">HTTP <b>${r.status}</b></p><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,现已补齐。
<b>顺序不能跳</b>:创建 → <b>补结构化输入</b> → 生成 → <b>管理员审核</b> → 投递 → 才能查询。
跳过任何一步,查询都会返回「该材料尚未发送给当前投顾」—— 那是合规设计,不是故障。</p>
<div class="row">
<input class="q" id="promo-task" placeholder="任务单号 PM-..." style="width:220px">
<button class="act" onclick="promoGet()">查询任务</button>
<button class="act" onclick="promoChecks()">合规检查结果</button>
</div>
<div class="row">
<input class="q" id="promo-name" value="南方科技稳健增利" placeholder="产品名称">
<input class="q" id="promo-title" value="稳健增利推介材料" placeholder="材料标题">
<input class="q" id="promo-style" value="steady_professional" placeholder="风格代码" style="width:160px">
<button class="act primary" onclick="promoCreate()">① 创建任务</button>
</div>
<div class="field">
<label>② 结构化输入(PUT inputs,六个块都必填;下面是骨架,把「待填写」补上再提交)
<button class="act" onclick="promoSaveInputs()">保存输入</button></label>
<textarea id="promo-inputs"></textarea>
</div>
<div class="row">
<button class="act" onclick="promoGenerate('pptx')">③ 生成 PPTX</button>
<button class="act" onclick="promoGenerate('pdf')">③ 生成 PDF</button>
<span class="muted">审核在「权限管理」页用管理员做(投顾没有 promotion:review —— 职责分离)</span>
</div>
<div class="row">
<input class="q" id="promo-version" placeholder="material_version_id" style="width:150px">
<input class="q" id="promo-advisors" value="9020" placeholder="投递给哪些投顾(逗号分隔 id)" style="width:260px">
<button class="act primary" onclick="promoDeliver()">⑤ 投递</button>
</div>
</div>
<div class="panel" id="advisor-extra"></div>`;
goal();
promoSkeleton();
}
function promoSkeleton() {
// 两个刻意的选择:
// 1) 不预填任何业绩数字 —— `performance_info` 的业绩字段全部可选且带
// `show_product_performance` 开关,关掉即可;编造业绩是合规红线。
// 2) 费率七项**必须非空**:合规规则 `fee_structure.incomplete`(severity=block)
// 会拦下生成,任务会被置成 `compliance_failed`。这里填占位文本让它先跑通流程,
// 真实材料必须换成真实费率 —— 页面上也这么提示。
$('promo-inputs').value = JSON.stringify({
product_info: {
fund_type: "混合型",
operation_mode: "契约型开放式",
investment_objective: "在严格控制风险的前提下,力争实现基金资产的长期稳健增值",
benchmark: "沪深300指数收益率×60%+中债综合指数收益率×40%",
risk_level: "R3"
},
manager_info: {
management_company: "南方科技基金管理有限公司",
manager_name: "待填写",
registration_code: "待填写"
},
team_info: { team_description: "待填写" },
strategy_info: {
investment_scope: "本基金投资于国内依法发行上市的股票、债券等金融工具",
strategy: "自上而下的大类资产配置与自下而上的个股精选相结合",
restrictions: "不投资于法律法规禁止的标的"
},
fee_structure: {
subscription_fee: "待填写", purchase_fee: "待填写", redemption_fee: "待填写",
sales_service_fee: "待填写", management_fee: "待填写", custody_fee: "待填写",
client_maintenance_fee: "待填写"
},
performance_info: { show_product_performance: false, show_manager_performance: false },
risk_disclosure: { special_risks: ["市场风险", "流动性风险"], additional_notes: "详见基金合同与招募说明书" },
source_notes: {}
}, null, 2);
}
function promoTaskNo() {
const t = $('promo-task').value.trim();
if (!t) { alert('请先填任务单号(创建任务后会返回)'); return null; }
return t;
}
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('产品名、材料标题、风格代码都要填');
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials',
body:{ product_name: name, material_title: title, style_code: style } });
const d = (r.body || {}).data || {};
if (d.task_no) $('promo-task').value = d.task_no;
showAdv('① 创建任务' + (d.task_no ? ':' + d.task_no : ''), r);
}
async function promoSaveInputs() {
const t = promoTaskNo(); if (!t) return;
let body;
try { body = JSON.parse($('promo-inputs').value); }
catch (e) { return alert('结构化输入不是合法 JSON:' + e.message); }
const r = await jpost('/api/call', { method:'PUT',
path:`/api/v1/fund-promotion-materials/${t}/inputs`, body });
showAdv('② 保存结构化输入 ' + t, r);
}
async function promoGenerate(fmt) {
const t = promoTaskNo(); if (!t) return;
if (!confirm(`生成 ${fmt.toUpperCase()}?这一步会产出材料版本。`)) return;
const r = await jpost('/api/call', { method:'POST',
path:`/api/v1/fund-promotion-materials/${t}/generations`, body:{ output_formats:[fmt] } });
const body = r.body || {};
const d = body.data || {};
if (d.material_version_id) $('promo-version').value = d.material_version_id;
showAdv('③ 生成 ' + fmt.toUpperCase() + ' ' + t, r);
if (d.material_version_id) {
$('advisor-extra').insertAdjacentHTML('beforeend',
`<p class="hint">已产出 <code>material_version_id=${d.material_version_id}</code>,
状态 <b>pending_review</b>(所以此刻查询任务看不到它 —— get_task 只返回 approved/sent)。
下一步请<b>管理员</b>在「权限管理」页对它审核通过,之后你才能投递;版本号已自动填进上面的投递框。</p>`);
} else if (body.code) {
$('advisor-extra').insertAdjacentHTML('beforeend',
`<p class="hint bad">生成未成功(body.code=${esc(body.code)})。最常见的原因是
<b>费率七项没填全</b>:合规规则 <code>fee_structure.incomplete</code> 会把任务置成
compliance_failed。另注意:这个平台失败时也返回 HTTP 200,要看 body.code。</p>`);
}
}
async function promoDeliver() {
const t = promoTaskNo(); if (!t) return;
const versionId = parseInt($('promo-version').value.trim(), 10);
if (!versionId) return alert('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x);
if (!ids.length) return alert('请填要投递的投顾 id');
if (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return;
const r = await jpost('/api/call', { method:'POST',
path:`/api/v1/fund-promotion-materials/${t}/deliveries`,
body:{ material_version_id: versionId, advisor_ids: ids, delivery_channel:'internal_record' } });
showAdv('⑤ 投递 ' + t, r);
}
async function promoGet() {
const t = promoTaskNo(); if (!t) return;
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
const err = ((r.body || {}).error) || {};
let extra = '';
if (r.status === 404 && String(err.message || '').includes('尚未发送')) {
extra = ' —— 材料存在但还没投递给你:请确认已完成「补输入 → 生成 → 管理员审核 → 投递」。'
+ '版本号可以在下面的原始返回里找,或让管理员查这个任务。';
}
showAdv('查询任务 ' + t, r);
if (extra) $('advisor-extra').insertAdjacentHTML('beforeend', `<p class="hint">${esc(extra)}</p>`);
}
async function promoChecks() {
const t = promoTaskNo(); if (!t) return;
showAdv('合规检查 ' + t,
await GET(`/api/v1/fund-promotion-materials/${t}/compliance-checks`));
}
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 = result(title, r);
}
// 统一的结果渲染:**必须看 body.code**,不能只看 HTTP 状态。
// 这个平台把业务失败也放在 HTTP 200 里(例如生成材料失败是
// `HTTP 200 + body.code=422 + message=材料内容未通过合规校验`),
// 只看 HTTP 会把它显示成成功。
function result(title, r) {
const body = (r && r.body) || {};
const code = body.code;
const err = body.error || {};
const biz = (code === undefined || code === null) ? null : Number(code);
const bad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
const bits = [`HTTP <b>${r.status}</b>`];
if (biz !== null) bits.push(`body.code <b>${biz}</b>`);
if (err.code) bits.push(`error <b>${esc(err.code)}</b>`);
let extra = '';
if (err.message) extra = esc(err.message);
else if (bad && body.message) extra = esc(body.message);
if (!bad) {
if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
}
return `<h2>${esc(title)}</h2>
<p class="hint ${bad ? 'bad' : ''}">${bits.join(' · ')}${extra ? ' —— ' + extra : ''}</p>
<pre>${esc(pretty(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()