Files
group_fqcd_jr/tools/login_console.py
T
lzf_0626 0358ea519f 投顾演示数据准备脚本:配好测评与归属,方案那步如实报告卡在证据来源
投顾线合并后 21 张 advisor_* 表全空,导致 published 返回 []、投顾 customer_ids 为空、
客户画像因缺测评返回 403。本脚本按依赖顺序补能自己造的那几样:

1. 产品目录:调 tools/import_hq_test_products.py 取真实行情(实测 19 个 ETF/LOF);
2. 客户风险测评:给 9001 补一条 C5、有效期一年(answers 里注明是演示数据);
3. 客户-投顾归属:9001 → 9020(assigned_at 往前留 5 秒,避开 DATETIME(0) 舍入陷阱)。

**方案那步不配** —— 缺的是证据来源不是技术:authoritative_tradable_products 是
fail closed 的(Missing or unverified evidence excludes a product),而造
advisor_product_suitability_reference 必须带 source_url 与 document_sha256,也就是真实的
销售适当性披露 / 基金合同文件(import_product_governance_reference.py 的两个 CSV,
不在仓库里,属环境数据)。脚本**不伪造这两个字段**(证据链红线),只检查并打印真正的解法。

实测:cust_t 的 /users/me/memory-profile 由 403 变 200(测评生效);admin 视角看 9020 的
customer_ids = ["9001"](归属生效);advisor 的 published 仍为 [](符合预期,尚无方案)。

⚠️ 顺带发现一处口径不一致:画像 fin_customer_profile.investor_type 是 C2,而新补的测评是
C5 —— 前者决定画像展示、后者决定适当性裁决,两者会同时出现在界面上。演示前需要统一
(要么把测评改成 C2,要么把画像也改成 C5)。
2026-09-12 10:34:28 +08:00

