回复 ZSY 的底座扩展确认 v2:逐行核验两句确认;指出 docs/05 端点编号撞车、三个新权限未随代码合并;补权限种子脚本

This commit is contained in:
2026-09-12 11:46:47 +08:00
parent 244f03917b
commit ade5e0c085
2 changed files with 459 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
# 回复 ZSY:底座扩展确认(v2)
> **致**:ZSY
> **被回复**:`致qyqy_底座扩展确认沟通文案_v2.md`
> **我方基线**:`qyqy_develop`(本地,领先 `origin/qyqy_develop` 6 个提交)
> **被评审的分支**:`origin/ZSY_develop` = `9aaacc2`(PR **#7**)
> **结论**:**你要补的两句确认,我独立核验后全部成立 —— 可以照批**;
> 但合并前有 **3 项必须处理**(§2),其中 2 项是**功能会直接不可用**,不是风格问题。
---
## 0. 先说核验方法与结论
我把 `origin/ZSY_develop`(`9aaacc2`)取到本地逐行读了,不是照抄你的描述:
| 你的主张 | 我实测到的 | 结论 |
|---|---|---|
| `security.py:87-91` 是那段 `sub` 校验 | 第 87 行 `subject = claims["sub"]`,88–91 行正是 `isascii`/`isdecimal`/`len ≤ 20`/`≤ 2⁶⁴−1` | **一致** |
| 访客 `sub` 是纯数字,`[1, 9×10¹⁸]` | 第 43 行 `str(uuid4().int % 9_000_000_000_000_000_000 + 1)`,19 位上限 | **一致** |
| 访客分支复用同一套 `decode` 与校验 | 访客判断在第 92 行,**在校验之后**;`auth.py` 只跳过 `IdentityService.resolve()`(第 52 行),没另写入口 | **一致** |
| `contracts.py:88` 默认 `True` | 第 88 行 `recalls_customer_memory: bool = True` | **一致** |
| `base.py:138` 短路 | 第 138 行 `if not self.definition.recalls_customer_memory or "visitor" in context.roles:` | **一致** |
| `customer_service.py:197` 显式 `False` | 第 197 行 `recalls_customer_memory=False` | **一致** |
**`docs/33` §1.2 提的两个确认,答复如下:**
1. **`sub` 是纯数字 —— 满足,这一条关掉了。**
2. **`visitor` 分支复用了同一个 `jwt.decode` 与同一段 `sub` 校验 —— 满足,这一条也关掉了。**
顺带把 72 处 `int(context.user_id)` 的结论说明白:**入口校验保证所有通过鉴权的 `sub` 都是纯十进制数**,
所以那 69 处未防御的 `int()` 不会因访客而抛 `ValueError`,**不需要**改成统一转换函数,
那轮跨模块收敛**不必做**。
**你主动提的两个「要不要改」,我的答复都是「不用改」**:
- **访客 `sub` 不必换号段**。数值撞上真实用户 ID 时,`auth.py:52` 已按 `visitor` 角色跳过
`IdentityService`,拿不到任何 RBAC;`data_scope="public"` 的 26 个消费点我已逐点核过,
对未知值一致 fail closed。而 `visitor: true` 需要私钥签名,普通用户令牌伪造不出来 —— 碰撞无实际后果。
- **「访客不召回」保留在 `base.py` 的角色判断里,不要下放给声明位**。角色判断是**底座级安全兜底**:
声明位漏写一个 Agent 就会静默召回访客记忆,而角色判断不会。加上 `recall_memory` 里那道
`memory.customer_id != context.user_id` 的越界检查,是三层,比一层好。
---
## 1. 照批的部分
| 项 | 依据 |
|---|---|
| §5 删掉的两个模块 | 我按你在 PR 描述里的口径实测:`git grep -n "knowledge_tool_service"`、`git grep -n "milvus_knowledge_adapter"`(限 `app/ tests/ tools/`)**输出均为空**。**照批** |
| `9aaacc2` 的提交拆分 | 实测 9 文件 +189/−483,与 `docs/33` §6 记录一致。**做法请保持** |
| C3 画像 ORM 清理 | 与我方独立核实结论一致(`EXTRA=''`、`GENERATION_EXPRESSION=''`)。`docs/00` 那处偏差已按 `docs/33` §3 登记进 `docs/08` 的「已知文档偏差」,**你不用再管** |
| `docs/05` §8.5 | **对**:没挤掉 §8.1–§8.4,`AGENTS.md`/`docs/09`/`docs/14` 的既有引用继续成立 |
| `docs/05` §9.7 | **不撞号**:我方 §9 只到 §9.6 |
| 元数据不可外部注入 | 比你自己注释的更强:`build_outbox_metadata()` 用 `model_copy(update=...)` **服务端覆盖**客户端提交的 `chitchat_streak`/`clarification_round`/`session_context`,不是「不接收」而是「不采信」。加分项 |
---
## 2. 合并前必须处理的三项
### 2.1 ⚠️ `docs/05` §19 端点编号撞车:`A034`、`A035` 各被占用两次
`e85989b` 你合进了含我方登录 + RBAC 只读接口的 `qyqy_develop`,那批在 §19 已占:
```
A034 | POST /api/v1/auth/tokens
A035 | GET /api/v1/admin/roles
A036 | GET /api/v1/admin/roles/{role_code}
A037 | GET /api/v1/admin/roles/{role_code}/permissions
A038 | GET /api/v1/admin/users/{user_id}/roles
```
你在 `9aaacc2` 里又插了两行同名编号:
```
A034 | GET /api/v1/admin/customer-profile-candidates ← 与上面重复
A035 | POST /api/v1/admin/customer-profile-candidates/{id}/reviews ← 与上面重复
```
**实测**:`9aaacc2:docs/05` 里 `A034` 出现 2 次、`A035` 出现 2 次。
**请改成 `A039`/`A040`**(`M003`/`M004` 不冲突,保留)。这与你 §8.5 的处置逻辑是同一件事 ——
编号被复用比编号不够更麻烦,我们刚因两家同时占 `docs/21`、`docs/22` 让过两次号。
> ⚠️ 顺带告知一个**守卫盲区**:`tools/check_authoritative_docs.py` 只校验 **`docs/` 文件名编号唯一性**
> 与权威性声明,**不校验 §19 的端点编号**。所以合并后文档守卫会**通过**,这处重复会静默遗留 ——
> 这也是为什么我在评审里手工核了一遍。
### 2.2 ⚠️ 三个新权限不在任何环境脚本里:接口会全 403
你新增的接口要三个新权限,但我实测:
- **我方库**(`127.0.0.1:3306/jr`)`sys_permission` 共 38 条,
`memory:candidate:confirm`、`memory:candidate:review`、`handover:read` **都不存在**
(已有的只有 `handover:create`=9008、`memory:read:self`=9009、`memory:read:customer`=9010);
- 你的分支里这三个字符串**只出现在业务代码、`docs/05`、和测试里**,
`tools/` 与 `alembic/` 下**没有任何种子或迁移**建立它们。
**后果**(合并后立即生效):
| 接口 | 需要的权限 | 现状 |
|---|---|---|
| `GET /api/v1/users/me/memory-candidates` | `memory:read:self` | ✅ 已有,能通 |
| `POST /api/v1/users/me/memory-candidates/{id}/decisions` | `memory:candidate:confirm` | ❌ **403** |
| `GET/POST /api/v1/admin/customer-profile-candidates*` | `memory:candidate:review` | ❌ **403** |
| `GET /api/v1/admin/customer-service/handover-tickets*` | `handover:read` | ❌ **403** |
fail closed 是对的(不会误放行),但**功能等于没上**。你的集成测试能过,说明**你的环境里已经手工插过** ——
这正是 `AGENTS.md` §E 那条「`config_release` / `sys_role` / `sys_permission` / `sys_user` 是环境数据、
不随代码合并」的**第 4 次踩坑**(前面已有三条线各踩一次)。
**分工建议**:这一份由**我方**出 `tools/grant_customer_service_phase2_permissions.py`
(id 从 **9036** 起,避开我方 9001–9010 与投顾 9020–9035),**两边各跑一次**。
你若已在自己环境手工插过,请把实际 id 报给我,我按你的号段对齐,避免两边不一致。
### 2.3 `milvus-lite` 放进了主 `dependencies`,与确认口径相反
`docs/33` §4 的口径是:
> 依赖新增 `milvus-lite` → **请放进 `pyproject.toml` 的 `optional-dependencies`**,
> **不要进主 `dependencies`**。
实测 `9aaacc2` 的实际改动是:
```
pyproject.toml → dependencies 段新增 "milvus-lite>=3.2,<4"
requirements.txt → 新增 "milvus-lite>=3.2,<4"
```
而且**你自己的接入说明**(`docs/客服Agent接入底座扩展说明_v1.md` §四 B 类表格)写的也是
「(**可选**本地开发依赖)」—— 所以这是**放错了段**,不是理念分歧:挪到
`[project.optional-dependencies]` 即可(`requirements.txt` 同理只留在本地开发那份里)。
理由不变:它只是本地开发用,进主依赖会让生产环境多背一个包,而 `MILVUS_LOCAL_URI` 那个坑正是它引入的。
---
## 3. 品牌名:新增一个事实,请连同这条一起上报
`docs/33` §2.4 我的口径是「需项目方拍板」。**现在给你一个补充事实,它对你有利**:
`app/core/customer_service_rules.py` 第 116、129 行**早就是「奶龙基金」**:
```
116: "奶龙基金不会通过电话、短信或聊天索要您的验证码、密码,也不会要求您把钱转到指定账户。"
129: "这件事需要人工为您办理。奶龙基金智能助手不能代办交易、修改资料、销户或受理投诉赔偿。"
```
也就是说,改之前我方底座的现状是**自相矛盾**的:转人工/防诈骗话术说「奶龙基金」,
而 `COMPANY` 是「南方科技」。**你这一改实际是消除了一处内部不一致**,不只是换品牌。
所以请在上报时把这层理由写进去(比单说「改个常量」更容易通过)。
两个附带提醒:
- **建议把品牌名单独一个提交**,便于项目方拍板后单独保留或回滚,不要混在功能提交里;
- 我方 `tools/chat_console.py` 的标题栏现在也还是「南方科技」(第 48、81 行)。
若最终定「奶龙」,**我方自己也要改**,这条算在我账上,不占你时间。
---
## 4. 合并预演结果:干净,可以合
我按 `docs/32-平台侧交接与联调准备.md` §5 的口径做了预演:
| 检查 | 结果 |
|---|---|
| `git merge-tree --write-tree qyqy_develop 9aaacc2` | **无冲突**(返回单一 tree,无 conflict 段) |
| 与你这 89 个文件的交集 | **我方领先的 6 个提交与 89 个文件零重叠**(`Compare-Object` 结果为空) |
| PR #7 元数据(Gitea API 实测) | `state=open`、`mergeable=True`、`base=qyqy_develop`、`head=ZSY_develop`、**89 files / +7635 / −73** |
| 迁移脚本 | **无新增**;`app/model` 的改动经我逐行核对只是别名与映射整理(`FinKnowledgeMeta` 别名、`ProfileSnapshot` 转出、`current_customer_id` 普通列、去重复声明),**未改任何表结构** → 不需要新迁移 |
| PR 标题 | 「客服 Agent 接入底座:访客身份、画像候选、转人工工单(含底座合规清理)」—— **范围写得准**,评审时以它为准 |
**合并顺序建议**:我方本地 `qyqy_develop` 领先远端 6 个提交、且与你的改动零重叠,
所以 **先推我这 6 个、再合 PR #7**(两个方向都无冲突,但这样 PR 的 diff 更干净)。
**合并后我方会跑**(`docs/32` §5):`alembic upgrade head` → `python tools/audit_schema.py`
→ `python tools/check_authoritative_docs.py` → `ruff` / `mypy` / `pytest` 四项门禁 → 真机联调;
外加 §2.2 的权限种子脚本。
---
## 5. 结论
- **你要补的两句确认:核验通过**,§1.2 与 §2.2 两条全部关掉,**不用再改代码**;
- **你主动提的两个「要不要改」:都不用改**(访客号段、角色兜底);
- **合并前请改两处**:§2.1 的 `A034`/`A035` → `A039`/`A040`;§2.3 的 `milvus-lite` 挪到
`optional-dependencies`;
- **由我方补一件**:§2.2 的权限种子脚本(等你报 id 号段后我对齐);
- **品牌名**:按你原计划上报项目方,请带上 §3 的补充事实,并单独一个提交。
这四项处理完,PR #7 我这边没有别的反对意见,可以合。
辛苦了。
---
## 6. 附:可直接复制发送的微信版
> 给不读长文档的场景用;内容与上文一致,逐条对应。
```
ZSY 你好,你的 v2 我逐行核过了(把 origin/ZSY_develop 取到本地读的,不是照抄描述)。
你补的两句确认:都成立,可以关掉。
1)sub 是纯数字 —— security.py:87-91 那段校验在访客分支之前,你第 43 行的生成方式
落在 [1, 9e18],19 位,全部满足;
2)默认值 True —— contracts.py:88 实测就是 True,base.py:138 的短路也读到了。
另外你主动提的两个"要不要改",我的答复都是不用改:访客 sub 不用换号段(撞上真实 ID 也
拿不到权限,auth.py:52 已跳过身份解析);"访客不召回"也别下放到声明位,放 base.py 的
角色判断是底座级兜底,比声明位可靠。
顺带一个好消息:入口校验保证了所有 sub 都是纯数字,所以我上次担心的 69 处没防御的
int(context.user_id) 不会被访客打炸,那轮跨模块收敛不用做了。
但合并前有三件事要处理,两件是功能会直接不可用:
1)docs/05 §19 端点编号撞车:A034/A035 各被占了两次(我方登录 + RBAC 只读已占 A034-A038,
你又插了画像候选那两行)。麻烦改成 A039/A040,M003/M004 不冲突、保留。
提醒一下:tools/check_authoritative_docs.py 只查文档文件名编号,不查 §19 端点号,
所以这个重复合并后守卫会放过去,我是手工核出来的。
2)三个新权限不在任何脚本里:memory:candidate:confirm、memory:candidate:review、
handover:read。我实测我方库 sys_permission 38 条里都没有,你分支的 tools/ 和 alembic/
里也没有种子或迁移。这样合过来以后,候选确认、候选审核、转人工工单这 5 个接口会全 403
(fail closed 是对的,但等于没上)。你的集成测试能过,说明你环境里已经手工插过了 ——
这正是"环境数据不随代码合并"的第 4 次踩坑。
这份我出了:tools/grant_customer_service_phase2_permissions.py,id 从 9036 起
(避开种子 9001-9019 和投顾 9020-9035),customer 角色给 confirm、admin 给 review +
handover:read。你如果已经插过,把你用的 id 号段报我,我按你的对齐。
注意它只增不删,跑过 seed_test_rbac.py(DELETE 9001-9099)之后要重跑。
3)milvus-lite 加到主 dependencies 了(pyproject.toml 和 requirements.txt 都是)。
按确认口径应该放 optional-dependencies,你自己的接入说明里也写的"(可选本地开发依赖)",
所以是放错段了,挪一下就行。
还有品牌名那条,给你一个对你有利的补充事实:app/core/customer_service_rules.py 第 116、
129 行早就是"奶龙基金"了(转人工和防诈骗话术),而 COMPANY 是"南方科技" —— 所以改之前
底座自己就是矛盾的,你这一改实际是消除内部不一致,不只是换品牌。上报时把这层理由写进去
更容易过。建议单独一个提交,便于项目方拍板后单独回滚。我方 tools/chat_console.py 也还是
南方科技,定了奶龙我们自己改。
合并预演我做了:merge-tree 无冲突,你这 89 个文件与我方本地领先的 6 个提交零重叠,
没有新迁移、app/model 的改动只是别名和映射整理、表结构没变。
建议顺序是先推我这 6 个、再合 PR #7(都无冲突,但这样 PR 的 diff 更干净)。
那三件处理完,PR #7 我这边没有别的反对意见。
辛苦了。
```
@@ -0,0 +1,210 @@
"""补客服二期(画像候选 + 转人工工单)需要的三个权限码。
## 为什么需要它
客服二期这条线新增了 6 个接口,其中 5 个要三个**平台此前不存在的权限码**:
| 接口 | 权限码 |
|---|---|
| `POST /api/v1/users/me/memory-candidates/{id}/decisions` | `memory:candidate:confirm` |
| `GET /api/v1/admin/customer-profile-candidates` | `memory:candidate:review` |
| `POST /api/v1/admin/customer-profile-candidates/{id}/reviews` | `memory:candidate:review` |
| `GET /api/v1/admin/customer-service/handover-tickets` | `handover:read` |
| `GET /api/v1/admin/customer-service/handover-tickets/{ticket_no}` | `handover:read` |
这三个码在 **没有种子脚本、也没有迁移** 的情况下被业务代码直接引用,
所以它们属于**环境数据**:代码合过来以后,如果库里没有这三行,上面 5 个接口一律
`403 缺少操作权限` —— 报错看起来像"权限配错了",实际是**权限码根本不存在**。
(`GET /api/v1/users/me/memory-candidates` 用的是既有的 `memory:read:self`,不受影响。)
这与 `AGENTS.md` §E 那条环境口径是同一类问题:
`config_release`、`sys_role`/`sys_permission`/`sys_user`、Milvus 集合 schema、
`advisor_product_*` **都不随代码合并**,换环境必须重放。
## 授给谁 —— 依据是代码里的真实校验
`app/service/authorization_service.py:11-17` 的语义是:
```python
allowed = permission in context.permissions
if admin:
allowed = allowed and bool({"admin", "super_admin"}.intersection(context.roles))
```
| 权限码 | 校验方式 | 授给 | 理由 |
|---|---|---|---|
| `memory:candidate:confirm` | `require(..., USER_CONFIRM_PERMISSION)`,**非 admin** | `customer` | 客户确认**自己**的候选;查询按 `customer_id` 过滤,不会越权 |
| `memory:candidate:review` | `require(..., ADMIN_REVIEW_PERMISSION, admin=True)` | `admin` | 需同时满足权限与管理员角色 |
| `handover:read` | `require(..., "handover:read", admin=True)` | `admin` | 同上 |
我方库里没有 `super_admin` 角色,`admin=True` 那一支由 `admin` 满足。
## ⚠️ 与 `seed_test_rbac.py` 的冲突(与投顾那次同源)
`seed_test_rbac.py` 是 **DELETE 重建**语义,它的
`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 会**清掉本脚本建的权限**。
所以本脚本**只增不删**,用于局部补齐;若要长期固化,请把这三个权限并进
`seed_test_rbac.py` 的 `PERMISSIONS` 常量(投顾那 16 个也是同一个待办)。
权限 id 从 **9036** 起:避开种子占用的 9001-9019,也避开投顾的 9020-9035。
用法:
python tools/grant_customer_service_phase2_permissions.py --dry-run # 只打印将写入什么
python tools/grant_customer_service_phase2_permissions.py
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime
from sqlalchemy import text
from app.infrastructure.db import SessionFactory
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: (id, permission_code, resource, action, data_scope)
PHASE2_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9036, "memory:candidate:confirm", "memory:candidate", "confirm", "self"),
(9037, "memory:candidate:review", "memory:candidate", "review", "all"),
(9038, "handover:read", "handover", "read", "all"),
)
#: 角色 id(`seed_test_rbac.py` 建的)。
CUSTOMER_ROLE_ID = 9001
ADMIN_ROLE_ID = 9003
#: 每个角色该拿到哪些码。
GRANTS: tuple[tuple[int, str, tuple[str, ...]], ...] = (
(CUSTOMER_ROLE_ID, "customer", ("memory:candidate:confirm",)),
(ADMIN_ROLE_ID, "admin", ("memory:candidate:confirm", "memory:candidate:review",
"handover:read")),
)
async def apply(*, dry_run: bool) -> int:
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
existing = dict(
(await session.execute(
text("SELECT permission_code, id FROM sys_permission")
)).all()
)
to_create = [p for p in PHASE2_PERMISSIONS if p[1] not in existing]
print(f"权限:库里已有 {len(existing)} 个,本次新增 {len(to_create)} 个")
for _, code, resource, action, scope in to_create:
print(f" + {code:<32} {resource}:{action} scope={scope}")
if not to_create:
print(" (三个权限都已在库中)")
role_ids: dict[str, int] = {}
for role_id, role_code, _codes in GRANTS:
found = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"),
{"code": role_code},
)
print(f"角色 {role_code:<10} {'已存在' if found else '缺失(请先跑 seed_test_rbac.py)'}")
if found is not None:
role_ids[role_code] = int(found)
if dry_run:
print("\n[dry-run] 未写入任何数据。")
return 0
for perm_id, code, resource, action, scope in to_create:
await session.execute(
text(
"""
INSERT INTO sys_permission
(id, permission_code, resource, action, data_scope, created_at, updated_at)
VALUES
(:id, :code, :resource, :action, :scope, :now, :now)
"""
),
{"id": perm_id, "code": code, "resource": resource,
"action": action, "scope": scope, "now": now},
)
# 重新取一遍完整映射,避免依赖本次插入的 id。
permission_ids = dict(
(await session.execute(
text("SELECT permission_code, id FROM sys_permission")
)).all()
)
for role_id, role_code, codes in GRANTS:
if role_code not in role_ids:
print(f"跳过 {role_code}:角色不存在")
continue
have = set(
(await session.scalars(
text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"),
{"r": role_ids[role_code]},
)).all()
)
added = 0
for code in codes:
perm_id = permission_ids.get(code)
if perm_id is None or int(perm_id) in have:
continue
await session.execute(
text(
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
" VALUES (:r, :p, :now)"
),
{"r": role_ids[role_code], "p": int(perm_id), "now": now},
)
added += 1
print(f"授权:{role_code:<10} 新增 {added} 项(共 {len(codes)} 项)")
await verify()
return 0
async def verify() -> None:
"""按权限码实测一遍,而不是只看插了几行。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT p.permission_code, r.role_code
FROM sys_permission p
JOIN sys_role_permission rp ON rp.permission_id = p.id
JOIN sys_role r ON r.id = rp.role_id
WHERE p.permission_code IN
('memory:candidate:confirm', 'memory:candidate:review', 'handover:read')
ORDER BY p.permission_code, r.role_code
"""
)
)
).all()
print("\n实测绑定关系:")
for code, role in rows:
print(f" {str(code):<32} → {role}")
missing = {
"memory:candidate:confirm", "memory:candidate:review", "handover:read",
} - {str(code) for code, _ in rows}
if missing:
print(f"[失败] 仍未绑定:{sorted(missing)}")
raise SystemExit(1)
print(
"\n客服二期接口现在应当可用了。若之后跑过 `seed_test_rbac.py`(DELETE 重建 9001-9099),"
"必须重跑本脚本 —— 或先把这三个权限并进那个种子的 PERMISSIONS 常量。"
)
def main() -> int:
parser = argparse.ArgumentParser(description="补客服二期的画像候选与转人工工单权限")
parser.add_argument("--dry-run", action="store_true", help="只打印将写入什么")
args = parser.parse_args()
return asyncio.run(apply(dry_run=args.dry_run))
if __name__ == "__main__":
sys.exit(main())