一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
191 lines
7.8 KiB
Python
191 lines
7.8 KiB
Python
"""校验 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;
|
||
5. 每行 `PERMISSIONS` 的字段数与 `data_scope` 取值合法(见 `_scope_findings`)。
|
||
|
||
`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]
|
||
if str(PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
||
#: 参与校验的 grant 脚本(都只做「按 code 判重」的幂等补齐)。
|
||
#: 原先还有 `tools/grant_customer_service_phase2_permissions.py`(客服二期);客服 Agent 模块
|
||
#: 已移除、该脚本一并删除,故不在此列——**重建客服模块时须把它加回来**,否则客服侧的
|
||
#: 权限码会脱离「grant 脚本 ↔ RBAC 种子」的一致性校验。
|
||
GRANT_SCRIPTS: tuple[tuple[str, str, str], ...] = (
|
||
("tools/grant_risk_permissions.py", "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}")
|
||
|
||
for permission_id in seed.RISK_PERMISSIONS:
|
||
if int(permission_id) not in seed_map:
|
||
problems.append(f"RISK_PERMISSIONS 引用了不存在的 id {permission_id}")
|
||
|
||
required_risk_codes = {
|
||
"risk:alert:read",
|
||
"risk:alert:write",
|
||
"risk:alert:scan",
|
||
"risk:report:mail",
|
||
}
|
||
risk_codes = {
|
||
seed_map[int(permission_id)]
|
||
for permission_id in seed.RISK_PERMISSIONS
|
||
if int(permission_id) in seed_map
|
||
}
|
||
missing_risk_codes = required_risk_codes - risk_codes
|
||
if missing_risk_codes:
|
||
problems.append(
|
||
"RISK_PERMISSIONS 缺少风控权限码:"
|
||
+ ", ".join(sorted(missing_risk_codes))
|
||
)
|
||
|
||
problems.extend(_scope_findings(seed))
|
||
|
||
return problems
|
||
|
||
|
||
#: `IdentityRepository.load_context` 只收集这三种 `data_scope` 的权限,其余**整条丢弃**。
|
||
VALID_DATA_SCOPES = frozenset({"self", "own_customers", "all"})
|
||
|
||
|
||
def _scope_findings(seed: ModuleType) -> list[str]:
|
||
"""第 5 条:每行 `PERMISSIONS` 的字段数与 `data_scope` 取值都必须合法。
|
||
|
||
## 为什么值得单独守
|
||
|
||
`PERMISSIONS` 的行形状是 `(id, 权限码, resource, action, data_scope)`。
|
||
2026-09-13 出过一次事故:交易那 4 条写成了 `(9061, "trade:order:create",
|
||
"trade", "order", "create")` —— 「resource, action」多写了一段,
|
||
于是 action 落进了 `data_scope`。
|
||
|
||
它**不报错、也不放宽**,而是**静默失效**:`load_context` 里
|
||
`if row["permission_code"] and row["data_scope"] in rank` 会把整条权限丢掉,
|
||
客户在库里"明明有"这四个权限,下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`;
|
||
而同一批里 `account:read:self`、`holding:read:self` 的 scope 是 `self`,
|
||
照常 200 —— 所以现象特别像"只有交易坏了",几乎不会有人去怀疑权限行本身写错。
|
||
|
||
因为 `seed_test_rbac.py` 是 DELETE 重建语义,这个错误**每跑一次种子就重现一次**。
|
||
"""
|
||
findings: list[str] = []
|
||
for index, row in enumerate(seed.PERMISSIONS, 1):
|
||
if len(row) != 5:
|
||
findings.append(
|
||
f"种子 PERMISSIONS 第 {index} 行有 {len(row)} 个字段,应为 5 个"
|
||
f"(id, 权限码, resource, action, data_scope):{row!r}"
|
||
)
|
||
continue
|
||
permission_id, code, scope = int(row[0]), str(row[1]), str(row[4])
|
||
if scope not in VALID_DATA_SCOPES:
|
||
findings.append(
|
||
f"种子 id {permission_id}({code})的 data_scope={scope!r} 非法:"
|
||
f"只能是 {sorted(VALID_DATA_SCOPES)} 之一 —— "
|
||
"否则该权限会被 IdentityRepository.load_context 静默丢弃,接口一律 403"
|
||
)
|
||
return findings
|
||
|
||
|
||
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())
|