296 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""登录测试台:一个记事本级别的前端,用来在浏览器里验证登录接口。
浏览器打开 **http://127.0.0.1:8099**(可用 `--port` 改)。
## 它做什么 / 不做什么
- **走真实登录接口**(`POST /api/v1/auth/tokens`)拿令牌,和前端将来要做的一模一样。
与 `tools/chat_console.py` 不同:那个是当初还没有登录接口时自己签令牌的权宜做法,
这个测的就是真链路。
- 拿到令牌后可以按角色调几个**只读**接口,直接把状态码与响应 JSON 摊在页面上 ——
方便确认"登录给的 roles 与接口实际放行的权限是否一致"。
- **不动 `app/` 一个字节**:内层用 `create_app()`,本进程只做两件事 —— 提供一个静态页面、
把 `/api/*` 同源转发给它。同源转发是为了避开 CORS,也让浏览器不必直连主服务。
- 只监听 `127.0.0.1`,不出本机。私钥始终在服务端(登录是后端做的),页面只拿到令牌。
## 为什么现在可以做这个了
`docs/24` 当初拒绝"给底座加 dev token 端点",理由是那种端点等于把**任意身份**开放给
任何能访问服务的人。现在不同了:登录接口要**校验密码**,所以浏览器拿令牌这件事
不再等于"谁都能冒充任何人"。这条顾虑已经消除。
用法:
python tools/login_console.py # 默认 8099
python tools/login_console.py --port 9000
"""
from __future__ import annotations
import argparse
import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, Response
from app.main import create_app
PAGE = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录测试台</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
max-width: 940px; margin: 0 auto; padding: 24px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { opacity: .65; margin-bottom: 20px; }
.card { border: 1px solid rgba(128,128,128,.35); border-radius: 10px;
padding: 16px; margin-bottom: 16px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
label { min-width: 62px; opacity: .8; }
input { flex: 1; min-width: 180px; padding: 7px 9px; border-radius: 6px;
border: 1px solid rgba(128,128,128,.5); background: transparent; color: inherit; }
button { padding: 7px 14px; border-radius: 6px; cursor: pointer;
border: 1px solid rgba(128,128,128,.5); background: rgba(128,128,128,.12);
color: inherit; font-size: 13px; }
button:hover { background: rgba(128,128,128,.22); }
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
button.primary:hover { background: #1d4ed8; }
pre { background: rgba(128,128,128,.12); padding: 12px; border-radius: 8px;
overflow: auto; max-height: 340px; font-size: 12.5px; margin: 8px 0 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
background: rgba(128,128,128,.18); margin-right: 6px; }
.ok { color: #16a34a; } .bad { color: #dc2626; }
.muted { opacity: .6; font-size: 12.5px; }
code { background: rgba(128,128,128,.18); padding: 1px 5px; border-radius: 4px; }
</style>
</head>
<body>
<h1>登录测试台</h1>
<div class="sub">
走真实接口 <code>POST /api/v1/auth/tokens</code>。密码仅用于本地演示。
</div>
<div class="card">
<div class="row">
<label>用户名</label><input id="username" placeholder="cust_t / risk_t / admin_t" autocomplete="off">
</div>
<div class="row">
<label>密码</label><input id="password" type="password" placeholder="演示口令">
</div>
<div class="row">
<button class="primary" onclick="doLogin()">登录</button>
<button onclick="logout()">登出(清令牌)</button>
<span class="muted">快捷填充:</span>
<button onclick="fill('cust_t','123456')">客户</button>
<button onclick="fill('risk_t','666666')">员工 / 风控</button>
<button onclick="fill('admin_t','88888888')">管理员</button>
<button onclick="fill('advisor_t','abc12345')">投顾</button>
</div>
</div>
<div class="card">
<div class="row"><strong>当前身份</strong></div>
<div id="identity" class="muted">未登录</div>
</div>
<div class="card">
<div class="row">
<strong>用这个令牌调接口</strong>
<span class="muted">(下面按钮按角色给,方便核对权限)</span>
</div>
<div class="row" id="probes"></div>
<div class="row">
<label>用户 id</label>
<input id="probeUserId" placeholder="任意用户 id,如 9001;留空则查当前登录用户"
autocomplete="off" style="max-width:440px">
<button onclick="probeUser()">查这个用户的身份</button>
</div>
<pre id="output">(还没有请求)</pre>
</div>
<script>
let token = null;
// 当前登录用户的 id(取自登录响应),供"我的身份"以及"用户 id 留空时默认查自己"使用。
// 页面里**不写死任何用户 id**:写死会让人以为权限解析错了(真实发生过一次)。
let identityUserId = null;
function fill(u, p) {
document.getElementById('username').value = u;
document.getElementById('password').value = p;
}
function show(text) {
document.getElementById('output').textContent = text;
}
function renderIdentity(data) {
const el = document.getElementById('identity');
identityUserId = data ? data.user_id : null;
if (!data) { el.textContent = '未登录'; el.className = 'muted'; return; }
const roles = (data.roles || []).map(r => `<span class="tag">${r}</span>`).join('');
el.className = '';
el.innerHTML =
`用户 <b>${data.username || data.user_id}</b> 角色 ${roles || '<span class="muted">(无)</span>'}<br>` +
`<span class="muted">data_scope=<b>${data.data_scope ?? '-'}</b> ` +
`令牌有效期 ${data.expires_in} 秒 令牌 ${data.access_token.slice(0, 24)}…</span>`;
renderProbes(data.roles || [], data.user_id);
}
// 按角色给出可调的只读接口 —— 用来核对"登录给的 roles"与"接口实际放行"是否一致。
// `userId` 必须用**当前登录用户**的 id:写死成 9001 会让管理员点了之后看到客户的信息,
// 看起来像"权限解析错了"(这个误导真实发生过一次)。
function renderProbes(roles, userId) {
const probes = [
{ label: '查我的画像', path: '/api/v1/users/me/memory-profile', all: true },
{ label: '风控概览', path: '/api/v1/risk/overview', role: 'risk_operator' },
{ label: '预警列表', path: '/api/v1/risk/alerts?limit=5', role: 'risk_operator' },
{ label: '角色清单', path: '/api/v1/admin/roles', role: 'admin' },
{ label: '我的身份(管理视角)',
path: `/api/v1/admin/users/${userId}/roles`, role: 'admin' },
// 这个接口按 `customer_id == 调用者 user_id` 过滤 —— 即"**我自己的**方案"
// (`product-recommendation:read:self` 的 `:self` 正对应这一点),与 `generate`
// 落库时写的 `customer_id=context.user_id` 是自洽的一对。
// 所以**投顾能看到自己生成并发布的方案**;空只说明还没有发布过任何方案。
{ label: '我发布的投顾方案', path: '/api/v1/advisor/recommendations/published',
role: 'advisor' },
];
const box = document.getElementById('probes');
box.innerHTML = '';
probes.forEach(p => {
const allowed = p.all || roles.includes(p.role);
const b = document.createElement('button');
b.textContent = p.label;
b.disabled = !allowed;
if (!allowed) b.title = `需要角色 ${p.role}`;
b.onclick = () => callApi(p.path);
box.appendChild(b);
});
}
async function doLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) { show('请先填用户名与密码'); return; }
show('请求中…');
try {
const res = await fetch('/api/v1/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const body = await res.json();
if (res.ok) {
token = body.data.access_token;
renderIdentity({ ...body.data, username });
show(`HTTP ${res.status} 登录成功\\n\\n` + JSON.stringify(body, null, 2));
} else {
token = null;
renderIdentity(null);
show(`HTTP ${res.status} 登录失败\\n\\n` + JSON.stringify(body, null, 2) +
'\\n\\n提示:401 涵盖"用户名不存在 / 密码错 / 账号停用",服务端刻意不区分;' +
'429 是登录限流(60 秒 10 次)。');
}
} catch (e) {
show('请求异常:' + e);
}
}
async function callApi(path) {
if (!token) { show('请先登录'); return; }
show('请求中…');
try {
const res = await fetch(path, { headers: { Authorization: 'Bearer ' + token } });
let text = await res.text();
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 原样显示 */ }
const mark = res.ok ? '<span class="ok">通过</span>' : '<span class="bad">被拒</span>';
show(`GET ${path}\\nHTTP ${res.status} ${mark}\\n\\n` + text);
} catch (e) {
show('请求异常:' + e);
}
}
// 查任意用户 id 的身份(管理员用)。不写死任何 id:留空则查当前登录用户。
async function probeUser() {
const raw = document.getElementById('probeUserId').value.trim();
const target = raw || (identityUserId || '');
if (!target) { show('请填用户 id;或先登录(留空时默认查当前登录用户)'); return; }
await callApi(`/api/v1/admin/users/${target}/roles`);
}
function logout() {
token = null;
renderIdentity(null);
show('已清除本地令牌(服务端没有注销接口,令牌在过期前仍然有效 —— 这是当前已知边界)');
}
</script>
</body>
</html>
"""
def build_console() -> FastAPI:
base_app = create_app()
console = FastAPI(title="登录测试台", docs_url=None, redoc_url=None)
@console.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
return HTMLResponse(PAGE)
@console.api_route(
"/api/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
)
async def proxy(path: str, request: Request) -> Response:
"""把 `/api/*` 同源转发给主应用(进程内 ASGI,不起第二个服务)。
同源是为了避开 CORS,也让浏览器不必直连主服务;`Authorization` 等请求头
原样透传。响应连状态码一起回传 —— 这个页面的用处之一就是看真实状态码。
"""
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in {"host", "content-length"}
}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=base_app),
base_url="http://base",
timeout=60,
) as client:
upstream = await client.request(
request.method,
f"/api/{path}",
headers=headers,
content=await request.body(),
params=request.query_params,
)
passthrough = {
key: value
for key, value in upstream.headers.items()
if key.lower() in {"content-type", "retry-after", "x-trace-id"}
}
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=passthrough,
)
return console
def main() -> None:
parser = argparse.ArgumentParser(description="登录测试台(浏览器验证登录接口)")
parser.add_argument("--port", type=int, default=8099)
args = parser.parse_args()
print(f"登录测试台:http://127.0.0.1:{args.port}")
print(" 演示账号:cust_t/123456 risk_t/666666 admin_t/88888888")
uvicorn.run(build_console(), host="127.0.0.1", port=args.port, log_level="warning")
if __name__ == "__main__":
main()