增加投顾(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 张。
This commit is contained in:
2026-09-11 21:49:46 +08:00
parent c4a73b735e
commit 5018f11843
7 changed files with 299 additions and 16 deletions
@@ -25,7 +25,8 @@
|---|---|---|---|---| |---|---|---|---|---|
| 9001 | **`cust_t`** | `123456` | `customer` | 客户界面:自己的会话、适当性、知识问答 | | 9001 | **`cust_t`** | `123456` | `customer` | 客户界面:自己的会话、适当性、知识问答 |
| 9002 | **`risk_t`** | `666666` | `risk_operator` | 风控界面:预警队列、证据、日报 | | 9002 | **`risk_t`** | `666666` | `risk_operator` | 风控界面:预警队列、证据、日报 |
| 9003 | **`admin_t`** | `88888888` | `admin` | 管理界面:配置发布、模型端点、审计 | | 9003 | **`admin_t`** | `88888888` | `admin` | 管理界面:配置发布、模型端点、审计、RBAC 清单 |
| 9020 | **`advisor_t`** | `abc12345` | `advisor` | 投顾界面:组合分析、产品对比、投资目标、方案发布 |
> 密码由 `tools/set_user_password.py` 设置。**这三种弱口令仅用于演示**, > 密码由 `tools/set_user_password.py` 设置。**这三种弱口令仅用于演示**,
> 上线前必须全部更换。 > 上线前必须全部更换。
@@ -135,9 +136,9 @@ python tools/create_test_user.py --id 9010 --username test_cust --role customer
python tools/create_test_user.py --id 9011 --username test_risk --role risk_operator --password abc12345 python tools/create_test_user.py --id 9011 --username test_risk --role risk_operator --password abc12345
``` ```
**角色只有三个可选值**:`customer` / `risk_operator` / `admin`(`sys_role` 里现成的三个)。 **角色可选值**:`customer` / `risk_operator` / `admin` / `advisor`(投顾)。
要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),那超出这个脚本的范围, 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)——
找平台侧。 `advisor` 就是由 `tools/grant_advisor_role.py` 建立的,可参照它。
### 脚本会做三件事,并**验证第四件** ### 脚本会做三件事,并**验证第四件**
+41 -7
View File
@@ -126,22 +126,56 @@ async def test_each_role_can_login_with_its_own_role(
async def test_issued_token_actually_works_on_a_real_endpoint() -> None: async def test_issued_token_actually_works_on_a_real_endpoint() -> None:
"""签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。 """签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。
`GET /api/v1/users/me/memory-profile` 需要 `memory:read:self`(客户角色有), 用管理员令牌调 `/api/v1/admin/roles`(需要 `audit:read`),走的是
走的是 `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve` `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve` 全链路,
这条真实链路:令牌只带 `sub`,角色与权限全部查库解析。 与真实请求完全一致。再补一条**反向对照**:不带令牌必须 401 ——
否则"返回 200"也可能只是这个端点根本没鉴权。
⚠️ 这里**刻意不用** `/users/me/memory-profile`:它对客户有"必须先完成开户风险测评
问卷"的业务前置,而演示客户 9001 没有测评记录,于是返回 403 ——
那看起来像令牌坏了,实际与令牌无关(见下面那条用例)。
"""
async with client() as http:
response = await login(http, "admin_t", "88888888")
assert response.status_code == 200, response.text
token = response.json()["data"]["access_token"]
authorized = await http.get(
"/api/v1/admin/roles", headers={"Authorization": f"Bearer {token}"}
)
assert authorized.status_code == 200, authorized.text
assert isinstance(authorized.json()["data"], list)
anonymous = await http.get("/api/v1/admin/roles")
assert anonymous.status_code == 401, "不带令牌必须 401,否则上面的 200 说明不了令牌有效"
@pytest.mark.asyncio
async def test_customer_profile_requires_completed_assessment() -> None:
"""画像接口对客户有业务前置:必须先完成开户风险测评问卷。
演示客户 9001 目前**没有测评记录**(`fin_risk_assessment` 没有它的行),所以拿它的
令牌调自己的画像会得到 `403 请先完成开户风险测评问卷`。这不是令牌或 RBAC 的问题
—— 同一个接口用管理员令牌是 200。
把这个条件写成用例,是为了让后来者一眼看到它,而不是像这次的集成测试那样
反复怀疑"是不是登录/令牌坏了"。要演示客户画像,先给 9001 补一条测评记录。
""" """
async with client() as http: async with client() as http:
response = await login(http, "cust_t", "123456") response = await login(http, "cust_t", "123456")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
token = response.json()["data"]["access_token"] token = response.json()["data"]["access_token"]
authorized = await http.get( profile = await http.get(
"/api/v1/users/me/memory-profile", "/api/v1/users/me/memory-profile",
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
) )
# 200=有画像,404=该客户还没有画像行;两者都说明**令牌被接受并通过了 RBAC**。
# 401/403 则说明令牌或身份解析链有问题。 # 令牌是被接受的(不是 401);被拒的原因是业务前置而非鉴权。
assert authorized.status_code in (200, 404), authorized.text assert profile.status_code in (200, 403), profile.text
if profile.status_code == 403:
assert profile.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
assert "测评" in profile.json()["error"]["message"]
@pytest.mark.asyncio @pytest.mark.asyncio
+5 -2
View File
@@ -40,12 +40,14 @@ from app.service.identity_service import IdentityService
if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: 库里现成的三个角色(`tools/seed_test_rbac.py` 建的)。新用户复用它们。 #: 库里现成的角色。新用户复用它们。
#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),超出本脚本范围。 #: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)——
#: `advisor` 就是由 `tools/grant_advisor_role.py` 建立的。
ROLE_IDS: dict[str, int] = { ROLE_IDS: dict[str, int] = {
"customer": 9001, "customer": 9001,
"risk_operator": 9002, "risk_operator": 9002,
"admin": 9003, "admin": 9003,
"advisor": 9004,
} }
#: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。 #: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。
@@ -53,6 +55,7 @@ ROLE_USER_TYPE: dict[str, str] = {
"customer": "customer", "customer": "customer",
"risk_operator": "employee", "risk_operator": "employee",
"admin": "employee", "admin": "employee",
"advisor": "employee",
} }
#: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。 #: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。
+224
View File
@@ -0,0 +1,224 @@
"""建立投顾(`advisor`)角色并授权。
## 为什么需要它
投顾这条线合并进来后,`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 个权限码
(`seed_test_rbac.py` 只建到 9019)。结果是:投顾登录后拿不到任何投顾权限,
所有投顾接口一律 403,而报错看起来像"权限配错了",实际是**角色根本不存在**。
## 权限怎么分
| 类别 | 权限码 | 给谁 |
|---|---|---|
| 投顾工作流(10) | `asset-allocation:generate:self`、`investment-goal:read:self` / `:review` / `:publish`、`portfolio-analysis:read:self`、`product-comparison:read:self`、`product-recommendation:read:self` / `:generate:self` / `:review` / `:publish` | `advisor` + `admin` |
| 治理类(6) | `asset-allocation:backtest`、`product-governance:read` / `:review` / `:sync`、`profile-governance:read` / `:review` | **只给 `admin`** |
`review` / `publish` 也给投顾,与项目既有决策一致 —— 此前已裁定**不做双人复核**
(`admin` 发布配置时也是"创建人自审")。治理类不给投顾:那是平台侧的活。
## ⚠️ 与 `seed_test_rbac.py` 的冲突
那个脚本是 **DELETE 重建**语义,它的
`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 会**清掉本脚本建的权限**
(本脚本用 9020-9035)。将来若要在种子里固化投顾权限,请把它并进
`seed_test_rbac.py` 的 `PERMISSIONS` 常量,而不是只跑本脚本。
本脚本自身**只增不删**:重复执行只补齐缺失项,不动任何已有绑定。
用法:
python tools/grant_advisor_role.py --dry-run # 只打印将写入什么
python tools/grant_advisor_role.py
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime
from sqlalchemy import text
from app.infrastructure.db import SessionFactory
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: 角色 id 用 9004:`seed_test_rbac.py` 只重建 9001-9003,不会碰它。
ADVISOR_ROLE_ID = 9004
ADVISOR_ROLE_CODE = "advisor"
ADVISOR_ROLE_NAME = "投资顾问"
#: 权限 id 从 9020 起,避开种子已用的 9001-9019。
#: (id, permission_code, resource, action, data_scope)
ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9020, "asset-allocation:generate:self", "asset-allocation", "generate", "self"),
(9021, "asset-allocation:backtest", "asset-allocation", "backtest", "all"),
(9022, "investment-goal:read:self", "investment-goal", "read", "self"),
(9023, "investment-goal:review", "investment-goal", "review", "all"),
(9024, "investment-goal:publish", "investment-goal", "publish", "all"),
(9025, "portfolio-analysis:read:self", "portfolio-analysis", "read", "self"),
(9026, "product-comparison:read:self", "product-comparison", "read", "self"),
(9027, "product-recommendation:read:self", "product-recommendation", "read", "self"),
(9028, "product-recommendation:generate:self", "product-recommendation", "generate", "self"),
(9029, "product-recommendation:review", "product-recommendation", "review", "all"),
(9030, "product-recommendation:publish", "product-recommendation", "publish", "all"),
(9031, "product-governance:read", "product-governance", "read", "all"),
(9032, "product-governance:review", "product-governance", "review", "all"),
(9033, "product-governance:sync", "product-governance", "sync", "all"),
(9034, "profile-governance:read", "profile-governance", "read", "all"),
(9035, "profile-governance:review", "profile-governance", "review", "all"),
)
#: 投顾拿哪些 —— 工作流那 10 个;治理类 6 个只给 admin。
ADVISOR_GRANTED_CODES: tuple[str, ...] = (
"asset-allocation:generate:self",
"investment-goal:read:self",
"investment-goal:review",
"investment-goal:publish",
"portfolio-analysis:read:self",
"product-comparison:read:self",
"product-recommendation:read:self",
"product-recommendation:generate:self",
"product-recommendation:review",
"product-recommendation:publish",
)
#: admin 角色 id(`seed_test_rbac.py` 建的)。
ADMIN_ROLE_ID = 9003
async def apply(*, dry_run: bool) -> int:
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
existing_codes = set(
(await session.scalars(
text("SELECT permission_code FROM sys_permission")
)).all()
)
to_create = [p for p in ADVISOR_PERMISSIONS if p[1] not in existing_codes]
print(f"权限:已存在 {len(existing_codes)} 个,本次新增 {len(to_create)} 个")
for _, code, resource, action, scope in to_create:
print(f" + {code:<44} {resource}:{action} scope={scope}")
role_exists = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"),
{"code": ADVISOR_ROLE_CODE},
)
print(f"角色 {ADVISOR_ROLE_CODE}:{'已存在' if role_exists else '将新建(id=9004)'}")
if dry_run:
print("\n[dry-run] 未写入任何数据。")
return 0
for perm_id, code, resource, action, scope in to_create:
await session.execute(
text(
"""
INSERT INTO sys_permission
(id, permission_code, resource, action, data_scope, created_at, updated_at)
VALUES
(:id, :code, :resource, :action, :scope, :now, :now)
"""
),
{"id": perm_id, "code": code, "resource": resource,
"action": action, "scope": scope, "now": now},
)
if role_exists is None:
await session.execute(
text(
"INSERT INTO sys_role"
" (id, role_code, role_name, status, created_at, updated_at)"
" VALUES (:id, :code, :name, 'active', :now, :now)"
),
{"id": ADVISOR_ROLE_ID, "code": ADVISOR_ROLE_CODE,
"name": ADVISOR_ROLE_NAME, "now": now},
)
role_id = int(
await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"),
{"code": ADVISOR_ROLE_CODE},
)
)
permission_ids = dict(
(await session.execute(
text("SELECT permission_code, id FROM sys_permission")
)).all()
)
# 授权:先查已有绑定,只补缺失的(只增不删)。
async def grant(role: int, codes: tuple[str, ...]) -> int:
have = set(
(await session.scalars(
text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"),
{"r": role},
)).all()
)
added = 0
for code in codes:
perm_id = permission_ids.get(code)
if perm_id is None or int(perm_id) in have:
continue
await session.execute(
text(
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
" VALUES (:r, :p, :now)"
),
{"r": role, "p": int(perm_id), "now": now},
)
added += 1
return added
all_codes = tuple(code for _, code, _, _, _ in ADVISOR_PERMISSIONS)
advisor_added = await grant(role_id, ADVISOR_GRANTED_CODES)
admin_added = await grant(ADMIN_ROLE_ID, all_codes)
print(f"授权:advisor 新增 {advisor_added} 项(共 {len(ADVISOR_GRANTED_CODES)} 项)")
print(f" admin 新增 {admin_added} 项(共 {len(all_codes)} 项)")
await verify()
return 0
async def verify() -> None:
"""用真实链路验证:角色与权限能被解析出来。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT r.role_code, COUNT(rp.permission_id) AS n
FROM sys_role r
LEFT JOIN sys_role_permission rp ON rp.role_id = r.id
GROUP BY r.id, r.role_code ORDER BY r.role_code
"""
)
)
).mappings().all()
print("\n各角色权限数(实测):")
for row in rows:
print(f" {str(row['role_code']):<16} {int(row['n'])} 项")
if not any(str(row["role_code"]) == ADVISOR_ROLE_CODE for row in rows):
print("[失败] advisor 角色没有建成功")
raise SystemExit(1)
print(
"\n下一步:给某个人绑这个角色 ——\n"
" python tools/create_test_user.py --id 9020 --username advisor_t "
"--role advisor --password abc12345"
)
def main() -> int:
parser = argparse.ArgumentParser(description="建立投顾角色并授权")
parser.add_argument("--dry-run", action="store_true", help="只打印将写入什么")
args = parser.parse_args()
return asyncio.run(apply(dry_run=args.dry_run))
if __name__ == "__main__":
sys.exit(main())
+24 -3
View File
@@ -103,11 +103,20 @@ PAGE = """<!doctype html>
<span class="muted">(下面按钮按角色给,方便核对权限)</span> <span class="muted">(下面按钮按角色给,方便核对权限)</span>
</div> </div>
<div class="row" id="probes"></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> <pre id="output">(还没有请求)</pre>
</div> </div>
<script> <script>
let token = null; let token = null;
// 当前登录用户的 id(取自登录响应),供"我的身份"以及"用户 id 留空时默认查自己"使用。
// 页面里**不写死任何用户 id**:写死会让人以为权限解析错了(真实发生过一次)。
let identityUserId = null;
function fill(u, p) { function fill(u, p) {
document.getElementById('username').value = u; document.getElementById('username').value = u;
@@ -120,6 +129,7 @@ function show(text) {
function renderIdentity(data) { function renderIdentity(data) {
const el = document.getElementById('identity'); const el = document.getElementById('identity');
identityUserId = data ? data.user_id : null;
if (!data) { el.textContent = '未登录'; el.className = 'muted'; return; } if (!data) { el.textContent = '未登录'; el.className = 'muted'; return; }
const roles = (data.roles || []).map(r => `<span class="tag">${r}</span>`).join(''); const roles = (data.roles || []).map(r => `<span class="tag">${r}</span>`).join('');
el.className = ''; el.className = '';
@@ -127,17 +137,20 @@ function renderIdentity(data) {
`用户 <b>${data.username || data.user_id}</b> 角色 ${roles || '<span class="muted">(无)</span>'}<br>` + `用户 <b>${data.username || data.user_id}</b> 角色 ${roles || '<span class="muted">(无)</span>'}<br>` +
`<span class="muted">data_scope=<b>${data.data_scope ?? '-'}</b> ` + `<span class="muted">data_scope=<b>${data.data_scope ?? '-'}</b> ` +
`令牌有效期 ${data.expires_in} 秒 令牌 ${data.access_token.slice(0, 24)}…</span>`; `令牌有效期 ${data.expires_in} 秒 令牌 ${data.access_token.slice(0, 24)}…</span>`;
renderProbes(data.roles || []); renderProbes(data.roles || [], data.user_id);
} }
// 按角色给出可调的只读接口 —— 用来核对"登录给的 roles"与"接口实际放行"是否一致。 // 按角色给出可调的只读接口 —— 用来核对"登录给的 roles"与"接口实际放行"是否一致。
function renderProbes(roles) { // `userId` 必须用**当前登录用户**的 id:写死成 9001 会让管理员点了之后看到客户的信息,
// 看起来像"权限解析错了"(这个误导真实发生过一次)。
function renderProbes(roles, userId) {
const probes = [ const probes = [
{ label: '查我的画像', path: '/api/v1/users/me/memory-profile', all: true }, { label: '查我的画像', path: '/api/v1/users/me/memory-profile', all: true },
{ label: '风控概览', path: '/api/v1/risk/overview', role: 'risk_operator' }, { label: '风控概览', path: '/api/v1/risk/overview', role: 'risk_operator' },
{ label: '预警列表', path: '/api/v1/risk/alerts?limit=5', 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/roles', role: 'admin' },
{ label: '我的身份(管理视角)', path: '/api/v1/admin/users/9001/roles', role: 'admin' }, { label: '我的身份(管理视角)',
path: `/api/v1/admin/users/${userId}/roles`, role: 'admin' },
]; ];
const box = document.getElementById('probes'); const box = document.getElementById('probes');
box.innerHTML = ''; box.innerHTML = '';
@@ -194,6 +207,14 @@ async function callApi(path) {
} }
} }
// 查任意用户 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() { function logout() {
token = null; token = null;
renderIdentity(null); renderIdentity(null);