Files
group_fqcd_jr/tools/grant_advisor_role.py
T
lzf_0626 615032ab00 fix: 修 POST /api/v1/conversations 的 MissingGreenlet(会话建不出来导致转人工 404)
- public_platform_service: 创建 ConversationSession 时显式赋值四个 server_default 时间列,
  否则 flush() 后需回读数据库生成值,在 async session 里以同步属性访问触发
  MissingGreenlet,接口 500,连带转人工一直报会话不存在
- portal: 客服改用 C001 真实建会话;转人工改传 reason_code/reason_detail(原 reason 属额外字段 422)
- portal: 投顾查客户投资目标走 customers/{id} 变体
- seed/grant: 补 investment-goal:{read,write,confirm}:customer 三个动态拼出的权限码
  (data_scope=own_customers,投顾只看名下客户)
2026-09-12 15:37:36 +08:00

249 lines
11 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` 走业务分支 ——
但 `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())