Files
group_fqcd_jr/tools/grant_advisor_role.py
T
Windows 2fe7d0c506 feat(投顾): 客户主动申报投顾方案 + 投顾受理自动生成方案草稿
客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 →
投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。

后端
- 新增表 advisor_service_request(本轮新建,带 AUTO_INCREMENT)+ 迁移
  20260916_advisor_service_request(幂等:先查表再建,兼容本库 alembic 指针滞后)
- 新增 AdvisorServiceRequestService:create / list_mine / queue / review
  · 申报前置:风险测评必须存在且未失效(FM-03,12 个月),服务端判
  · 队列复用 ProductRecommendationService._visible_customer_ids(本人 + 归属),待受理排前
  · 受理即调用推荐逻辑生成草稿并把 content_id 回填;生成前置失败给出人话原因并保持待受理
  · 「已发送客户」不落库,由关联方案 published_at 推导,避免两处状态各写各的
- 权限码 9071-9074(客户 write/read:self、投顾 read/review),已进种子与授权工具

前端
- 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见)
- 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表

客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。
2026-09-16 18:17:46 +08:00

275 lines
13 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`** |
| 方案操作(3,本脚本声明,种子里是 9031/9032/9070) | `product-recommendation:review` / `:publish` / `:delete` | `advisor` + `admin` —— 用于**修复没跑过种子的环境**(否则工作台四个按钮整体 403) |
| 治理类(种子已含) | `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 之后,与两侧都不冲突。
#: 本脚本声明 6 行:3 个治理类(种子里没有,9041-9043)+ 3 个方案操作
#: (种子里有,9031/9032/9070 —— 声明出来是为了在**没跑过种子的环境**里补齐)。
#: `check_rbac_seed_consistency.py` 要求这里每行的 id+code 与种子完全一致。
#: (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"),
# 推荐方案的审核/发布/删除。**定义在种子里**(9031/9032/9070),这里再声明一遍
# 是为了让本脚本能**修复缺失的环境**:有些库(如本机演示库)从没跑过
# `seed_test_rbac.py`,这三行就不存在 —— 结果是投顾工作台「审核通过 / 驳回 /
# 发送给客户 / 删除」四个按钮**整体 403**,而报错只显示"权限不足"。
# 本脚本按 code 判重,缺哪行补哪行(只增不删)。
(9031, "product-recommendation:review", "product-recommendation", "review", "all"),
(9032, "product-recommendation:publish", "product-recommendation", "publish", "all"),
(9070, "product-recommendation:delete", "product-recommendation", "delete", "all"),
# 客户申报受理(队列 + 受理/驳回)。定义在种子的 9073/9074。
(9073, "advisor-request:read", "advisor-request", "read", "all"),
(9074, "advisor-request:review", "advisor-request", "review", "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",
# 代客三项:对**名下客户**(而非自己)做推荐 / 资产配置 / 组合分析。
# 同样是服务层按 `customer_id == 自己` 动态拼出来的 `:customer` 变体,
# `data_scope=own_customers` —— 缺这三个码,投顾工作台一选客户就整片 403
# (`product_recommendation_service.py:71`、`asset_allocation_service.py:57`、
# `portfolio_analysis_service.py:46`)。
"product-recommendation:generate:customer",
"asset-allocation:generate:customer",
"portfolio-analysis:read:customer",
"product-recommendation:review",
"product-recommendation:publish",
"product-recommendation:delete",
# 客户申报受理:看名下客户的申报队列 + 受理/驳回(受理会自动出方案草稿)。
"advisor-request:read",
"advisor-request:review",
# 平台通用:投顾同样要跑 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())