chore: 号段一致性自检固化为 tools 脚本并纳入门禁;AGENTS.md 更新主分支口径、Agent/工具清单与测试基线
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user