Files
group_fqcd_jr/tools/portal.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

1902 lines
90 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.
"""统一登录门户(**调试/联调工具**,不是产品前端)。
> ⚠️ **定位说明(2026-09-13)**:正式前端已由组员交付并合入主干,位于
> `app/static/portal/`,通过 **`/portal/`** 访问(访客 / 客户 / 员工 / 风控四套页面;
> 端点表见 `app/static/portal/common/api-client.js`)。
> **本文件不是产品的一部分**,它是平台侧保留的**跨角色快速联调工具**:
> 一个页面里用五个演示账号登录、直接点各模块接口、看原始响应与错误码。
> 涉及具体页面与交互的工作请改正式前端,不要再往这里加功能。
---
一个登录入口,按角色分流到不同的工作台。
**和 `tools/chat_console.py` 的区别**:那个控制台没有登录、身份写死在命令行里;
本门户**走真实的 `POST /api/v1/auth/tokens`**,令牌只存活在本进程,浏览器拿不到,
然后按登录者的**真实角色**决定进哪个界面 —— 也就是照着"客户 / 员工 / 管理员"三种
使用者的实际路径走一遍,而不是逐个接口点着测。
| 登录者的角色 | 进入的界面 | 主要能力 |
|---|---|---|
| `customer` | **客服** | 与客服 Agent 对话(真实受理 + 进程内驱动 Worker)、转人工、查看并确认自己的画像候选 |
| `risk_operator` / `operator` | **员工工作台** | 风控总览、预警列表与详情、预警扫描、确认/升级/解决等处置、生成日报 |
| `admin` / `super_admin` | **权限界面** | 角色清单、每个角色的权限明细、按用户查角色、审计流水、客服转人工工单、知识库清单 |
| `advisor` | **投顾工作台** | 当前投资目标、已发布方案、组合分析 |
角色从 `IdentityService.resolve()` 现查(不是从令牌里读)—— 所以改了库里角色,重登即生效。
跑法:
```powershell
D:\\conda\\envs\\jr_py313\\python.exe tools\\portal.py
# 浏览器打开 http://127.0.0.1:8101
```
**前置**:
1. MySQL / Redis 可用(平台启动时会连);
2. 演示账号能登录:先 `python tools/seed_test_rbac.py`,再 `python tools/set_user_password.py`;
3. **客服对话前请停掉常驻 Worker**(`python -m app.worker`)—— 本门户自己驱动这一条 run,
常驻 Worker 会与它抢 `agent_run` 队列,表现为页面一直转圈。
**权限问题的表现是设计如此**:某个按钮点了返回 403,说明这个角色的权限**本来就不够**
(例如客户点风控、员工点权限管理)。平台一律 fail closed,门户把 403 原样显示出来,
不做任何前端隐藏式"代为授权"。
"""
from __future__ import annotations
import argparse
import sys
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
sys.stdout.reconfigure(errors="replace")
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
CUSTOMER_AGENT = "customer_service"
DEMO_ACCOUNTS: tuple[tuple[str, str, str], ...] = (
("cust_t", "123456", "客户"),
("risk_t", "666666", "员工(风控专员)"),
("admin_t", "88888888", "管理员"),
("advisor_t", "abc12345", "投资顾问"),
("offsite_t", "offsite123", "员工(运营专员)"),
)
#: 角色 → 界面。按优先级从高到低匹配,多角色者进权限最高的那个界面。
VIEW_BY_ROLE: tuple[tuple[str, str], ...] = (
("super_admin", "admin"),
("admin", "admin"),
("risk_operator", "staff"),
("operator", "offsite"), # 运营走场外基金这条线,不是风控那个台
("advisor", "advisor"),
("customer", "customer"),
)
VIEW_LABELS = {
"customer": "客服",
"staff": "风控工作台",
"offsite": "运营工作台",
"admin": "权限管理",
"advisor": "投顾工作台",
"unknown": "未分配界面",
}
def view_for(roles: tuple[str, ...] | list[str]) -> str:
for role, view in VIEW_BY_ROLE:
if role in roles:
return view
return "unknown"
class Platform:
"""共享的平台连接:一个 app、一个 client、一个 lifespan,所有会话共用。
分开的理由:每个会话各自 `create_app()` 会把平台实例化好几遍(连接池、后台任务都翻倍);
而"多个浏览器标签用不同身份"又是演示时的刚需 —— 于是把**连接**共享、把**令牌**按会话分开。
"""
def __init__(self, base_url: str | None) -> None:
self.base_url = base_url
self.application: FastAPI | None = None
self._client: httpx.AsyncClient | None = None
self._lifespan: Any = None
self.environment: dict[str, Any] = {}
async def client(self) -> httpx.AsyncClient:
if self._client is not None:
return self._client
if self.base_url:
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0)
self.environment = {"mode": "外部服务", "target": self.base_url}
return self._client
from app.core.config import get_settings
from app.main import create_app
application = create_app()
self.application = application
self._lifespan = application.router.lifespan_context(application)
await self._lifespan.__aenter__()
self._client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=application), base_url="http://platform", timeout=120.0
)
dsn = get_settings().mysql_dsn
self.environment = {"mode": "进程内", "mysql": dsn.split("@")[-1] if "@" in dsn else dsn}
return self._client
async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
if self._lifespan is not None:
await self._lifespan.__aexit__(None, None, None)
self._lifespan = None
async def request(
self,
method: str,
path: str,
*,
token: str | None,
query: dict[str, Any] | None = None,
body: Any = None,
) -> dict[str, Any]:
if not token:
return {"status": 401, "body": {"error": {"code": "NOT_LOGGED_IN", "message": "请先登录"}}}
client = await self.client()
headers = {"Authorization": f"Bearer {token}"}
# 幂等头:平台的写接口要求 `Idempotency-Key`(16-128 位 ASCII),
# 漏了会被 `AGENT_INPUT_INVALID: 必须提供 16-128 位 ASCII Idempotency-Key` 拒绝。
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
headers["Idempotency-Key"] = uuid.uuid4().hex
try:
response = await client.request(
method.upper(),
path,
params=query or None,
json=body,
headers=headers,
)
except Exception as exc:
return {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
return {"status": response.status_code, "body": _json(response)}
class Session:
"""一次登录会话:只持有令牌与身份。"""
def __init__(self, username: str, user_id: str, token: str) -> None:
self.username = username
self.user_id = user_id
self.token = token
self.roles: tuple[str, ...] = ()
self.permissions: tuple[str, ...] = ()
def as_dict(self, environment: dict[str, Any]) -> dict[str, Any]:
view = view_for(self.roles)
return {
"username": self.username,
"user_id": self.user_id,
"roles": list(self.roles),
"permissions": list(self.permissions),
"view": view,
"view_label": VIEW_LABELS[view],
"logged_in": True,
"environment": environment,
}
class Portal:
"""多会话门户:`X-Session` 头区分标签页,令牌始终留在服务端。"""
def __init__(self, base_url: str | None) -> None:
self.platform = Platform(base_url)
self.sessions: dict[str, Session] = {}
self.error: str | None = None
async def login(self, session_id: str, username: str, password: str) -> dict[str, Any]:
client = await self.platform.client()
response = await client.post(
"/api/v1/auth/tokens", json={"username": username, "password": password}
)
payload = _json(response)
if response.status_code != 200:
return {"ok": False, "status": response.status_code, "body": payload}
data = (payload.get("data") or {}) if isinstance(payload, dict) else {}
token = data.get("access_token") or data.get("token")
if not token:
return {"ok": False, "status": response.status_code, "body": payload}
session = Session(username, str(data.get("user_id") or _sub_of(token) or ""), token)
await self._resolve(session)
self.sessions[session_id] = session
return {"ok": True, "me": session.as_dict(self.platform.environment)}
async def _resolve(self, session: Session) -> None:
"""角色与权限现查库 —— 与平台每请求解析的口径一致(令牌里只有 sub)。"""
from app.core.contracts import RequestContext
from app.service.identity_service import IdentityService
context = await IdentityService().resolve(
RequestContext(user_id=session.user_id, trace_id=str(uuid.uuid4()))
)
session.roles = tuple(context.roles)
session.permissions = tuple(sorted(context.permissions))
def logout(self, session_id: str) -> None:
self.sessions.pop(session_id, None)
def me(self, session_id: str) -> dict[str, Any]:
session = self.sessions.get(session_id)
data = (
session.as_dict(self.platform.environment)
if session
else {"logged_in": False, "environment": self.platform.environment}
)
data["accounts"] = [
{"username": u, "password": p, "label": label} for u, p, label in DEMO_ACCOUNTS
]
data["startup_error"] = self.error
return data
async def call(
self,
session_id: str,
method: str,
path: str,
query: dict[str, Any] | None = None,
body: Any = None,
) -> dict[str, Any]:
session = self.sessions.get(session_id)
return await self.platform.request(
method, path, token=session.token if session else None, query=query, body=body
)
async def chat(self, session_id: str, message: str, chat_session: str) -> dict[str, Any]:
"""客服对话:真实受理 → 本进程驱动这一条 run → 取结果。"""
from app.worker.runtime import WorkerRuntime
accepted = await self.call(
session_id,
"POST",
"/api/v1/agent-runs",
body={
"agent_type": CUSTOMER_AGENT,
"message": message,
"session_id": chat_session,
"idempotency_key": uuid.uuid4().hex,
},
)
if accepted["status"] != 202:
return {"ok": False, "status": accepted["status"], "body": accepted["body"]}
run_id = ((accepted["body"] or {}).get("data") or {}).get("run_id")
if not run_id:
return {"ok": False, "status": accepted["status"], "body": accepted["body"]}
await WorkerRuntime().execute(run_id)
detail = await self.call(session_id, "GET", f"/api/v1/agent-runs/{run_id}")
data = (detail["body"] or {}).get("data") or {}
result = data.get("result") or {}
raw_intent = result.get("intent")
intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent
return {
"ok": True,
"run_id": run_id,
"status": data.get("status"),
"answer": str(result.get("content") or ""),
"intent": intent or "",
"transfer": bool(result.get("transfer_required")),
"transfer_reason": result.get("transfer_reason") or "",
}
def _json(response: httpx.Response) -> Any:
try:
return response.json()
except Exception:
return {"_raw": response.text[:3000]}
def _sub_of(token: str) -> str | None:
import jwt
try:
return str(jwt.decode(token, options={"verify_signature": False}).get("sub"))
except Exception:
return None
def build_portal(state: Portal) -> FastAPI:
# 注意:`Request` 必须在**模块顶层**导入。本文件启用了
# `from __future__ import annotations`,注解在此处是字符串,FastAPI 解析
# `request: Request` 时只在模块全局里找类型名 —— 写成函数内 import 会被
# 当成"必填 query 参数 request",所有请求直接 422,且报错完全看不出是这个原因。
portal = FastAPI(title="统一登录门户")
def sid(request: Request) -> str:
return request.headers.get("X-Session") or "default"
@portal.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
return HTMLResponse(PAGE)
@portal.get("/api/me")
async def me(request: Request) -> JSONResponse:
return JSONResponse(state.me(sid(request)))
@portal.post("/api/login")
async def login(request: Request, payload: dict[str, Any]) -> JSONResponse:
try:
result = await state.login(
sid(request), str(payload.get("username") or ""), str(payload.get("password") or "")
)
state.error = None
except Exception as exc:
state.error = f"{type(exc).__name__}: {exc}"
result = {"ok": False, "status": 0, "body": {"error": {"message": state.error}}}
return JSONResponse(result)
@portal.post("/api/logout")
async def logout(request: Request) -> JSONResponse:
state.logout(sid(request))
return JSONResponse({"ok": True})
@portal.post("/api/call")
async def call(request: Request, payload: dict[str, Any]) -> JSONResponse:
try:
result = await state.call(
sid(request),
str(payload.get("method") or "GET"),
str(payload.get("path") or "/"),
payload.get("query"),
payload.get("body"),
)
except Exception as exc:
result = {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
return JSONResponse(result)
@portal.post("/api/chat")
async def chat(request: Request, payload: dict[str, Any]) -> JSONResponse:
message = str(payload.get("message") or "").strip()
chat_session = str(payload.get("session_id") or f"portal-{uuid.uuid4().hex[:8]}")
if not message:
return JSONResponse({"ok": False, "body": {"error": {"message": "请输入内容"}}})
try:
return JSONResponse(await state.chat(sid(request), message, chat_session))
except Exception as exc:
return JSONResponse(
{"ok": False, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
)
return portal
PAGE = r"""<!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; }
/* 表格:行高固定、超长省略(17- 文档要求) */
table.fixed { table-layout: fixed; }
table.fixed td, table.fixed th { height: 30px; line-height: 30px; padding: 0 9px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
table.fixed td.wrap { white-space: normal; line-height: 1.5; height: auto; padding: 6px 9px; }
/* 筛选栏 */
.filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end;
background: #f7f9fc; border: 1px solid #e6ecf3; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; }
.filters label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #5a6b7f; }
.filters input, .filters select { padding: 5px 8px; border: 1px solid #c9d2dd;
border-radius: 4px; font: inherit; font-size: 13px; min-width: 120px; }
/* 分页 */
.pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12.5px;
color: #5a6b7f; }
/* 风险等级徽章 */
.lv { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11.5px; }
.lv-高, .lv-high { background: #fdecea; color: #a8342a; }
.lv-中, .lv-medium { background: #fff4e5; color: #8a5a00; }
.lv-低, .lv-low { background: #e8eef6; color: #44536a; }
/* Toast */
#toasts { position: fixed; right: 18px; bottom: 18px; z-index: 9999;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
.toast { min-width: 240px; max-width: 420px; padding: 10px 14px; border-radius: 6px;
box-shadow: 0 4px 16px rgba(31,45,61,.18); font-size: 13px; line-height: 1.6;
background: #fff; border-left: 4px solid #1f6feb; white-space: pre-wrap; }
.toast.ok { border-left-color: #1a7f37; }
.toast.bad { border-left-color: #c0392b; }
.toast.warn { border-left-color: #e0a800; }
.toast b { display: block; margin-bottom: 2px; }
/* 模态框 */
#modal-back { position: fixed; inset: 0; background: rgba(15,23,32,.45); z-index: 9998;
display: none; align-items: center; justify-content: center; padding: 20px; }
#modal-back.on { display: flex; }
.modal { background: #fff; border-radius: 8px; width: min(860px, 100%);
max-height: 88vh; display: flex; flex-direction: column;
box-shadow: 0 12px 40px rgba(15,23,32,.3); }
.modal header { background: #fff; color: #1f2d3d; border-bottom: 1px solid #e6ecf3;
padding: 13px 18px; font-size: 15px; font-weight: 600; display: flex;
align-items: center; }
.modal .body { padding: 16px 18px; overflow: auto; }
.modal .foot { padding: 12px 18px; border-top: 1px solid #e6ecf3; display: flex;
gap: 8px; justify-content: flex-end; }
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; font-size: 13px; }
.kv dt { color: #6b7c93; }
.kv dd { margin: 0; word-break: break-all; }
.modal textarea { width: 100%; min-height: 88px; padding: 8px; border: 1px solid #c9d2dd;
border-radius: 4px; font: inherit; font-size: 13px; resize: vertical; }
.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.tabs button { padding: 5px 11px; font-size: 12.5px; border: 1px solid #c9d2dd;
background: #fff; border-radius: 14px; cursor: pointer; }
.tabs button.on { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.stream { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px;
font-family: Consolas, monospace; font-size: 12.5px; white-space: pre-wrap;
max-height: 300px; overflow: auto; line-height: 1.6; }
</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="toasts"></div>
<div id="modal-back" onclick="if(event.target===this)closeModal()">
<div class="modal">
<header><span id="modal-title">标题</span></header>
<div class="body" id="modal-body"></div>
<div class="foot" id="modal-foot"></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>
<span class="env" id="build" title="页面构建时间:刷新后这个值会变,可确认加载的不是缓存旧页"></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();
}
/* ---------------- 通用交互组件 ----------------
17- 文档明确要求:不使用浏览器原生 alert/prompt 作为正式交互方案,
不能只用控制台日志代替用户提示。这里用 Toast + 模态框替代。 */
const PAGE_BUILD = "__BUILD__";
function toast(text, kind) {
const box = $('toasts');
if (!box) { console.error('[portal] #toasts 缺失,退回原生提示'); (kind === 'bad' ? console.error : console.log)(text); return; }
const el = document.createElement('div');
el.className = 'toast ' + (kind || '');
el.textContent = text;
box.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 3200);
setTimeout(() => el.remove(), 3600);
}
function ok(text) { toast(text, 'ok'); }
function bad(text) { toast(text, 'bad'); }
function warn(text) { toast(text, 'warn'); }
function openModal(title, bodyHtml, footHtml) {
const back = $('modal-back');
if (!back) {
// 兜底:容器缺失(多半是浏览器缓存了旧页面)时不要静默失败
console.error('[portal] #modal-back 缺失:页面可能是旧版本,请硬刷新(Ctrl+F5)');
window.alert(String(title) + '\n\n页面组件缺失,请硬刷新后再试(Ctrl+F5)');
return false;
}
$('modal-title').textContent = title;
$('modal-body').innerHTML = bodyHtml;
$('modal-foot').innerHTML = footHtml || '<button class="act" onclick="closeModal()">关闭</button>';
back.classList.add('on');
return true;
}
function closeModal() { $('modal-back').classList.remove('on'); }
/** 替代 confirm:返回 Promise<boolean>。 */
function askConfirm(title, text, okLabel) {
return new Promise((resolve) => {
const opened = openModal(title, `<p style="font-size:13.5px;line-height:1.8">${esc(text)}</p>`,
`<button class="act" onclick="closeModal();window.__ask(false)">取消</button>
<button class="act primary" onclick="closeModal();window.__ask(true)">${esc(okLabel || '确定')}</button>`);
// 模态打不开时必须**立刻 resolve**,否则 await 永远挂着 —— 表现就是"点了没反应"
if (!opened) { resolve(false); return; }
window.__ask = resolve;
});
}
/** 替代 prompt:返回 Promise<string|null>。 */
function askText(title, label, options) {
const opts = options || {};
if (opts.choices) {
return new Promise((resolve) => {
const sel = opts.choices.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
const opened = openModal(title,
`<label style="font-size:12.5px;color:#5a6b7f">${esc(label)}</label>
<select id="ask-value" style="width:100%;margin-top:6px;padding:7px;border:1px solid #c9d2dd;border-radius:4px;font:inherit">${sel}</select>`,
`<button class="act" onclick="closeModal();window.__ask(null)">取消</button>
<button class="act primary" onclick="closeModal();window.__ask(document.getElementById('ask-value').value)">提交</button>`);
if (!opened) { resolve(null); return; }
window.__ask = resolve;
});
}
return new Promise((resolve) => {
const opened = openModal(title,
`<label style="font-size:12.5px;color:#5a6b7f">${esc(label)}</label>
<textarea id="ask-value" maxlength="${opts.maxlength || 500}">${esc(opts.value || '')}</textarea>
<div class="muted" style="margin-top:4px">${esc(opts.hint || '')}</div>`,
`<button class="act" onclick="closeModal();window.__ask(null)">取消</button>
<button class="act primary" onclick="closeModal();window.__ask(document.getElementById('ask-value').value)">提交</button>`);
if (!opened) { resolve(null); return; }
window.__ask = resolve;
});
}
/* ---------------- 统一响应处理(17- 文档 §六) ----------------
失败提示解析顺序:error.message -> HTTP 状态码映射 -> 通用失败提示。
注意本平台**业务失败也返回 HTTP 200**,错误在 body.code 里。 */
const HTTP_TEXT = {
401: '登录已失效,请重新登录',
403: '没有当前操作权限',
404: '记录不存在或无权查看',
409: '当前状态不允许该操作',
413: '文件超过大小限制',
422: '提交内容不符合要求',
500: '系统异常,请稍后重试',
503: '服务暂时不可用',
504: '服务暂时不可用',
};
/** 解析出「这次调用到底成没成」,并给出可直接展示的话。 */
function judge(r) {
const body = (r && r.body) || {};
const err = body.error || {};
const code = body.code;
const biz = (code === undefined || code === null) ? null : Number(code);
const httpBad = !r || r.status >= 400 || r.status === 0;
const bizBad = (biz !== null && biz !== 0) || !!err.code;
const failed = httpBad || bizBad;
let reason = '';
if (err.message) reason = err.message;
else if (bizBad && body.message) reason = body.message;
else if (bizBad && biz !== null) reason = HTTP_TEXT[biz] || (body.message || '');
if (!reason && httpBad) reason = HTTP_TEXT[(r || {}).status] || '请求失败';
if (!failed) return { failed: false, reason: '' };
const details = (body.error && body.error.field_errors) || body.field_errors || [];
if (details.length) {
reason += ':' + details.map((d) => `${d.field} ${d.message}`).join(';');
}
return { failed: true, reason: reason || '请求失败' };
}
/** 按 17- 文档 §六 给出「操作名 + 成功/失败」的提示文案。 */
function report(action, r, successText) {
const verdict = judge(r);
if (verdict.failed) {
const text = verdict.reason || '请求失败';
if ((r || {}).status === 409) {
bad(`${action}未完成:${text}\n可能已被处理,请刷新后核对状态`);
} else {
bad(`${action}失败:${text}`);
}
return false;
}
ok(successText || `${action}成功`);
return true;
}
// 统一调用平台接口:令牌在服务端,前端只传方法与路径
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 || '');
if ($('build')) $('build').textContent = 'build ' + PAGE_BUILD;
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) { bad('创建会话失败(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 } });
bad(`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>`;
}
/* ---------------- 员工 · 风控工作台 ----------------
按 docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md 实现:
概览五指标、预警队列每页 5 条 + 风险等级优先 + 筛选 + 游标分页 + 详情弹窗、
五个处置动作(确认接收二次确认、误报必填理由)、八类证据、通知、日报流式。
交互上不用原生 alert/prompt —— 改用 Toast 与模态框。 */
const EVIDENCE_SOURCES = [
['customers', '客户'], ['products', '产品'], ['transactions', '交易'],
['capital_flows', '资金'], ['holdings', '持仓'], ['login_records', '登录'],
['alerts', '预警'], ['notifications', '通知'],
];
// 注意是 `holdings` 而不是 positions —— 传错会被 422 拒绝。
const LEVEL_RANK = { '高风险': 0, '高': 0, '中风险': 1, '中': 1, '低风险': 2, '低': 2 };
const RISK_PAGE_SIZE = 5;
let RISK_CURSOR = null;
let RISK_CURSOR_STACK = [];
let RISK_HAS_MORE = false;
let RISK_ITEMS = [];
let RISK_DETAIL = null;
function renderStaff(box) {
box.innerHTML = `
<div class="panel">
<h2>风险概览</h2>
<p class="hint">数据范围 <code>all</code>(风控专员可看全部客户)。五个指标取自
<code>GET /api/v1/risk/overview</code>。</p>
<div class="grid" id="risk-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" onclick="loadAlerts(null)">刷新队列</button>
<button class="act warn" onclick="scanRisk()">手动扫描</button>
<button class="act" onclick="openDailyReport()">生成日报</button>
<button class="act" onclick="openNotifications()">通知记录</button>
<button class="act" onclick="openEvidence()">八类证据</button>
</div>
</div>
<div class="panel">
<h2>预警队列<span class="muted" id="risk-count"></span></h2>
<p class="hint">固定每页 ${RISK_PAGE_SIZE} 条、按风险等级优先。**排序在页内进行** ——
接口本身不支持排序参数,跨页整体排序需要服务端支持。<br>
⚠️ 风险等级筛选值必须用 <b>高 / 中 / 低</b>:预警对象的 <code>risk_level</code> 是「高」,
而概览的 <code>levels</code> 键是「高风险」—— 平台两处口径不一致,用「高风险」筛不出任何结果。</p>
<div class="filters">
<label>关键词<input id="f-keyword" placeholder="预警号/摘要"></label>
<label>客户号<input id="f-customer_no" placeholder="T-CUST"></label>
<label>风险等级<select id="f-risk_level">
<option value="">全部</option>
<option value="高">高</option>
<option value="中">中</option>
<option value="低">低</option>
</select></label>
<label>规则码<input id="f-rule_code" placeholder="RW-015"></label>
<label>产品代码<input id="f-product_code"></label>
<label>产品名<input id="f-product_name"></label>
<label>开始时间<input id="f-start_time" placeholder="2026-09-01"></label>
<label>结束时间<input id="f-end_time" placeholder="2026-09-30"></label>
<button class="act primary" onclick="loadAlerts(null)">查询</button>
<button class="act" onclick="resetRiskFilters()">重置</button>
</div>
<div id="alerts"><div class="muted">加载中…</div></div>
<div class="pager">
<button class="act" id="pg-prev" onclick="riskPrevPage()">上一页</button>
<button class="act" id="pg-next" onclick="riskNextPage()">下一页</button>
<span id="pg-info"></span>
</div>
</div>
<div class="panel" id="staff-extra"></div>`;
loadRisk();
loadAlerts(null);
}
function resetRiskFilters() {
['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
'product_name', 'start_time', 'end_time'].forEach((k) => { $('f-' + k).value = ''; });
loadAlerts(null);
}
function riskQuery() {
const q = {};
['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
'product_name', 'start_time', 'end_time'].forEach((k) => {
const v = ($('f-' + k) || {}).value;
if (v && v.trim()) q[k] = v.trim();
});
q.limit = RISK_PAGE_SIZE; // 该接口 limit 上限就是 5
return q;
}
async function loadRisk() {
const r = await GET('/api/v1/risk/overview');
const d = (r.body || {}).data || {};
const verdict = judge(r);
if (verdict.failed) {
$('risk-overview').innerHTML = `<div class="stat"><div class="n">—</div><div class="l">${esc(verdict.reason)}</div></div>`;
return;
}
const levels = d.levels || {};
const hi = (d.high_priority || []).length;
const cards = [
['预警总量', d.total],
['待处理', d.pending],
['已超时', d.overdue],
['高风险', levels['高风险'] ?? levels['高'] ?? 0],
['重点预警', hi],
];
$('risk-overview').innerHTML = cards.map(([label, value]) =>
`<div class="stat"><div class="n">${esc(value ?? '—')}</div><div class="l">${esc(label)}</div></div>`).join('');
}
async function loadAlerts(cursor) {
if (cursor === null || cursor === undefined) { RISK_CURSOR_STACK = []; }
const q = riskQuery();
if (cursor) q.cursor = cursor;
const r = await GET('/api/v1/risk/alerts', q);
const verdict = judge(r);
const box = $('alerts');
if (verdict.failed) {
box.innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`;
$('pg-info').textContent = '';
return;
}
const data = (r.body || {}).data;
const items = Array.isArray(data) ? data : ((data || {}).items || []);
const meta = (r.body || {}).meta || {};
RISK_HAS_MORE = !!meta.has_more;
RISK_CURSOR = meta.next_cursor || null;
// 页内按风险等级优先,同级按 priority_score 降序
RISK_ITEMS = items.slice().sort((a, b) => {
const ra = LEVEL_RANK[a.risk_level] ?? 9;
const rb = LEVEL_RANK[b.risk_level] ?? 9;
if (ra !== rb) return ra - rb;
return (b.priority_score || 0) - (a.priority_score || 0);
});
$('risk-count').textContent = `(本页 ${RISK_ITEMS.length} 条)`;
if (!RISK_ITEMS.length) {
box.innerHTML = '<div class="muted">没有符合条件的预警。可调整筛选条件或点「手动扫描」。</div>';
} else {
box.innerHTML = `<table class="fixed"><thead><tr>
<th style="width:112px">预警号</th><th style="width:110px">客户</th>
<th style="width:74px">等级</th><th style="width:150px">规则</th>
<th style="width:80px">处置状态</th><th style="width:70px">回执</th>
<th>摘要</th><th style="width:150px">操作</th></tr></thead><tbody>`
+ RISK_ITEMS.map((a) => {
const no = esc(a.alert_no || '');
const lv = esc(a.risk_level || '');
const rules = (a.rule_codes || []).join(', ');
const ack = a.ack_status === '已确认' ? '已确认' : '未确认';
const escMark = a.is_escalated ? '<span class="lv lv-高">已升级</span>' : '';
return `<tr>
<td><code>${no}</code></td>
<td>${esc(a.customer_name || a.customer_no || '')}</td>
<td><span class="lv lv-${lv}">${lv}</span></td>
<td title="${esc(rules)}">${esc(rules)}</td>
<td>${esc(a.status || '')}</td>
<td>${esc(ack)} ${escMark}</td>
<td title="${esc(a.evidence_summary || '')}">${esc(a.evidence_summary || '')}</td>
<td>
<button class="act" onclick="openAlert('${no}')">详情</button>
<button class="act" onclick="ack('${no}')">确认</button>
</td></tr>`;
}).join('')
+ '</tbody></table>';
}
$('pg-prev').disabled = RISK_CURSOR_STACK.length === 0;
$('pg-next').disabled = !RISK_HAS_MORE;
$('pg-info').textContent =
`第 ${RISK_CURSOR_STACK.length + 1} 页 · 每页 ${RISK_PAGE_SIZE} 条` + (RISK_HAS_MORE ? ' · 还有下一页' : ' · 已到末页');
}
function riskNextPage() {
if (!RISK_HAS_MORE) return;
RISK_CURSOR_STACK.push(RISK_CURSOR);
loadAlerts(RISK_CURSOR);
}
function riskPrevPage() {
if (!RISK_CURSOR_STACK.length) return;
const prev = RISK_CURSOR_STACK.pop();
loadAlerts(RISK_CURSOR_STACK.length ? prev : null);
}
function levelPill(lv) {
const t = esc(lv || '');
return `<span class="lv lv-${t}">${t}</span>`;
}
async function openAlert(no) {
const r = await GET(`/api/v1/risk/alerts/${no}`);
const verdict = judge(r);
if (verdict.failed) { bad(`打开预警详情失败:${verdict.reason}`); return; }
const d = (r.body || {}).data || {};
const a = d.alert || {};
const c = d.customer || {};
RISK_DETAIL = a;
const ack = a.ack_status === '已确认';
const snap = a.evidence_snapshot || {};
openModal(`预警详情 ${a.alert_no || no}`, `
<dl class="kv">
<dt>预警编号</dt><dd><code>${esc(a.alert_no)}</code></dd>
<dt>预警类型</dt><dd>${esc(a.alert_type)}</dd>
<dt>风险等级</dt><dd>${levelPill(a.risk_level)} · 优先级分 ${esc(a.priority_score)}</dd>
<dt>处置状态</dt><dd>${esc(a.status)} ${a.is_escalated ? '(已升级标记)' : ''}</dd>
<dt>回执状态</dt><dd>${esc(a.ack_status)}${a.ack_at ? ' @ ' + esc(a.ack_at) : ''}</dd>
<dt>证据归档</dt><dd>${a.evidence_archived ? '已归档' : '未归档'}</dd>
<dt>规则</dt><dd>${(a.rule_codes || []).map((x) => `<span class="pill">${esc(x)}</span>`).join('')}</dd>
<dt>到期时间</dt><dd>${esc(a.due_at)}</dd>
<dt>结案原因</dt><dd>${esc(a.close_reason || '—')}</dd>
<dt>证据摘要</dt><dd>${esc(a.evidence_summary)}</dd>
<dt>证据快照</dt><dd><pre style="margin:0">${esc(pretty(snap))}</pre></dd>
<dt>客户</dt><dd>${esc(c.name)}(${esc(c.customer_no)})· 投资者类型 ${esc(c.investor_type)} ·
行为分 ${esc(c.behavior_score)} · 总资产 ${esc(c.total_asset)}</dd>
<dt>风险标签</dt><dd>${esc(c.risk_tags || '—')}</dd>
</dl>`,
`<button class="act" onclick="ack('${esc(a.alert_no)}')" ${ack ? 'disabled' : ''}>确认接收</button>
<button class="act" onclick="investigate('${esc(a.alert_no)}')">进入调查</button>
<button class="act warn" onclick="exclude('${esc(a.alert_no)}')">关闭误报</button>
<button class="act" onclick="escalate('${esc(a.alert_no)}')">升级</button>
<button class="act primary" onclick="resolveAlert('${esc(a.alert_no)}')">完成结案</button>
<button class="act" onclick="closeModal()">关闭</button>`);
}
/* 五个处置动作。写操作都走二次确认,误报/升级/结案必填文字。 */
async function ack(no) {
if (!await askConfirm('确认接收', `确认接收预警 ${no}?\n确认后才会进入可处置状态。`, '确认接收')) return;
const r = await jpost('/api/call',
{ method:'POST', path:`/api/v1/risk/alerts/${no}/acknowledgements`, body:{} });
if (report('确认接收', r, '预警已确认接收')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
}
async function investigate(no) {
if (!await askConfirm('进入调查', `将预警 ${no} 标记为调查中?`, '进入调查')) return;
const r = await jpost('/api/call',
{ method:'POST', path:`/api/v1/risk/alerts/${no}/investigations`, body:{} });
if (report('进入调查', r, '已进入调查')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
}
async function exclude(no) {
const reason = await askText('关闭误报', '误报理由(必填,1-500 字)',
{ value: '', maxlength: 500, hint: '本题为必填项,平台会校验。' });
if (reason === null) return;
if (!reason.trim()) { bad('误报理由不能为空'); return; }
const r = await jpost('/api/call',
{ method:'POST', path:`/api/v1/risk/alerts/${no}/exclusions`, body:{ reason: reason.trim() } });
if (report('关闭误报', r, '已关闭误报')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
}
async function escalate(no) {
const reason = await askText('升级处理', '升级原因(必填,1-500 字)',
{ value: '客户风险等级需人工复核', maxlength: 500 });
if (reason === null) return;
if (!reason.trim()) { bad('升级原因不能为空'); return; }
const r = await jpost('/api/call',
{ method:'POST', path:`/api/v1/risk/alerts/${no}/escalations`, body:{ reason: reason.trim() } });
if (report('升级处理', r, '预警已升级')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
}
async function resolveAlert(no) {
const resolution = await askText('完成结案', '处理结论(必填,1-500 字)',
{ value: '已联系客户核实,风险已排除', maxlength: 500 });
if (resolution === null) return;
if (!resolution.trim()) { bad('处理结论不能为空'); return; }
const r = await jpost('/api/call',
{ method:'POST', path:`/api/v1/risk/alerts/${no}/resolutions`, body:{ resolution: resolution.trim() } });
if (report('完成结案', r, '预警已完成结案')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
}
async function scanRisk() {
if (!await askConfirm('手动扫描', '触发一次风控规则扫描?会产生新的预警与审计记录。', '开始扫描')) return;
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/alerts/scan', body:{} });
if (report('手动扫描', r, '预警扫描完成')) { loadRisk(); loadAlerts(null); }
}
/* 日报:SSE 流式生成 -> 可编辑 -> 多邮箱发送 */
async function openDailyReport() {
openModal('生成风控日报', `
<div class="row"><button class="act primary" onclick="streamDailyReport()">开始流式生成</button>
<span class="muted">使用 <code>POST /api/v1/risk/daily-report/stream</code>(SSE)</span></div>
<div class="stream" id="dr-stream">(尚未开始)</div>
<label style="display:block;margin-top:12px;font-size:12.5px;color:#5a6b7f">日报内容(可编辑后再发送)</label>
<textarea id="dr-content" style="min-height:200px"></textarea>
<div class="row" style="margin-top:10px">
<input class="q" id="dr-subject" value="风控预警日报" placeholder="邮件主题" style="width:220px">
<input class="q" id="dr-to" placeholder="收件人,逗号分隔" style="width:280px">
<button class="act primary" onclick="sendDailyReport()">发送日报</button>
</div>
<div class="muted">发送接口要 <code>recipients</code> / <code>subject</code> / <code>content</code> 三个字段。</div>`);
}
async function streamDailyReport() {
const box = $('dr-stream');
box.textContent = '';
try {
const resp = await fetch('/api/call', {
method: 'POST', headers: HEAD(),
body: JSON.stringify({ method:'POST', path:'/api/v1/risk/daily-report/stream', body:{} }),
});
const payload = await resp.json();
const raw = ((payload.body || {})._raw) || '';
if (!raw) {
box.textContent = pretty(payload.body);
const verdict = judge(payload);
if (verdict.failed) bad('日报生成失败:' + verdict.reason);
return;
}
// 逐条解析 SSE:event: xxx / data: {...}
let content = '';
raw.split('\n').forEach((line) => {
if (line.startsWith('data:')) {
try {
const obj = JSON.parse(line.slice(5).trim());
if (obj.type === 'replace' && obj.content) content = obj.content;
else if (obj.content) content += obj.content;
if (obj.message) box.textContent += `[${obj.stage || obj.type}] ${obj.message}\n`;
} catch { /* 忽略非 JSON 行 */ }
} else if (line.startsWith('event:')) {
box.textContent += `── ${line.slice(6).trim()} ──\n`;
}
});
if (content) { $('dr-content').value = content; ok('日报生成完成'); }
else warn('日报流已结束,但没有解析到内容');
} catch (err) {
bad('日报流式请求失败:' + err.message);
}
}
async function sendDailyReport() {
const content = $('dr-content').value.trim();
const subject = $('dr-subject').value.trim();
const to = $('dr-to').value.split(',').map((x) => x.trim()).filter(Boolean);
if (!content) { bad('日报内容为空,先生成或填写'); return; }
if (!to.length) { bad('请至少填一个收件人'); return; }
if (!await askConfirm('发送日报', `发送给 ${to.join(', ')}?`, '发送')) return;
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report/mail',
body:{ recipients: to, subject, content } });
report('邮件发送', r, '日报发送成功');
}
/* 八类证据 */
async function openEvidence() {
openModal('八类证据', `
<div class="tabs" id="ev-tabs">${EVIDENCE_SOURCES.map(([k, label], i) =>
`<button class="${i === 0 ? 'on' : ''}" onclick="loadEvidence('${k}', this)">${esc(label)}</button>`).join('')}</div>
<div class="filters">
<label>关键词<input id="ev-keyword"></label>
<label>行为分等级<input id="ev-behavior_level" placeholder="如 关注"></label>
<label>发送状态<input id="ev-send_status" placeholder="如 成功"></label>
<label>开始时间<input id="ev-start_time" placeholder="2026-09-01"></label>
<label>结束时间<input id="ev-end_time" placeholder="2026-09-30"></label>
<button class="act primary" onclick="reloadEvidence()">查询</button>
</div>
<div id="ev-body"><div class="muted">加载中…</div></div>`,
'<button class="act" onclick="closeModal()">关闭</button>');
loadEvidence('customers');
}
let EV_SOURCE = 'customers';
async function loadEvidence(source, btn) {
EV_SOURCE = source;
if (btn) {
document.querySelectorAll('#ev-tabs button').forEach((b) => b.classList.remove('on'));
btn.classList.add('on');
}
const q = {};
['keyword', 'behavior_level', 'send_status', 'start_time', 'end_time'].forEach((k) => {
const v = ($('ev-' + k) || {}).value;
if (v && v.trim()) q[k] = v.trim();
});
const r = await GET(`/api/v1/risk/evidence/${source}`, q);
const verdict = judge(r);
const box = $('ev-body');
if (verdict.failed) { box.innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`; return; }
const data = (r.body || {}).data;
const items = Array.isArray(data) ? data : ((data || {}).items || []);
if (!items.length) { box.innerHTML = '<div class="muted">该类证据暂无数据。</div>'; return; }
const cols = Object.keys(items[0]);
box.innerHTML = `<div class="muted" style="margin-bottom:6px">${items.length} 条 · 来源
<code>${esc(source)}</code></div>
<table class="fixed"><thead><tr>${cols.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>`
+ items.slice(0, 30).map((row) => '<tr>' + cols.map((c) => {
const v = row[c];
const text = (v === null || v === undefined) ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
return `<td title="${esc(text)}">${esc(text)}</td>`;
}).join('') + '</tr>').join('')
+ '</tbody></table>';
}
function reloadEvidence() { loadEvidence(EV_SOURCE); }
/* 通知记录 */
async function openNotifications() {
openModal('通知记录', '<div id="nt-body"><div class="muted">加载中…</div></div>',
'<button class="act" onclick="closeModal()">关闭</button>');
const r = await GET('/api/v1/risk/notifications');
const verdict = judge(r);
if (verdict.failed) { $('nt-body').innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`; return; }
const data = (r.body || {}).data;
const items = Array.isArray(data) ? data : ((data || {}).items || []);
$('nt-body').innerHTML = items.length
? `<table class="fixed"><thead><tr><th>预警编号</th><th>类型</th><th>发送状态</th><th>时间</th></tr></thead><tbody>`
+ items.map((n) => `<tr><td><code>${esc(n.alert_no ?? '')}</code></td>
<td>${esc(n.notification_type ?? n.type ?? '')}</td>
<td>${esc(n.send_status ?? n.status ?? '')}</td>
<td>${esc(n.created_at ?? n.sent_at ?? '')}</td></tr>`).join('')
+ '</tbody></table>'
: '<div class="muted">暂无通知记录。</div>';
}
/* ---------------- 员工 · 运营工作台(场外基金) ---------------- */
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>operator_id</code>**,而且是**防伪校验** —— 平台会核对
它是否等于当前登录用户,所以门户一律自动带本次登录的 user_id,不让你手填。</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() {
// 该接口的分页参数是 `page` / `page_size`(不是 limit)。
const r = await GET('/api/v1/offsite-fund/mails', { page: 1, page_size: 20 });
const raw = r.body?.data;
const items = Array.isArray(raw) ? raw : (raw?.items || []);
$('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>`;
}
// 场外线的写接口**必须带 operator_id**,而且是防伪校验:平台会核对它是否等于当前
// 登录用户。所以这里一律取本次登录的 user_id,不硬编码、也不让用户随便填。
function myId() { return (ME && ME.user_id) || ''; }
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:{ operator_id: myId() } }));
loadMails();
}
async function recoverMailbox() {
if (!confirm('触发邮箱恢复?这是写操作。')) return;
showOffsite('邮箱恢复', await jpost('/api/call',
{ method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries',
body:{ operator_id: myId() } }));
}
async function docFields() {
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('识别字段 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/nl2sql-fields`));
}
async function docRules() {
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('规则结果 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/rule-results`));
}
async function docConfirm() {
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
// decision 是**中文枚举**:确认无误 / 确认异常 / 未处理
const decision = prompt('确认结论(确认无误 / 确认异常 / 未处理):', '确认无误');
if (decision === null) return;
if (['确认无误', '确认异常', '未处理'].indexOf(decision) < 0) {
return bad('只能是:确认无误 / 确认异常 / 未处理');
}
if (!confirm(`对单据 ${t} 提交「${decision}」?这是写操作,会进审计。`)) return;
showOffsite('确认单据 ' + t, await jpost('/api/call',
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`,
body:{ decision, operator_id: myId() } }));
}
async function docRetry() {
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('重试识别 ' + t, await jpost('/api/call',
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`,
body:{ operator_id: myId() } }));
}
async function docNotify() {
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
// notification_type 取值:risk / settlement / mail_return / normal_return / exception_return…
const type = prompt('通知类型(risk / settlement / mail_return / normal_return / exception_return):',
'normal_return');
if (type === null) return;
if (!confirm(`为单据 ${t} 创建「${type}」通知?`)) return;
showOffsite('创建通知 ' + t, await jpost('/api/call',
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`,
body:{ notification_type: type, operator_id: myId() } }));
}
async function settle() {
// 这个接口要的是 fund_code + application_date,没有 operator_id
const fund = prompt('基金代码 fund_code:', '');
if (fund === null) return;
const date = prompt('申请日期 application_date(YYYY-MM-DD):', '');
if (date === null) return;
if (!fund.trim() || !date.trim()) return bad('基金代码与申请日期都必填');
if (!confirm(`重算 ${fund} 在 ${date} 的结算统计?这是写操作。`)) return;
showOffsite('结算重算', await jpost('/api/call',
{ method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate',
body:{ fund_code: fund.trim(), application_date: date.trim() } }));
}
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">
<span class="muted">1-100</span>
<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() {
// 该接口 limit 的取值范围是 1-100;钳一下,免得填大值得到 422。
let limit = parseInt($('audit-limit').value.trim() || '20', 10);
if (!Number.isFinite(limit)) limit = 20;
limit = Math.min(100, Math.max(1, limit));
$('audit-limit').value = limit;
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 bad('请填任务单号');
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 bad('请填任务单号');
if (!vid) return bad('请填 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="南方稳健增利债券A" 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) { bad('请先填任务单号(创建任务后会返回)'); 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 bad('产品名、材料标题、风格代码都要填');
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 bad('结构化输入不是合法 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 bad('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x);
if (!ids.length) return bad('请填要投递的投顾 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 isBad = (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 (isBad && body.message) extra = esc(body.message);
if (!isBad) {
if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
}
return `<h2>${esc(title)}</h2>
<p class="hint ${isBad ? '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>
"""
#: 页面构建标记:取本文件的修改时间。刷新后这个值应当变化 ——
#: 用它一眼确认浏览器加载的不是缓存旧页(旧页缺 Toast/模态容器时,点击会静默失效)。
_PAGE_BUILD = datetime.fromtimestamp(Path(__file__).stat().st_mtime).strftime("%m-%d %H:%M")
PAGE = PAGE.replace("__BUILD__", _PAGE_BUILD)
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()