Files
group_fqcd_jr/tools/seed_test_rbac.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

401 lines
24 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.
"""造 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"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
CUSTOMER_PERMISSIONS = (
9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9018,
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 customer_id 过滤,不越权)。
9044,
# ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量)
9060, 9061, 9062, 9063, 9064, 9065,
)
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
RISK_PERMISSIONS = (
9001, 9002, 9003, 9010, 9011, 9012,
9047, 9048, 9049, 9050,
)
#: 投顾域权限码(2026-09-17 投顾模块整体清除,CS-PURGE-2026-012/013)。
#:
#: **定义仍保留在 `PERMISSIONS` 中**(不删 `sys_permission` 行):保新旧环境 schema 一致,
#: 且 `tools/check_permission_coverage.py` 仍需用它们对账。本集合的作用只是
#: **不再把它们授予任何角色** —— 清除前 admin 因 `ADMIN_PERMISSIONS` 取「全量元组」
#: 而持有这 19 个绑定,属投顾清除后的角色残留(2026-09-17 文档整理 D-3)。
#:
#: ⚠️ **不在此列的 5 个必须保留**:`profile-governance:read`(9027) / `:review`(9028),
#: 与 `product-governance:read/review/sync`(9041-9043)。原因:`app/service/profile_governance_service.py`
#: 与 `app/service/product_governance_monitor_service.py` **仍存活并在用它们**。
#: 注意 `AuthorizationService.require(context, code, admin=True)` 的语义是
#: 「**追加**要求 admin 角色」而不是「admin 旁路权限码」
#: (见 `app/service/authorization_service.py:31-34`:先查 `code in context.permissions`,
#: 再查角色),所以删掉这两个绑定会让保留中的端点直接 403。
ADVISOR_DOMAIN_PERMISSION_IDS: frozenset[int] = frozenset(
{
9020, 9021, 9022, 9023, 9024, 9025, 9026, # 投资目标/产品推荐/组合分析/资产配置(自助)
9029, 9030, 9031, 9032, 9033, 9034, # 同上(审核/发布/回测/产品对比)
9057, 9058, 9059, # investment-goal:{read,write,confirm}:customer
9066, 9067, 9068, # product-recommendation / asset-allocation / portfolio-analysis 的 :customer 变体
}
)
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计),**但不含投顾域残留**。
ADMIN_PERMISSIONS = tuple(
row[0] for row in PERMISSIONS if row[0] not in ADVISOR_DOMAIN_PERMISSION_IDS
)
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())