Files
group_fqcd_jr/tools/login_console.py
T
lzf_0626 306a514316 修正登录测试台的探针:投顾那个按钮其实该是客户视角
/api/v1/advisor/recommendations/published 的语义是"**我(客户)自己**已发布的方案" ——
服务端按 `customer_id == 调用者 user_id` 过滤(权限码 `product-recommendation:read:self`
的 `:self` 正对应这一点),所以**投顾调它必然为空**:筛的是 customer_id = 投顾自己的 id。
把它标成 customer 角色,免得再用投顾账号点它、然后怀疑权限。

查证过程中确认了三处数据空缺(投顾合并后那 21 张 advisor_* 表是新建的):
- client_facing_content = 0 行(没有任何投顾方案)
- sys_customer_assignment = 0 行(投顾没绑定任何客户,故 customer_ids 为空)
- fin_risk_assessment 里没有 9001(客户画像的前置"完成开户风险测评问卷"未满足)
2026-09-11 22:28:32 +08:00

295 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` 正对应这一点),所以**投顾调它必然为空**。标成 customer 才不会被误用。
{ label: '我已发布的投顾方案(客户视角)',
path: '/api/v1/advisor/recommendations/published', role: 'customer' },
];
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()