客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 → 投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。 后端 - 新增表 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),已进种子与授权工具 前端 - 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见) - 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表 客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。
394 lines
24 KiB
Python
394 lines
24 KiB
Python
"""造 RBAC 测试数据,验证真实链路身份加载与越权拦截。
|
||
|
||
数据使用 9001 起的号段(角色 9001-9003、权限 9001-9046),便于清理,不影响业务数据。
|
||
权限码是几条线叠加出来的:9001-9019 基础能力、9020-9034 投顾线、9041-9046 投顾治理类与客服二期。
|
||
|
||
⚠️ 本脚本对**权限、角色、角色绑定**是 **DELETE 重建**:`DELETE FROM sys_permission WHERE
|
||
id BETWEEN 9001 AND 9099` 会删掉该号段内**所有**权限(包括别的线用 `grant_*.py` 建的),
|
||
再按 `PERMISSIONS` 重建,并且**只清 role_id 9001-9003 的角色绑定**。三个已知后果:
|
||
|
||
1. 新增权限码必须并进本文件的 `PERMISSIONS`,否则重建一次就没了(表现为"接口突然 403");
|
||
2. `advisor`(9004)这类不在 `ROLES` 里的角色,其绑定**不会被清** —— 若它们引用的
|
||
permission_id 在重建时换了语义,绑定就会指向错误的权限码。改 id→code 映射前先确认;
|
||
3. **`sys_user` 不再是 DELETE 重建**(2026-09-12 改):投顾域的 `advisor_profile_tag` 等表用
|
||
FK 引用 `sys_user`,一旦库里有数据引用 9001-9003,`DELETE FROM sys_user` 会被外键拒绝
|
||
(MySQL 1451),**种子整个跑不完**;而 `commit()` 在最后,所以外部表现是"什么都没发生"。
|
||
现已改为「存在则 UPDATE 非密码字段、不存在才 INSERT」,且**不覆盖 `password_hash`**,
|
||
因此重跑种子不会再弄丢演示密码,也不需要事后补 `set_user_password.py`。
|
||
|
||
为什么需要这份种子:权限码必须与**代码实际声明**一致——工具声明写在
|
||
`app/service/agent/bootstrap.py`(`check_suitability` 需 `suitability:read`、
|
||
`query_fund_quote` 需 `fund:quote:read`),接口校验写在各 Service 的
|
||
`AuthorizationService.require(...)`。种子缺权限的后果不是"少测一项",而是
|
||
"功能明明实现了却一直 403",让验收与集成测试得出错误结论。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sys
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import text
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
if str(PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
||
from app.core.contracts import RequestContext # noqa: E402
|
||
from app.infrastructure.db import SessionFactory # noqa: E402
|
||
from app.service.identity_service import IdentityService # noqa: E402
|
||
|
||
CUSTOMER_USER = "9001"
|
||
RISK_USER = "9002"
|
||
ADMIN_USER = "9003"
|
||
|
||
# (权限 id, 权限码, resource, action, data_scope)
|
||
PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
|
||
(9001, "agent:run", "agent", "run", "self"),
|
||
(9002, "suitability:read", "suitability", "read", "self"),
|
||
(9003, "fund:quote:read", "fund", "quote", "self"),
|
||
(9004, "conversation:create", "conversation", "create", "self"),
|
||
(9005, "conversation:close", "conversation", "close", "self"),
|
||
(9006, "conversation:feedback", "conversation", "feedback", "self"),
|
||
(9007, "agent:cancel", "agent", "cancel", "self"),
|
||
(9008, "handover:create", "handover", "create", "self"),
|
||
(9009, "memory:read:self", "memory", "read", "self"),
|
||
(9010, "memory:read:customer", "memory", "read", "own_customers"),
|
||
(9011, "knowledge:reference:read", "knowledge", "reference", "self"),
|
||
(9012, "audit:read", "audit", "read", "all"),
|
||
(9013, "config:read", "config", "read", "all"),
|
||
(9014, "config:write", "config", "write", "all"),
|
||
(9015, "config:review", "config", "review", "all"),
|
||
(9016, "config:activate", "config", "activate", "all"),
|
||
(9017, "model-endpoint:manage", "model-endpoint", "manage", "all"),
|
||
# `query_knowledge` 工具声明该权限(`app/service/knowledge_tool.py` 的
|
||
# `REQUIRED_PERMISSION`);缺失会让客服 Agent 调工具时被拒(缺少工具权限)。
|
||
# 补入种子以保证"重跑种子 / 换环境"后权限仍存在。
|
||
(9018, "knowledge:query", "knowledge", "query", "self"),
|
||
# 知识库**管理**面(Task 11):上传/列表/删除三个端点统一要这个权限码
|
||
# (`app/service/knowledge_management_service.py` 的 `REQUIRED_PERMISSION`)。
|
||
# 刻意不把 `knowledge:query`(客户角色也有)复用成管理权限:那会让客户看到整库文档清单。
|
||
# 归属:只给 admin(ADMIN_PERMISSIONS 是全量元组,自动包含本行)。
|
||
(9019, "knowledge:manage", "knowledge", "manage", "all"),
|
||
(9020, "investment-goal:write:self", "investment-goal", "write", "self"),
|
||
(9021, "investment-goal:confirm:self", "investment-goal", "confirm", "self"),
|
||
(9022, "investment-goal:read:self", "investment-goal", "read", "self"),
|
||
(9023, "product-recommendation:generate:self", "product-recommendation", "generate", "self"),
|
||
(9024, "product-recommendation:read:self", "product-recommendation", "read", "self"),
|
||
(9025, "portfolio-analysis:read:self", "portfolio-analysis", "read", "self"),
|
||
(9026, "asset-allocation:generate:self", "asset-allocation", "generate", "self"),
|
||
(9027, "profile-governance:read", "profile-governance", "read", "all"),
|
||
(9028, "profile-governance:review", "profile-governance", "review", "all"),
|
||
(9029, "investment-goal:review", "investment-goal", "review", "all"),
|
||
(9030, "investment-goal:publish", "investment-goal", "publish", "all"),
|
||
(9031, "product-recommendation:review", "product-recommendation", "review", "all"),
|
||
(9032, "product-recommendation:publish", "product-recommendation", "publish", "all"),
|
||
(9033, "asset-allocation:backtest", "asset-allocation", "backtest", "all"),
|
||
(9034, "product-comparison:read:self", "product-comparison", "read", "self"),
|
||
# ---- 9041-9046:投顾治理类 3 个 + 客服二期 3 个 ----
|
||
# 投顾线(`bbf623a`)已把 9020-9034 放进本种子,但漏了这 3 个治理类权限码,
|
||
# 而 `app/service/` 的治理接口直接要求它们 —— 缺了就是"实现了却一直 403"。
|
||
# 号段从 9041 起:库里曾有一批 9020-9035 是 `grant_advisor_role.py` 用旧号段建的,
|
||
# 与种子的 9020-9034 **id→code 映射不同**,挪到 9041 之后与两侧都不冲突。
|
||
(9041, "product-governance:read", "product-governance", "read", "all"),
|
||
(9042, "product-governance:review", "product-governance", "review", "all"),
|
||
(9043, "product-governance:sync", "product-governance", "sync", "all"),
|
||
# 客服二期(画像候选 + 转人工工单)的 3 个权限码。此前不在任何种子或迁移里,
|
||
# 属"环境数据":代码合过来后若库里缺这几行,候选确认/审核与工单查看 5 个接口一律 403。
|
||
(9044, "memory:candidate:confirm", "memory:candidate", "confirm", "self"),
|
||
(9045, "memory:candidate:review", "memory:candidate", "review", "all"),
|
||
(9046, "handover:read", "handover", "read", "all"),
|
||
|
||
# ---- 9047-9050:风控模块四个权限码 ----
|
||
# 风控 Service 和 Agent 工具都按这四个权限码失败关闭。此前依赖
|
||
# `grant_risk_permissions.py` 临时补种,重跑本脚本时会删除 9001-9099 号段内的
|
||
# 风控权限,导致登录成功后风控接口全部 403。这里纳入唯一权限定义源。
|
||
(9047, "risk:alert:read", "risk", "alert", "all"),
|
||
(9048, "risk:alert:write", "risk", "alert", "all"),
|
||
(9049, "risk:alert:scan", "risk", "alert", "all"),
|
||
(9050, "risk:report:mail", "risk", "report", "all"),
|
||
# ---- 9051-9056:代码早就要求、但**从未建过**的 6 个权限码 ----
|
||
# 症状同样是 `AGENT_PERMISSION_DENIED: 缺少操作权限`:看着像角色配错,其实是
|
||
# **权限码根本不存在**,于是任何角色都过不去。对账工具见
|
||
# `tools/check_permission_coverage.py`(把"代码声明的权限"与"库里各角色拥有的"比一遍)。
|
||
# 推广材料:`promotion_material_service.py` 用这四个;第 164 行专门判
|
||
# `"advisor" in context.roles`,说明投顾本来就在这条业务线上。
|
||
(9051, "promotion:read", "promotion", "read", "all"),
|
||
(9052, "promotion:write", "promotion", "write", "all"),
|
||
(9053, "promotion:review", "promotion", "review", "all"),
|
||
(9054, "promotion:deliver", "promotion", "deliver", "all"),
|
||
# NL2SQL 工具声明该权限(`bootstrap.py`);`financial_nl2sql_service.py:272` 的角色
|
||
# 白名单是 {advisor, operator, admin, super_admin} —— 投顾与运营都要用它。
|
||
(9055, "financial:nl2sql:read", "financial", "nl2sql", "all"),
|
||
# 平台验证探针工具(`platform_probe.py`),只给 admin。
|
||
(9056, "probe:read", "probe", "read", "all"),
|
||
# ---- 9057-9059:`investment-goal:{action}:customer` 三个变体 ----
|
||
# 这三个是**动态拼出来的**:`investment_goal_service.py:270-286` 的
|
||
# `_assert_customer_access` 按 `customer_id == 自己` 决定拼 `:self` 还是 `:customer`,
|
||
# 所以对账工具抓不到字面量,一直以为权限齐了 —— 实际投顾查/建/确认**客户**的目标
|
||
# 全部 403。action 取值来自调用点:`write`(L43) / `confirm`(L110) / `read`(L138,155)。
|
||
#
|
||
# `data_scope` 必须是 `own_customers`:那段代码在后面还会校一次
|
||
# `scope != "all" and (scope != "own_customers" or customer_id not in context.customer_ids)`
|
||
# ⇒ 只有 `own_customers` 且客户确在投顾名下才放行。这是**最小权限**的正确形态:
|
||
# 投顾只看自己服务的客户,而不是全量客户。
|
||
(9057, "investment-goal:read:customer", "investment-goal", "read", "own_customers"),
|
||
(9058, "investment-goal:write:customer", "investment-goal", "write", "own_customers"),
|
||
(9059, "investment-goal:confirm:customer", "investment-goal", "confirm", "own_customers"),
|
||
|
||
# ---- 9060-9065:账户看板 + 场内模拟交易(T 段 9 端点) ----
|
||
# ZSY_develop §T 段(docs/05 §19)用户自助端点的权限码。
|
||
# Controller 当前不主动 require,但为与"业务契约 = 权限码声明"一致仍登记进
|
||
# `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。
|
||
# 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。
|
||
(9060, "account:read:self", "account", "read", "self"),
|
||
# ⚠️ 第 5 个字段是 **data_scope**,只能取 `self` / `own_customers` / `all`。
|
||
# 这四条原先误写成 `trade, order, create` 这样的「resource, action, action」三段,
|
||
# 于是 action 落进了 data_scope 位置(值为 'create'/'read'/'cancel')。
|
||
# 后果不是"宽松"而是**静默失效**:`IdentityRepository.load_context` 只收集
|
||
# data_scope 合法的权限,这四条会被整条丢掉 ⇒ 客户在**库里明明有**这四个权限,
|
||
# 下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`,
|
||
# 而 T001/T006(`account:read:self`/`holding:read:self` 是 'self')照常 200,
|
||
# 所以现象特别像"只有交易坏了",很难联想到权限码本身写错。
|
||
(9061, "trade:order:create", "trade", "order", "self"),
|
||
(9062, "trade:order:read", "trade", "order", "self"),
|
||
(9063, "trade:order:cancel", "trade", "order", "self"),
|
||
(9064, "holding:read:self", "holding", "read", "self"),
|
||
(9065, "trade:txn:read", "trade", "txn", "self"),
|
||
|
||
# ---- 9066-9068:投顾**代客**三项(`*:customer` 变体) ----
|
||
# 与 9057-9059 是**同一个坑的第二批**:这三个码同样是服务层按
|
||
# `customer_id == context.user_id` **动态拼**出来的,对账工具抓不到字面量,
|
||
# 于是库里从来没有它们,投顾一在投顾工作台对**客户**做操作就整片 403:
|
||
# · `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`
|
||
# `data_scope` 必须是 `own_customers`:`AuthorizationService.require_customer_scope`
|
||
# 只放行 `all`,或 `own_customers` 且客户确在 `context.customer_ids`(= 归属客户)内。
|
||
# 这正是投顾该有的最小权限 —— 只对自己服务的客户出方案。
|
||
(9066, "product-recommendation:generate:customer", "product-recommendation",
|
||
"generate", "own_customers"),
|
||
(9067, "asset-allocation:generate:customer", "asset-allocation", "generate",
|
||
"own_customers"),
|
||
(9068, "portfolio-analysis:read:customer", "portfolio-analysis", "read",
|
||
"own_customers"),
|
||
# ---- 9069:客服转人工工单**处置**(分配/接单/解决/关闭/取消) ----
|
||
# 原先只有 9046 `handover:read`(只读队列),于是"工单只能看、不能推进":
|
||
# 库里 40 张单子全部停在 pending(`docs/02` §7.2 的状态机一个动作也没有入口)。
|
||
# 状态机与审计见 `app/service/customer_service_handover_action_service.py`。
|
||
# data_scope 取 `all`:工单队列本身就是全平台视图,不按归属客户切。
|
||
(9069, "handover:write", "handover", "write", "all"),
|
||
# ---- 9070:推荐方案**删除** ----
|
||
# 投顾工作台「历史方案记录」的四个按钮里,审核/发布复用 9031/9032,删除此前
|
||
# **没有权限码**(`ProductRecommendationService.delete` 是本轮新增)。
|
||
# 不给它单独的 `:self` 变体:删除按 content_id 寻址,归属由服务层的
|
||
# `_visible_customer_ids`(本人 + 名下客户)兜底,与 published/history 同一把尺子。
|
||
(9070, "product-recommendation:delete", "product-recommendation", "delete", "all"),
|
||
# ---- 9071-9074:客户**主动申报**投顾方案 + 投顾受理审批 ----
|
||
# 客户在自己主页提交申报(金额/期限/风险偏好),投顾在工作台受理后自动出草稿。
|
||
# 客户侧用 `:self`(只能看/提自己的);投顾侧 `read`/`review` 为 `all`,
|
||
# 但**真正的范围限制在服务层**:队列按 `sys_customer_assignment` 归属过滤
|
||
# (`AdvisorServiceRequestService.queue` 复用 `_visible_customer_ids`)。
|
||
(9071, "advisor-request:write:self", "advisor-request", "write", "self"),
|
||
(9072, "advisor-request:read:self", "advisor-request", "read", "self"),
|
||
(9073, "advisor-request:read", "advisor-request", "read", "all"),
|
||
(9074, "advisor-request:review", "advisor-request", "review", "all"),
|
||
)
|
||
|
||
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
|
||
CUSTOMER_PERMISSIONS = (
|
||
9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9018,
|
||
9020, 9021, 9022, 9023, 9024, 9025, 9026, 9034,
|
||
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 customer_id 过滤,不越权)。
|
||
9044,
|
||
# ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量)
|
||
9060, 9061, 9062, 9063, 9064, 9065,
|
||
# 客户主动申报投顾方案(提交 + 查看自己的申报)
|
||
9071, 9072,
|
||
)
|
||
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
|
||
RISK_PERMISSIONS = (
|
||
9001, 9002, 9003, 9010, 9011, 9012,
|
||
9047, 9048, 9049, 9050,
|
||
)
|
||
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。
|
||
ADMIN_PERMISSIONS = tuple(row[0] for row in PERMISSIONS)
|
||
|
||
ROLES: tuple[tuple[int, str, str], ...] = (
|
||
(9001, "customer", "客户"),
|
||
(9002, "risk_operator", "风控专员"),
|
||
(9003, "admin", "平台管理员"),
|
||
)
|
||
|
||
USERS: tuple[tuple[int, str, str, str], ...] = (
|
||
(9001, "T-CUST", "cust_t", "customer"),
|
||
(9002, "T-RISK", "risk_t", "employee"),
|
||
(9003, "T-ADMIN", "admin_t", "employee"),
|
||
# 9004 review_t:**不绑任何角色、不设密码**的账号,用于两个边界用例:
|
||
# - `test_rbac_read_mysql`:账号**存在但无权限** → 应返回 200 + 空权限集,而不是 404(错误码语义);
|
||
# - `test_auth_login_mysql` 的 `PLACEHOLDER_ACCOUNTS`:`password_hash` 是占位符 → 必须 401 而非 500。
|
||
# 2026-09-12 补:此前这两个测试都引用它,但**种子从未创建**它 —— 后者因"账号不存在也返回 401"
|
||
# 而恰好蒙过,前者则一直红(`/admin/users/9004/roles` 返回 404 "用户不存在")。
|
||
(9004, "T-REVIEW", "review_t", "employee"),
|
||
)
|
||
|
||
#: `sys_user.user_type` → `fund_account_status`。口径与 `tools/create_test_user.py`
|
||
#: 的 `FUND_ACCOUNT_STATUS` **必须一致**:客户只有"已开户"才能访问账户域接口,
|
||
#: 员工账号没有基金账户。此前这里(以及 INSERT 语句)硬编码写死 `'closed'`,
|
||
#: 于是种子建的客户永远"未开户"—— `GET /api/v1/users/me/account/dashboard` 与
|
||
#: `GET /api/v1/users/me/cash-ledger` 会返回 404「客户未开户」,前端对应页面直接打不开。
|
||
FUND_ACCOUNT_STATUS_BY_USER_TYPE: dict[str, str] = {
|
||
"customer": "已开户",
|
||
"employee": "closed",
|
||
}
|
||
|
||
#: 用户 → 角色绑定,**显式列出**而不是按位置配对。
|
||
#:
|
||
#: 原先写的是 `zip(user_ids, role_ids, strict=True)`,隐含假设「USERS 与 ROLES 一一对应」;
|
||
#: 9004 review_t 是**故意不绑角色**的账号,一加进来 zip 就会
|
||
#: `ValueError: zip() argument 2 is shorter than argument 1`,整个种子跑不完
|
||
#: (而 commit 在最后,外部表现是"什么都没发生")。显式列表让"谁绑什么"一眼可见,
|
||
#: 也不受两侧顺序/长度变化影响。
|
||
USER_ROLES: tuple[tuple[int, int], ...] = (
|
||
(9001, 9001), # cust_t → customer
|
||
(9002, 9002), # risk_t → risk_operator
|
||
(9003, 9003), # admin_t → admin
|
||
# 9004 review_t 不在此列:「账号存在但无权限」正是它要覆盖的场景。
|
||
)
|
||
|
||
GRANTS: tuple[tuple[int, tuple[int, ...]], ...] = (
|
||
(9001, CUSTOMER_PERMISSIONS),
|
||
(9002, RISK_PERMISSIONS),
|
||
(9003, ADMIN_PERMISSIONS),
|
||
)
|
||
|
||
|
||
async def seed() -> None:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
# MySQL 的 DATETIME(0) 会把微秒四舍五入到秒:若用"当前时间"写 assigned_at,
|
||
# 进位后可能落在未来,使紧随其后的 `assigned_at <= now` 授权校验判定
|
||
# "尚未生效",于是刚种好的账号一个权限都拿不到(表现为 roles=() 而非报错)。
|
||
# 往前留 5 秒,彻底避开这个舍入窗口。
|
||
effective_at = now - timedelta(seconds=5)
|
||
async with SessionFactory() as session:
|
||
await session.execute(
|
||
text("DELETE FROM sys_role_permission WHERE role_id IN (9001,9002,9003)")
|
||
)
|
||
await session.execute(text("DELETE FROM sys_user_role WHERE user_id IN (9001,9002,9003)"))
|
||
# 风控权限在旧环境中可能由 `grant_risk_permissions.py` 自动分配了任意主键。
|
||
# 先按 permission_code 清掉角色绑定和旧主键,保证本次能用固定号段重建;
|
||
# 否则 `risk:report:mail` 已存在于 9001-9099 之外时会触发 permission_code 唯一键冲突。
|
||
await session.execute(text("""
|
||
DELETE rp FROM sys_role_permission rp
|
||
JOIN sys_permission p ON p.id = rp.permission_id
|
||
WHERE p.permission_code IN (
|
||
'risk:alert:read', 'risk:alert:write',
|
||
'risk:alert:scan', 'risk:report:mail'
|
||
)
|
||
"""))
|
||
await session.execute(text("""
|
||
DELETE FROM sys_permission
|
||
WHERE permission_code IN (
|
||
'risk:alert:read', 'risk:alert:write',
|
||
'risk:alert:scan', 'risk:report:mail'
|
||
)
|
||
"""))
|
||
await session.execute(text("DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099"))
|
||
await session.execute(text("DELETE FROM sys_role WHERE id IN (9001,9002,9003)"))
|
||
# 不再 DELETE sys_user:投顾域的 advisor_profile_tag 等表用 FK 引用它,库里有数据引用
|
||
# 9001-9003 时删除会被外键拒绝(1451)导致整个种子跑不完,而且删了用户等于把演示密码
|
||
# 一起弄丢。改为下面循环里的「更新或插入」,且不覆盖 password_hash。
|
||
|
||
for user_id, user_no, user_name, user_type in USERS:
|
||
# 开户状态按角色区分,与 `tools/create_test_user.py` 的 FUND_ACCOUNT_STATUS 口径一致:
|
||
# **客户必须"已开户"**,否则账户域接口会一律 404「客户未开户」——
|
||
# 实测受影响的是 `GET /api/v1/users/me/account/dashboard`(前端 T001)与
|
||
# `GET /api/v1/users/me/cash-ledger`(T009),页面直接打不开。
|
||
# 员工账号没有基金账户,保持 closed。
|
||
fund_status = FUND_ACCOUNT_STATUS_BY_USER_TYPE.get(user_type, "closed")
|
||
updated = await session.execute(
|
||
text(
|
||
"UPDATE sys_user SET user_no=:no, username=:name, user_type=:type,"
|
||
" professional_investor_status='none', fund_account_status=:fund,"
|
||
" status='正常', updated_at=:now WHERE id=:id"
|
||
),
|
||
{"id": user_id, "no": user_no, "name": user_name, "type": user_type,
|
||
"fund": fund_status, "now": now},
|
||
)
|
||
if updated.rowcount == 0:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_user (id, user_no, username, password_hash, user_type,"
|
||
" professional_investor_status, fund_account_status, status,"
|
||
" created_at, updated_at)"
|
||
" VALUES (:id,:no,:name,'x',:type,'none',:fund,'正常',:now,:now)"
|
||
),
|
||
{"id": user_id, "no": user_no, "name": user_name,
|
||
"type": user_type, "fund": fund_status, "now": now},
|
||
)
|
||
for role_id, role_code, role_name in ROLES:
|
||
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": role_id, "code": role_code, "name": role_name, "now": now},
|
||
)
|
||
for permission_id, code, resource, action, scope in PERMISSIONS:
|
||
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": permission_id, "code": code, "resource": resource, "action": action,
|
||
"scope": scope, "now": now},
|
||
)
|
||
for user_id, role_id in USER_ROLES:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_user_role (user_id, role_id, assigned_at)"
|
||
" VALUES (:user,:role,:now)"
|
||
),
|
||
{"user": user_id, "role": role_id, "now": effective_at},
|
||
)
|
||
for role_id, permission_ids in GRANTS:
|
||
for permission_id in permission_ids:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
|
||
" VALUES (:role,:perm,:now)"
|
||
),
|
||
{"role": role_id, "perm": permission_id, "now": now},
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
async def verify() -> None:
|
||
service = IdentityService()
|
||
for label, user_id in (("customer", CUSTOMER_USER), ("risk_operator", RISK_USER),
|
||
("admin", ADMIN_USER)):
|
||
context = await service.resolve(RequestContext(user_id=user_id, trace_id="verify-trace"))
|
||
print(
|
||
f"{label:14s} roles={context.roles} permissions={sorted(context.permissions)} "
|
||
f"portal={context.portal} data_scope={context.data_scope}"
|
||
)
|
||
customer = await service.resolve(
|
||
RequestContext(user_id=CUSTOMER_USER, trace_id="verify-trace")
|
||
)
|
||
missing = {"agent:run", "suitability:read", "fund:quote:read"} - set(customer.permissions)
|
||
if missing:
|
||
raise SystemExit(f"客户角色缺少公共工具所需权限:{sorted(missing)}")
|
||
print("verified: customer can pass public tool authorization checks")
|
||
|
||
|
||
async def main() -> None:
|
||
await seed()
|
||
await verify()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|