Files
group_fqcd_jr/tools/login_console.py
T
lzf_0626 5018f11843 增加投顾(advisor)角色;修正登录测试的错误假设;修投顾带入的 2 处文档重号
## 投顾角色

投顾线合并后,bootstrap.py 有 10 处 allowed_roles 引用 advisor,
financial_nl2sql_service.py:272 还硬编码检查 {"advisor","operator","admin","super_admin"},
promotion_material_service.py:164 按 "advisor" in context.roles 走业务分支 ——
但 sys_role 里没有这个角色、sys_permission 里也没有投顾那 16 个权限码(种子只建到 9019)。
表现是所有投顾接口 403,而报错看起来像"权限配错了",实际是角色根本不存在。

- tools/grant_advisor_role.py:建 advisor 角色(id=9004,避开种子的 9001-9003 重建范围)
  + 16 个投顾权限(id 9020-9035)+ 授权(advisor 拿 10 项工作流、admin 补齐 16 项)。
  只增不删、可重复执行、带 --dry-run。
- tools/create_test_user.py:ROLE_IDS 加 advisor。
- 先跑 alembic upgrade head:补 21 张 advisor_* 表,业务表 68 → 89,审计通过。

权限划分:投顾工作流 10 项(read:self / generate:self / review / publish)给 advisor;
治理类 6 项(product-governance:*、profile-governance:*、asset-allocation:backtest)只给 admin。
review/publish 也给 advisor,与既有决策一致(此前已裁定不做双人复核)。

验证:advisor_t 登录 200,roles=['advisor'] data_scope=all 权限 10 项;
用它查 RBAC 清单得 403(没有 audit:read),边界正确。

⚠️ 与种子的冲突:seed_test_rbac.py 是 DELETE 重建语义,其
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 会清掉本脚本建的权限。
要把投顾权限固化,应并进 seed_test_rbac.py 的 PERMISSIONS 常量。

## 修正登录测试的一个错误假设

test_issued_token_actually_works_on_a_real_endpoint 原本用客户的
/users/me/memory-profile 验证令牌可用,投顾合并后它返回 403。追下去发现**与令牌无关**:
那个接口对客户有业务前置"请先完成开户风险测评问卷",而演示客户 9001 没有测评记录。
是我的测试选错了验证端点,把"业务前置未满足"误判成"令牌坏了"。

- 改用管理员令牌调 /api/v1/admin/roles(需要 audit:read,走完整鉴权链路),
  并补一条反向对照:不带令牌必须 401,否则那个 200 说明不了令牌有效。
- 把那个业务前置单独写成一个用例,让后来者一眼看到条件,而不是反复怀疑令牌。

过程里我先按控制台乱码猜了两次失败原因,都不对;最后把响应抓成 UTF-8 文件才看到真实
消息。教训记下:不要读乱码猜消息。

## 修投顾带入的 2 处文档重号

21-投顾Agent迁移TODO.md → 30-…、22-投顾Agent灰度与回滚操作手册.md → 31-…
(沿用 NL 那次让号的先例:既有文档更早、引用更多;且这两份新文档没有被任何地方引用。)
文档守卫:40 份无编号冲突。

验证:ruff 干净 / mypy 228 文件 0 错 / 文档守卫 40 份无冲突 /
unit+contract 1207 passed(0 failed)/ integration 99 passed / 业务表 89 张。
2026-09-11 21:49:46 +08:00

289 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>
</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' },
];
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()