From 4d8edb4b7f229bbb830b23e29c48ef155a0e5595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sat, 12 Sep 2026 12:29:20 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=8F=B7=E6=AE=B5=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E8=87=AA=E6=A3=80=E5=9B=BA=E5=8C=96=E4=B8=BA=20tools?= =?UTF-8?q?=20=E8=84=9A=E6=9C=AC=E5=B9=B6=E7=BA=B3=E5=85=A5=E9=97=A8?= =?UTF-8?q?=E7=A6=81=EF=BC=9BAGENTS.md=20=E6=9B=B4=E6=96=B0=E4=B8=BB?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=8F=A3=E5=BE=84=E3=80=81Agent/=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B8=85=E5=8D=95=E4=B8=8E=E6=B5=8B=E8=AF=95=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 26 +++- .../unit/tools/test_rbac_seed_consistency.py | 29 +++++ tools/check_rbac_seed_consistency.py | 121 ++++++++++++++++++ 3 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 tests/unit/tools/test_rbac_seed_consistency.py create mode 100644 tools/check_rbac_seed_consistency.py diff --git a/AGENTS.md b/AGENTS.md index 5f747b6..d0dd491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ > **⭐ 第 0 步先读这个**:`docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md` > —— 客服 Agent + RAG 这条线的交接文档(**含合并完成后的第二次更新**):环境口径、交付内容与 > **可复现验证证据**、合并后修掉的 3 个真机故障、**已知问题清单(逐条标注当前状态)**、Git/PR 状态。 -> 当前工作分支是 **`NL_develop`**(个人分支 → PR 合回 `qyqy_develop`),**不要再用 `6516ccb`**。 +> **主集成分支是 `qyqy_develop`**(ZSY 的客服接入线已由 PR #7 合入,见 `docs/36`); +> 客服/RAG 那条线的个人分支是 **`NL_develop`**(个人分支 → PR 合回 `qyqy_develop`),**不要再用 `6516ccb`**。 > 它是对"当前状态"最准确的一份,读完它再读下面这些。 > > **⚠️ 文档现状(2026-09-11 第二次修订)**:本文件原先声明"已删除 5 份编号文档", @@ -78,9 +79,20 @@ 场外/推广那 17 张逐表登记见 `docs/28-场外与推广域数据表登记.md`; **投顾那 21 张的登记文档待补**(按同样口径另立一份)。 核验命令:`python tools/audit_schema.py`(若报 `unexpected` 先分清是"库里多表"还是"迁移没进来")。 -- 已注册业务 Agent:`FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`(见 `app/service/agent/bootstrap.py`)。 +- 已注册业务 Agent(**7 个**,见 `app/service/agent/implementations/` 与 `app/service/agent/`): + `FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`、 + **`AdvisorAgent`**、**`OffsiteFundAgent`**、**`PromotionMaterialAgent`**。 - 已注册公共只读工具:`search_knowledge`(客服知识检索)、`check_suitability`、`query_customer_profile`(画像)、`query_fund_quote`; + **`query_knowledge` 是 `search_knowledge` 的别名**(同一 handler,为兼容一期发布配置与旧客户端保留,见 `bootstrap.py`); + 其余业务线工具(风控、投顾、NL2SQL)按各自 Agent 白名单注册,全部在 `bootstrap.py` 的 `get_agent_factory()` 里。 **工具可用范围 = 代码上限 ∩ 当前 active `config_release` 的发布白名单**,缺发布配置则失败关闭。 +- ⚠️ **RBAC 权限码的定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`**(9001-9046 号段): + 那个脚本是 **DELETE 重建**语义(`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099`), + **没并进它的权限码重建一次就没了**,表现是"接口突然 403"而没有任何报错线索。 + `tools/grant_*.py` 只补种子里缺的,且 id 必须与种子**逐条一致** —— + 一致性由 `python tools/check_rbac_seed_consistency.py` 及其单测守着 + (2026-09-12 曾因两套 id→code 映射并存,让 `advisor` 在种子重建后静默拿到语义错误的权限)。 + 另注:`sys_user` 已改为「存在则更新、不存在才插入」,故重跑种子**不会**再弄丢演示密码。 - ⚠️ **`config_release` 是环境数据,不随代码合并**:本机 active 版本 id 与架构师环境**不同** (本机是我方发布的客服白名单;他那边还有风控的 9 条白名单)。**"白名单已发布"必须带环境限定**,换环境要重发。 发布脚本 `tools/publish_customer_service_config.py`(**同 key 的继承项必须被本次定义覆盖**,否则旧值会被子集校验 422 拦下整次发布)。 @@ -93,10 +105,12 @@ 健康检查与部分检索链路会指向本地 **Milvus Lite 文件**。团队/生产环境请**保持该变量为空**。 对应的 `milvus-lite` 属**本地开发依赖**,应放在 `pyproject.toml` 的 `optional-dependencies`,**不要进主 `dependencies`**。 -- 测试基线(2026-09-11 架构师环境实测):`ruff` 干净 / `mypy app` **228 个文件 0 错** / - `pytest tests/unit tests/contract` → **1207 passed, 2 skipped, 0 failed** / - `pytest tests/integration` → **99 passed**。完整口径与联调清单见 - `docs/32-平台侧交接与联调准备.md`。 +- 测试基线(**2026-09-12 合并 PR #7 之后实测**):`ruff` 干净 / `mypy app` **244 个文件 0 错** / + `pytest tests/unit tests/contract` → **1276 passed, 2 skipped, 0 failed** + (**用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**;出现数量级差异再按下面那条对版本) / + `pytest tests/integration` → 99 passed(**合并前口径,合并后未整套复跑**,已复跑的是 + `test_auth_login_mysql.py` + `test_rbac_read_mysql.py` → 19 passed)。完整口径与联调清单见 + `docs/32-平台侧交接与联调准备.md`;本次合并的逐项证据见 `docs/36-PR7合并记录与权限号段修正.md`。 - ⚠️ **mypy 与测试数必须带环境读**:出现"一边上百个错、另一边 0 错"时先对版本,别当代码质量问题。 已知根因是某一侧的虚拟环境没满足 `pyproject.toml` 的 `sqlalchemy>=2.0,<3` / `mypy>=1.14,<2`。 **不要装 `sqlalchemy2-stubs`** —— 那是给 SQLAlchemy 1.4 的,2.0 自带 `py.typed`, diff --git a/tests/unit/tools/test_rbac_seed_consistency.py b/tests/unit/tools/test_rbac_seed_consistency.py new file mode 100644 index 0000000..7fdc366 --- /dev/null +++ b/tests/unit/tools/test_rbac_seed_consistency.py @@ -0,0 +1,29 @@ +"""RBAC 权限号段一致性(只读,不连库)。 + +规则与动机见 `tools/check_rbac_seed_consistency.py` 的模块说明:权限码的定义源是 +`tools/seed_test_rbac.py` 的 `PERMISSIONS`,各 `grant_*.py` 只能补种子里缺的, +且 id 必须与种子逐条一致 —— 2026-09-12 曾因两套 id→code 映射并存, +让 `advisor` 角色在种子重建后静默拿到语义错误的权限。 +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def _load_tool_module() -> ModuleType: + path = PROJECT_ROOT / "tools" / "check_rbac_seed_consistency.py" + spec = importlib.util.spec_from_file_location("check_rbac_seed_consistency", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_permission_ids_are_consistent_between_seed_and_grant_scripts() -> None: + problems = _load_tool_module().collect_findings() + assert problems == [], "权限号段不一致:\n" + "\n".join(problems) diff --git a/tools/check_rbac_seed_consistency.py b/tools/check_rbac_seed_consistency.py new file mode 100644 index 0000000..2094b00 --- /dev/null +++ b/tools/check_rbac_seed_consistency.py @@ -0,0 +1,121 @@ +"""校验 RBAC 权限号段的一致性(只读,不连数据库)。 + +## 为什么需要它 + +权限码的**定义源**是 `tools/seed_test_rbac.py` 的 `PERMISSIONS` 常量 —— 因为那个脚本是 +**DELETE 重建**语义: + +``` +DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 +``` + +任何没并进 `PERMISSIONS` 的权限码,重建一次就没了,表现是"接口突然 403", +而且**没有任何报错线索**。 + +2026-09-12 出过一次真实事故:库里有一批 `9020-9035` 是 `grant_advisor_role.py` 用**旧号段** +建的,而投顾线把 `9020-9034` 写进了种子 —— 两套 **id→code 映射不同**。种子的清理只清 +`role_id 9001-9003` 的角色绑定,`advisor`(9004) 的绑定**不在清理范围内**,于是重建后 +advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self` +变成 `investment-goal:write:self`),全程不报错。 + +本脚本把这条约束变成可自动检查的四条: + +1. 种子内 id 不重复; +2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条; +3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在; +4. 各 `grant_*.py` 之间不抢同一个 id。 + +`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这四条是纳入门禁的。 + +用法:python tools/check_rbac_seed_consistency.py +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +#: 参与校验的 grant 脚本(都只做「按 code 判重」的幂等补齐)。 +GRANT_SCRIPTS: tuple[tuple[str, str, str], ...] = ( + ("tools/grant_advisor_role.py", "ADVISOR_PERMISSIONS", "投顾"), + ("tools/grant_customer_service_phase2_permissions.py", "PHASE2_PERMISSIONS", "客服二期"), +) + + +def _load(name: str, relative: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, PROJECT_ROOT / relative) + if spec is None or spec.loader is None: + raise RuntimeError(f"无法加载 {relative}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def collect_findings() -> list[str]: + """返回问题列表;空列表表示一致。""" + problems: list[str] = [] + + seed = _load("_rbac_seed", "tools/seed_test_rbac.py") + seed_map: dict[int, str] = {} + for row in seed.PERMISSIONS: + permission_id, code = int(row[0]), str(row[1]) + if permission_id in seed_map: + problems.append( + f"种子里 id {permission_id} 重复:{seed_map[permission_id]} 与 {code}" + ) + seed_map[permission_id] = code + + claimed: dict[int, str] = {} + for relative, attribute, label in GRANT_SCRIPTS: + module = _load(f"_rbac_{attribute.lower()}", relative) + for row in getattr(module, attribute): + permission_id, code = int(row[0]), str(row[1]) + seeded = seed_map.get(permission_id) + if seeded is None: + problems.append(f"{label}({relative}):id {permission_id}({code})不在种子里") + elif seeded != code: + problems.append( + f"{label}({relative}):id {permission_id} 与种子冲突 —— " + f"种子={seeded},脚本={code}" + ) + if permission_id in claimed and claimed[permission_id] != code: + problems.append( + f"id {permission_id} 被两个脚本抢用:{claimed[permission_id]} 与 {code}" + ) + claimed[permission_id] = code + + for permission_id in seed.CUSTOMER_PERMISSIONS: + if int(permission_id) not in seed_map: + problems.append(f"CUSTOMER_PERMISSIONS 引用了不存在的 id {permission_id}") + + return problems + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + + seed = _load("_rbac_seed_report", "tools/seed_test_rbac.py") + print(f"种子权限 {len(seed.PERMISSIONS)} 条;grant 脚本 {len(GRANT_SCRIPTS)} 个") + + problems = collect_findings() + if problems: + print("\n[失败] 号段不一致:") + for item in problems: + print(f" - {item}") + print( + "\n处置:权限码的定义以 tools/seed_test_rbac.py 的 PERMISSIONS 为准;" + "grant 脚本只补种子里缺的,且 id 必须与种子一致。" + ) + return 1 + + print("一致性检查通过:种子内 id 唯一,各 grant 脚本与种子逐条一致。") + return 0 + + +if __name__ == "__main__": + sys.exit(main())