Files
group_fqcd_jr/tools/create_test_user.py
lzf_0626 5018f11843 增加投顾(advisor)角色;修正登录测试的错误假设;修投顾带入的 2 处文档重号
## 投顾角色

投顾线合并后,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 走业务分支 ——
但 sys_role 里没有这个角色、sys_permission 里也没有投顾那 16 个权限码(种子只建到 9019)。
表现是所有投顾接口 403,而报错看起来像"权限配错了",实际是角色根本不存在。

- tools/grant_advisor_role.py:建 advisor 角色(id=9004,避开种子的 9001-9003 重建范围)
  + 16 个投顾权限(id 9020-9035)+ 授权(advisor 拿 10 项工作流、admin 补齐 16 项)。
  只增不删、可重复执行、带 --dry-run。
- tools/create_test_user.py:ROLE_IDS 加 advisor。
- 先跑 alembic upgrade head:补 21 张 advisor_* 表,业务表 68 → 89,审计通过。

权限划分:投顾工作流 10 项(read:self / generate:self / review / publish)给 advisor;
治理类 6 项(product-governance:*、profile-governance:*、asset-allocation:backtest)只给 admin。
review/publish 也给 advisor,与既有决策一致(此前已裁定不做双人复核)。

验证:advisor_t 登录 200,roles=['advisor'] data_scope=all 权限 10 项;
用它查 RBAC 清单得 403(没有 audit:read),边界正确。

⚠️ 与种子的冲突:seed_test_rbac.py 是 DELETE 重建语义,其
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 会清掉本脚本建的权限。
要把投顾权限固化,应并进 seed_test_rbac.py 的 PERMISSIONS 常量。

## 修正登录测试的一个错误假设

test_issued_token_actually_works_on_a_real_endpoint 原本用客户的
/users/me/memory-profile 验证令牌可用,投顾合并后它返回 403。追下去发现**与令牌无关**:
那个接口对客户有业务前置"请先完成开户风险测评问卷",而演示客户 9001 没有测评记录。
是我的测试选错了验证端点,把"业务前置未满足"误判成"令牌坏了"。

- 改用管理员令牌调 /api/v1/admin/roles(需要 audit:read,走完整鉴权链路),
  并补一条反向对照:不带令牌必须 401,否则那个 200 说明不了令牌有效。
- 把那个业务前置单独写成一个用例,让后来者一眼看到条件,而不是反复怀疑令牌。

过程里我先按控制台乱码猜了两次失败原因,都不对;最后把响应抓成 UTF-8 文件才看到真实
消息。教训记下:不要读乱码猜消息。

## 修投顾带入的 2 处文档重号

21-投顾Agent迁移TODO.md → 30-…、22-投顾Agent灰度与回滚操作手册.md → 31-…
(沿用 NL 那次让号的先例:既有文档更早、引用更多;且这两份新文档没有被任何地方引用。)
文档守卫:40 份无编号冲突。

验证:ruff 干净 / mypy 228 文件 0 错 / 文档守卫 40 份无冲突 /
unit+contract 1207 passed(0 failed)/ integration 99 passed / 业务表 89 张。
2026-09-11 21:49:46 +08:00

