Files
group_fqcd_jr/tools/grant_advisor_role.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

225 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""建立投顾(`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())