客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 → 投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。 后端 - 新增表 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),已进种子与授权工具 前端 - 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见) - 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表 客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。
275 lines
13 KiB
Python
275 lines
13 KiB
Python
"""建立投顾(`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())
|