Files

249 lines
11 KiB
Python
Raw Permalink Normal View History

"""建立投顾(`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"),
)
#: 投顾拿哪些。2026-09-12 补齐:此前只给了 10 项工作流权限,结果投顾**用不了**
#: 投资目标创建/确认、跑不了 Agent、查不了行情与知识、看不了所服务客户的画像 ——
#: 表现出来就是一片 `AGENT_PERMISSION_DENIED`。下面每一项都对应代码里真实用到的地方。
ADVISOR_GRANTED_CODES: tuple[str, ...] = (
# 投顾工作流(定义在种子的 9020-9034)
"asset-allocation:generate:self",
"investment-goal:read:self",
"investment-goal:write:self", # 新建投资目标
"investment-goal:confirm:self", # 与客户确认目标
# 看/建/确认**客户**(而非自己)的投资目标。这三个码是 `investment_goal_service.py`
# 按 `customer_id == 自己` 动态拼出来的,`data_scope=own_customers`:
# 只有客户在投顾名下才放行 —— 投顾服务的本来就是别人的钱。
"investment-goal:read:customer",
"investment-goal:write:customer",
"investment-goal:confirm:customer",
"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",
# 平台通用:投顾同样要跑 Agent、查行情、检索知识、看所服务客户的画像
"agent:run",
"suitability:read",
"fund:quote:read",
"knowledge:query",
"knowledge:reference:read",
"memory:read:customer",
"conversation:create",
"conversation:close",
"conversation:feedback",
# 推广材料(`promotion_material_service.py:164` 专门判 `advisor`)
"promotion:read",
"promotion:write",
"promotion:deliver",
# 金融数据(`financial_nl2sql_service.py` 的角色白名单含 advisor)
"financial:nl2sql:read",
)
#: 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())