## 投顾角色
投顾线合并后,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 张。
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""登录测试台:一个记事本级别的前端,用来在浏览器里验证登录接口。
|
||
|
||
浏览器打开 **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()
|