"""建立投顾(`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` 走业务分支 —— 但 `seed_test_rbac.py` **只重建 customer / risk_operator / admin 三个角色**, `sys_role` 里没有 `advisor`。结果是:投顾登录后拿不到任何投顾权限, 所有投顾接口一律 403,而报错看起来像"权限配错了",实际是**角色根本不存在**。 ## 号段为什么是 9041(重要,2026-09-12 修正) 投顾那 13 个业务权限码**已由投顾线(`bbf623a`)并进 `seed_test_rbac.py` 的 9020-9034**, 但那个种子**漏了 3 个治理类权限码**(`product-governance:*`),而治理接口直接要求它们。 本脚本现在只补这 3 个(**9041-9043**)+ 建角色 + 绑定。 ⚠️ 本脚本**此前**用 9020-9035 定义过整套 16 个权限,与种子的 9020-9034 **id→code 映射不同**。 若库里还留着那批旧数据,跑一次种子会把 9020-9034 换成种子的语义,而 `advisor` 角色(9004) 的绑定**不在种子的清理范围内**(种子只清 role_id 9001-9003),于是它的绑定会指向**错误的权限码**。 **处置顺序:先跑 `seed_test_rbac.py`(对齐 9001-9034),再跑本脚本(补 9041-9043 并重建绑定)。** ## 权限怎么分 | 类别 | 权限码 | 给谁 | |---|---|---| | 投顾工作流(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` —— **定义在种子的 9020-9034** | | 治理类(3,本脚本建) | `product-governance:read` / `:review` / `:sync` | **只给 `admin`** | | 治理类(种子已含) | `asset-allocation:backtest`、`profile-governance:read` / `:review` | **只给 `admin`** | `review` / `publish` 也给投顾,与项目既有决策一致 —— 此前已裁定**不做双人复核** (`admin` 发布配置时也是"创建人自审")。治理类不给投顾:那是平台侧的活。 本脚本自身**只增不删**:重复执行只补齐缺失项,不动任何已有绑定。 用法: 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 从 9041 起。两个硬约束: #: 1. 种子 `seed_test_rbac.py` 已占 9001-9034(含投顾线的 9020-9034); #: 2. 库里曾有一批 9020-9035 是本脚本用**旧号段**建的,与种子的 9020-9034 #: **id→code 映射不同** —— 整体挪到 9041 之后,与两侧都不冲突。 #: 只定义种子里**缺**的这 3 个治理类权限码;其余 13 个由种子提供。 #: (id, permission_code, resource, action, data_scope) ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( (9041, "product-governance:read", "product-governance", "read", "all"), (9042, "product-governance:review", "product-governance", "review", "all"), (9043, "product-governance:sync", "product-governance", "sync", "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())