220 lines
8.7 KiB
Python
Raw Permalink 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.
"""添加一个可登录的测试账号(用户 + 角色 + 密码),并**验证它真的能拿到权限**。
## 为什么需要它
`sys_user` / `sys_user_role` / `sys_role` 这些表**没有 ORM 模型**(全项目用裸 SQL 访问,
见 `tools/seed_test_rbac.py`),手写 INSERT 要凑齐 10 个字段、还要自己算密码哈希。
更要紧的是这里有个**静默陷阱**:
MySQL 的 DATETIME(0) 会把微秒**四舍五入到秒**。若 `sys_user_role.assigned_at`
用"当前时间"写入,进位后可能落在未来,而授权校验是 `assigned_at <= now` ——
于是刚建好的账号**一个角色都拿不到**,表现为 `roles=()`,**不报错也不失败**。
`seed_test_rbac.py` 已经踩过一次(它的注释里写着)。本脚本统一把 `assigned_at`
往前留 5 秒,并在最后**用 `IdentityService.resolve` 打印真实解析结果**而不是
"插入成功" —— 后者根本不能说明这个账号能用。
## 用法
python tools/create_test_user.py --list
python tools/create_test_user.py --id 9010 --username test_cust \\
--role customer --password abc12345
重复执行同一个 `--id` 是**覆盖**语义:更新用户名 / 密码 / 角色,不会产生重复行。
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory
from app.service.auth_service import hash_password
from app.service.identity_service import IdentityService
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: 库里现成的角色。新用户复用它们。
#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)——
#: `advisor` 就是由 `tools/grant_advisor_role.py` 建立的。
ROLE_IDS: dict[str, int] = {
"customer": 9001,
"risk_operator": 9002,
"admin": 9003,
"advisor": 9004,
}
#: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。
ROLE_USER_TYPE: dict[str, str] = {
"customer": "customer",
"risk_operator": "employee",
"admin": "employee",
"advisor": "employee",
}
#: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。
FUND_ACCOUNT_STATUS: dict[str, str] = {
"customer": "已开户",
"employee": "closed",
}
#: `assigned_at` 往前留的秒数,见模块文档里的静默陷阱。
ASSIGN_BACKDATE_SECONDS = 5
async def list_users() -> None:
"""列出所有账号、它的角色与密码状态。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT u.id, u.username, u.user_type, u.status,
u.password_hash,
GROUP_CONCAT(r.role_code ORDER BY r.role_code) AS roles
FROM sys_user u
LEFT JOIN sys_user_role ur ON ur.user_id = u.id
LEFT JOIN sys_role r ON r.id = ur.role_id
GROUP BY u.id, u.username, u.user_type, u.status, u.password_hash
ORDER BY u.id
"""
)
)
).mappings().all()
print(f"{'id':<8}{'username':<18}{'user_type':<12}{'status':<8}{'roles':<28}密码")
for row in rows:
has_password = str(row["password_hash"] or "").startswith(("$2a$", "$2b$", "$2y$"))
print(
f"{row['id']:<8}{str(row['username']):<18}{str(row['user_type']):<12}"
f"{str(row['status']):<8}{str(row['roles'] or '(无角色)') or '(无角色)':<28}"
f"{'已设' if has_password else '占位符,无法登录'}"
)
async def upsert_user(
*, user_id: int, username: str, role: str, password: str
) -> int:
"""建/更新账号并绑定角色,最后验证权限能解析出来。"""
role_id = ROLE_IDS[role]
user_type = ROLE_USER_TYPE[role]
now = datetime.now(UTC).replace(tzinfo=None)
assigned_at = now - timedelta(seconds=ASSIGN_BACKDATE_SECONDS)
async with SessionFactory() as session, session.begin():
role_exists = await session.scalar(
text("SELECT id FROM sys_role WHERE id = :role_id"), {"role_id": role_id}
)
if role_exists is None:
print(f"[失败] 角色 {role}(id={role_id})不存在,先跑 tools/seed_test_rbac.py")
return 1
# 覆盖语义:同一个 id 重跑不会堆出第二行。
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, :user_no, :username, :password_hash, :user_type,
'none', :fund_status, '正常', :now, :now)
AS new
ON DUPLICATE KEY UPDATE
username = new.username,
password_hash = new.password_hash,
user_type = new.user_type,
fund_account_status = new.fund_account_status,
status = '正常',
updated_at = new.updated_at
"""
),
{
"id": user_id,
"user_no": f"T-{username.upper()[:20]}",
"username": username,
"password_hash": hash_password(password),
"user_type": user_type,
"fund_status": FUND_ACCOUNT_STATUS[user_type],
"now": now,
},
)
# 角色绑定先清后插。表上其实**有** `uk_sys_user_role (user_id, role_id)` 唯一键
# (`tools/generate_baseline_sql.py:128`),所以直接 INSERT 不会堆重复行 ——
# 先清的理由是**支持改角色**:不清的话换角色会在表里留下两条绑定,
# `IdentityService` 解析出的 roles 就变成两个(既是 customer 又是 admin)。
await session.execute(
text("DELETE FROM sys_user_role WHERE user_id = :user_id"), {"user_id": user_id}
)
await session.execute(
text(
"INSERT INTO sys_user_role (user_id, role_id, assigned_at)"
" VALUES (:user_id, :role_id, :assigned_at)"
),
{"user_id": user_id, "role_id": role_id, "assigned_at": assigned_at},
)
print(f"[OK] id={user_id} username={username} role={role} 已写入")
return await verify(user_id, username, role)
async def verify(user_id: int, username: str, role: str) -> int:
"""用真实链路解析身份 —— 这是唯一能证明"这个账号能用的"方式。"""
context = await IdentityService().resolve(
RequestContext(user_id=str(user_id), trace_id="create-test-user")
)
print(f" 解析结果:roles={context.roles} data_scope={context.data_scope}")
print(f" 权限 {len(context.permissions)} 项")
if role not in context.roles:
print(
"[失败] 角色没有解析出来。最可能的原因是 assigned_at 落在了未来"
"(DATETIME(0) 的毫秒舍入),请重跑本脚本。"
)
return 1
print(f"[OK] 可以用它登录:{{\"username\": \"{username}\", \"password\": \"<你刚设的>\"}}")
return 0
async def main() -> int:
parser = argparse.ArgumentParser(description="添加可登录的测试账号")
parser.add_argument("--list", action="store_true", help="列出所有账号与角色")
parser.add_argument("--id", type=int, help="用户 id(9001-9003 已被演示账号占用)")
parser.add_argument("--username", help="登录用户名")
parser.add_argument("--role", choices=sorted(ROLE_IDS), help="角色")
parser.add_argument("--password", help="登录密码(仅限演示环境)")
args = parser.parse_args()
if args.list:
await list_users()
return 0
missing = [
name
for name, value in (
("--id", args.id), ("--username", args.username),
("--role", args.role), ("--password", args.password),
)
if value is None
]
if missing:
print(f"[失败] 缺少参数:{' '.join(missing)}(或直接用 --list 看现有账号)")
return 1
return await upsert_user(
user_id=args.id,
username=args.username,
role=args.role,
password=args.password,
)
if __name__ == "__main__":
sys.exit(asyncio.run(main()))