fix: 种子改用 UPSERT 建用户且不覆盖密码,解除投顾 FK 导致的跑不通;记录 RBAC 对齐最终状态

This commit is contained in:
2026-09-12 12:22:32 +08:00
parent 39f7b81200
commit 4cc0ecdea9
2 changed files with 61 additions and 45 deletions
+34 -35
View File
@@ -105,27 +105,12 @@ ZSY 的客服 Agent 接入线,含三块新能力与一次合规清理:
---
## 5. 本机 RBAC 对齐:实际执行记录
## 5. 本机 RBAC 对齐:最终执行记录
代码侧已就绪。**原计划是"种子 + 三个 grant 脚本"的全套对齐,但实测发现种子在这台环境上
根本跑不通**(见 §5.1),于是改用**不依赖种子**的方案,已执行完毕:
分两轮完成。**第一轮原计划是"种子 + 三个 grant 脚本",但种子当场跑不通**(见 §5.1),
于是先改用不依赖种子的方案把功能补齐;随后按项目方决定修好种子,**第二轮完整跑通**。
| 步骤 | 命令 | 结果 |
|---|---|---|
| 1 | 清理 `advisor`(9004) 的旧绑定 | ✅ 删 10 条(防 id 语义漂移,见 §3.2) |
| 2 | `tools/grant_advisor_role.py` | ✅ **按权限码重建 advisor 的 10 条绑定** |
| 3 | `tools/grant_customer_service_phase2_permissions.py` | ✅ 新增权限 `9044-9046`;授权 `customer` +1、`admin` +3 |
| 4 | `tools/grant_risk_permissions.py` | ✅ 风控 4 个权限幂等确认(本就存在) |
| 5 | `pytest tests/integration/test_auth_login_mysql.py tests/integration/test_rbac_read_mysql.py` | ✅ **19 passed** |
对齐后的实测状态:权限 **41 条**;绑定数 `admin 40 / customer 11 / risk_operator 10 / advisor 10 / operator 1`。
三个新权限的绑定实测为:`memory:candidate:confirm` → `customer` + `admin`,
`memory:candidate:review` → `admin`,`handover:read` → `admin`。
**没有跑** `tools/set_user_password.py`:种子失败后事务完整回滚,`cust_t`/`risk_t`/`admin_t`/`advisor_t`
的 bcrypt 密码都还在(已用 `--list` 核实),无需重设。
### 5.1 ⚠️ 新发现的既有破坏:`seed_test_rbac.py` 在这台环境上已经跑不通
### 5.1 第一轮撞到的破坏:`seed_test_rbac.py` 在这台环境上跑不通(已修)
```
sqlalchemy.exc.IntegrityError: (1451, 'Cannot delete or update a parent row: a foreign key
@@ -135,26 +120,40 @@ FOREIGN KEY (`customer_id`) REFERENCES `sys_user` (`id`))')
```
投顾线引入的 `advisor_profile_tag` 有 FK 指向 `sys_user`,而库里有数据引用 `9001-9003`,
于是种子最后那步 `DELETE FROM sys_user` 被外键拒绝。
于是种子最后那步 `DELETE FROM sys_user` 被外键拒绝。**因为 `commit()` 在最后,种子是原子的**
(失败即完整回滚,已实测:权限仍 38 条、三个用户密码完好),但任何人在这台环境、
或任何有投顾数据的环境上跑种子都会失败 —— 而且外部表现只是"什么都没发生"。
**好消息是种子是原子的**:`session.commit()` 在最后,失败即完整回滚 —— 本次已实测确认
(权限仍 38 条、三个用户密码完好)。**但不能依赖这一点**:哪天有人把 `commit()` 挪到中间,
就会出现"权限被删、用户还在"的半成品状态。
**修法(已落地)**:把 `DELETE FROM sys_user` 换成「存在则 UPDATE 非密码字段、不存在才 INSERT」,
且**不覆盖 `password_hash`**。附带好处:重跑种子不再弄丢演示密码,也不需要事后补
`set_user_password.py` —— 实测两轮之后 `9001`/`9002`/`9003`/`9020` 的 bcrypt 密码都还在。
**影响**:任何人在这台环境、或任何有投顾数据的环境上跑种子都会失败。
考虑过但**未采用**:① 种子完全不碰 `sys_user`(把用户创建交给 `create_test_user.py`,代价是
"新建环境多一步");② 给该 FK 加 `ON DELETE CASCADE`(改已有约束,牵涉规则 4,不做)。
**待定修法(需项目方定,本次未动)**,三种取向:
### 5.2 对齐后的最终实测状态
1. **把 `DELETE FROM sys_user` 改为 UPSERT,且不覆盖 `password_hash`** —— 破坏最小:
用户不被删、密码不丢,FK 也不会被触发。**我倾向这个**;
2. 种子只重建权限与角色、不碰 `sys_user`,用户创建交给 `create_test_user.py`;
3. 给该 FK 加 `ON DELETE CASCADE` —— 会改已有约束,牵涉规则 4,**不建议**。
| 项 | 值 |
|---|---|
| 权限总数 | **45 条** |
| 绑定数 | `admin 44 / customer 20 / risk_operator 10 / advisor 10 / operator 1` |
| 原先缺的 `9018 knowledge:query` | ✅ 已补(种子重建带入) |
| 原先缺的 `9019 knowledge:manage` | ✅ 已补 |
| `9041-9043` 投顾治理类 | ✅ 就位 |
| `9044-9046` 客服二期 | ✅ 就位;实测 `memory:candidate:confirm` → `customer` + `admin`,另两个 → `admin` |
| 演示密码 | ✅ 四个账号 bcrypt 密码完好,**未重设** |
| `pytest tests/integration/test_auth_login_mysql.py tests/integration/test_rbac_read_mysql.py` | ✅ **19 passed** |
| `pytest tests/unit tests/contract` | ✅ **1275 passed, 2 skipped** |
### 5.2 仍然存在的既有环境缺口
执行顺序(有依赖,不能颠倒):
本机库里 **`9018 knowledge:query` 与 `9019 knowledge:manage` 仍然不存在** —— 本机 RBAC
数据比种子旧。前者是 `search_knowledge` 工具的必需权限,缺它会让客服 Agent 调检索工具时被拒。
**因为种子跑不通,这一项本次没能补上**,已列入 §6。与 ZSY 的合并无关,是我方环境的账。
```
清 advisor(9004) 旧绑定 → seed_test_rbac.py → grant_advisor_role.py
→ grant_customer_service_phase2_permissions.py → grant_risk_permissions.py
```
第一步必须做,原因见 §3.2;后两个 `grant_*.py` 里风控那 4 个权限是**必要的幂等补齐** ——
种子不重建 `1986...` 号段的风控权限,却会清掉 `risk_operator`/`admin` 对它们的绑定(本次实测补了 8 条)。
---
@@ -167,5 +166,5 @@ FOREIGN KEY (`customer_id`) REFERENCES `sys_user` (`id`))')
| 只读真机冒烟(用真实令牌打 §19 新增端点,403 当失败) | 待 ZSY 回话 | 已提议 |
| 环境数据不可跨环境 | 全员 | 第 4 次踩坑,已写进种子文档字符串 |
| `docs/05` §19 端点编号 | 已解决 | `A039`/`A040`,全表 62 个编号唯一 |
| **`seed_test_rbac.py` 在有投顾数据的环境跑不通** | **项目方定修法** | 见 §5.1,三种取向待选;种子本身是原子的,已实测回滚无损 |
| 本机缺 `9018 knowledge:query` / `9019 knowledge:manage` | 我方环境 | 见 §5.2,依赖 §5.1 的修法;缺 `knowledge:query` 会让客服检索工具被拒 |
| `seed_test_rbac.py` 被投顾 FK 挡住 | ✅ 已修 | 改 `sys_user` 为 UPSERT 且不覆盖密码,见 §5.1;重跑种子不再弄丢演示密码 |
| 本机缺 `9018 knowledge:query` / `9019 knowledge:manage` | ✅ 已补 | 见 §5.2,客服检索工具的必需权限已就位 |
+27 -10
View File
@@ -3,13 +3,18 @@
数据使用 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 的角色绑定**。两个已知后果:
⚠️ 本脚本对**权限、角色、角色绑定**是 **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 映射前先确认。
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`、
@@ -138,18 +143,30 @@ async def seed() -> None:
await session.execute(text("DELETE FROM sys_user_role WHERE user_id IN (9001,9002,9003)"))
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)"))
await session.execute(text("DELETE FROM sys_user 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:
await session.execute(
updated = 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','closed','正常',:now,:now)"
"UPDATE sys_user SET user_no=:no, username=:name, user_type=:type,"
" professional_investor_status='none', fund_account_status='closed',"
" status='正常', updated_at=:now WHERE id=:id"
),
{"id": user_id, "no": user_no, "name": user_name, "type": user_type, "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','closed','正常',:now,:now)"
),
{"id": user_id, "no": user_no, "name": user_name,
"type": user_type, "now": now},
)
for role_id, role_code, role_name in ROLES:
await session.execute(
text(