diff --git a/docs/36-PR7合并记录与权限号段修正.md b/docs/36-PR7合并记录与权限号段修正.md index 6ce4905..ad38c4a 100644 --- a/docs/36-PR7合并记录与权限号段修正.md +++ b/docs/36-PR7合并记录与权限号段修正.md @@ -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,客服检索工具的必需权限已就位 | diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index 9cf0731..7ed92e6 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -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(