## 现象
投顾工作台(`advisor_t`,9020)一选客户就整片失败,页面只显示
「请求失败,请检查权限或稍后重试」。API 访问日志给出真相:
```
POST /api/v1/advisor/recommendations 403
POST /api/v1/advisor/asset-allocation 403
POST /api/v1/advisor/portfolio-analysis 403
GET /api/v1/advisor/recommendations/published 200 ← 只有它不需要代客权限
```
## 根因:三个 `*:customer` 权限码在库里根本不存在
三个服务在"代客"(`customer_id != context.user_id`)时要求的是**动态拼出来的
`:customer` 变体**:
- `product_recommendation_service.py:71` → `product-recommendation:generate:customer`
- `asset_allocation_service.py:57` → `asset-allocation:generate:customer`
- `portfolio_analysis_service.py:46` → `portfolio-analysis:read:customer`
而 `sys_permission` 里 `:customer` 后缀**只有 4 个**(`memory:read:customer`、
`investment-goal:{read,write,confirm}:customer`)—— 这三个从来没登记过。
`AuthorizationService.require()` 第一步就查不到该码 ⇒ 直接 403。
这与种子里 **9057-9059 的注释是同一个坑**("动态拼出来的权限码,对账工具抓不到字面量,
于是投顾查/建/确认客户目标全部 403"),前人修了 investment-goal 那三个,这三个漏了。
## 修法(按仓库纪律:种子定义权限码 → grant 脚本绑定角色)
1. `tools/seed_test_rbac.py`:新增 **9066-9068** 三个码,`data_scope=own_customers`
(`require_customer_scope` 只放行 `all`,或 `own_customers` 且客户确在
`context.customer_ids` = 归属客户内 —— 这正是投顾该有的最小权限),
并写明三处调用点,避免后人再漏;
2. `tools/grant_advisor_role.py`:把三个码加入 `ADVISOR_GRANTED_CODES`
(admin 因 `ADMIN_PERMISSIONS = 全部种子权限` 自动获得)。
已执行:`seed_test_rbac.py` → `grant_advisor_role.py`
(advisor 新增 3 项,共 31 项;admin 62 项)。
## 验证(真实 HTTP,9020 身份)
| 客户 | 推荐方案 | 资产配置 | 组合分析 |
|---|---|---|---|
| 9101(演示客户) | 200 `profile_required` | 200 `profile_required` | 200 `no_positions` |
| **9001(真实客户)** | **200 已生成方案**(content_id=5, pending_review) | **200 ready**(含配置比例) | **200 ready**(7 个持仓,市值 10698.60) |
**403 全部消失**;对真实客户三项均返回真实结果。
## 同时补的归属数据
`sys_customer_assignment` 原有 9020→9001 一行;工作台把演示客户的 id
(9101-9104)当真实 `customer_id` 发给后端,而 `own_customers` 要求客户在投顾名下,
因此用 `tools/assign_customer_scope.py` 补了 4 行(9020 → 9101/9102/9103/9104),
回验 `customer_ids = ('9001','9101','9102','9103','9104')`。
## 仍未解决(属**数据**缺口,不是权限)
1. **9101-9104 在库里没有任何数据**:`advisor-config.js:126` 注释指向的
`_seed_demo_customers.py` 在仓库、git 历史与桌面上**都不存在**(从未提交),
所以这四位没有账号 / 风险测评 / 投资目标 / 持仓 —— 只能返回 `profile_required`。
2. **推荐候选恒为 0**:`AdvisorProductRepository.authoritative_tradable_products`
要求**已验证**的适当性证据与合同快照(`review_status='verified'` 且
`source_url` / `document_sha256` 非空,fail closed),而
`advisor_product_suitability_reference` / `advisor_product_contract_snapshot`
均为 0 行。这需要产品治理线提供可核查证据,**不应伪造**。
## 门禁
`tools/check_rbac_seed_consistency.py` 通过(种子内 id 唯一、各 grant 脚本与种子逐条一致);
`pytest tests/unit tests/contract` 全绿。
257 lines
11 KiB
Python
257 lines
11 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`** |
|
||
| 治理类(种子已含) | `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",
|
||
# 代客三项:对**名下客户**(而非自己)做推荐 / 资产配置 / 组合分析。
|
||
# 同样是服务层按 `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",
|
||
# 平台通用:投顾同样要跑 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())
|