Files
wangjianlong_0626 4766e3bd98 feat(benefit): 客户权益功能(T010)+ 修投顾迁移契约里写死 head 的脆弱断言
## 1. 新增客户权益(用户端)

`GET /api/v1/users/me/entitlements`(T010,权限 `benefit:read:self`):

- **层级**由 `fin_customer_profile.total_asset` **实时判定**
  (门槛来自 `knowledge/product/高净值客户服务规范.md`:
  金卡 50 万 / 白金 200 万 / 钻石 600 万 / 私行 1000 万;低于 50 万为普通客户);
- **权益按层级累积展开**(文档原文"含全部下级权益,新增以下"):
  金卡 9 条 / 白金 20 / 钻石 33 / 私行 54,各档已逐档实测;
- 返回**升级提示**(`next_tier`:下一层级与门槛),前端可直接渲染"再投 X 元升级"。

### 新增表 `fin_customer_benefit`(1 张)

层级 → 权益目录,54 条种子数据(`tools/seed_customer_benefits.py`,按 `benefit_code` 幂等)。

**基线合规证明**(规则 1/3/4):只新增这一张表;**未**重命名/删除任何已有表;
**未**重命名/删除/复用任何已有字段,**未**改任何已有字段的类型、可空性或业务含义;
未改 `docs/00`。
复核:`tools/audit_schema.py` → `90 business tables, no missing or unexpected tables`。

### 两条设计取舍

1. **不落"某客户享有哪些权益"**:层级可算,权益由层级推出,两者都不落库。
   与 `docs/00` L159(不保留 `net_worth_flag`,因为可算)同一取向。
2. **权益只存各层新增条目**,累积由服务层 `tier_chain()` 展开 ——
   否则改一条权益要改四处,漏一处就出现"白金没有金卡权益"。

### 数据来源与一处刻意省略

逐条照抄知识文档,不新增文档里没有的权益。**私行那条
「7×24小时私人银行专线:400-XXX-XXXX 转 8」不写号码** ——
文档里是占位符,而对客号码的唯一来源是 `customer_service_rules.CONTACT_PHONE`
(本线此前修过"同一客服给客户两个不同号码"的缺陷)。把占位符抄进库等于再造一份假号码。

## 2. 修投顾迁移契约里写死的断言

`tests/unit/test_advisor_migration_contract.py` 原先断言

```python
assert script.get_heads()[0] == "20260911_merge_adv_risk_heads"
```

那是"投顾迁移刚加完那一刻"的快照 —— 本 PR 一新增迁移(`20260912_customer_benefit`)
它就变红,**而红的原因与投顾链的对错无关**:断言测到的是时间,不是契约。

原意是"投顾链接在这条主链上、没另起分支"。改为断言**投顾链尾是当前 head 的祖先**
(链尾从 `ADVISOR_FILES[-1]` 派生,不写死),既保住原意又不受后续迁移影响。
`len(script.get_heads()) == 1`(链不分叉)与"投顾文件首尾相接"两条原样保留。

## 3. 顺带发现的既有缺口(**不在本次改动范围**)

`app/api/controllers/trading.py` 的 **T001–T009 未调用 `AuthorizationService.require`**:
`docs/05` §19 为它们登记了权限码(`account:read:self` / `trade:order:*` / `holding:read:self`),
但代码只做认证 + 开户测评门槛,**没有执行 RBAC 权限检查**。
对照:仓库里 **26 个 service** 都调了 `require`,`trade_service` 不在其中。

本线的 T010 **按正确做法实现**:`CustomerBenefitService.entitlements_for` 先鉴权再读数据,
且**鉴权在读取客户资产之前**(有测试断言"拒绝时未查库")。
T001–T009 如何补,需架构师定口径后另行处理。

## 4. 文档

- 新增 `docs/41-客户权益功能说明.md`:表登记 + 基线合规证明 + 分层口径 + 累积规则 +
  数据来源 + 权限 + 与仪表盘的关系 + 上述缺口
- `docs/05` §19 登记 T010,并**单独注明它引入了新表**(避免被误读为
  "T 段数据库零变更"的一部分)
- `AGENTS.md` 表数 89 → **90** 张业务表

## 验证

- `pytest tests/unit/service/test_customer_benefit_service.py` → **20 passed**
  (含边界:499999.99 不是金卡、500000 整是金卡、1000 万整是私行;累积条数;升级提示;
  鉴权先于读数据)
- 全量 `pytest tests` → `2 failed, 1469 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → **0 错 / 252 文件**
- 真机:`GET /users/me/entitlements` → `200`;各档分层与累积条数逐档实测通过
- `audit_schema.py` → 90 张业务表无缺失/意外;文档守卫 55 份无编号冲突;
  端点编号无重复;RBAC 种子一致性通过
2026-09-12 17:24:37 +08:00

161 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 项目级开发约束
以下规则对人工开发者和编码 Agent 均为强制约束:
1. 数据库以 `docs/00-新数据库基线设计.md` 为不可变业务基线。
2. 允许创建新表,允许在已有表中增加新字段。
3. 禁止重命名或删除已有表。
4. 禁止重命名、删除、复用已有字段,禁止改变已有字段的类型、可空性和既有业务含义。
5. 历史结构无法满足新需求时,使用新增字段、新表、兼容视图或应用双读解决。
6. 架构固定使用 MVC+S;Agent 属于 Service 层。
7. 业务 Agent 必须继承公共 `BaseAgent` 并由 `AgentFactory` 创建,不得绕过公共鉴权、记忆、模型路由、工具、合规、审计和事件流程。
8. 当前系统业务功能只针对场内基金模拟交易;场外基金运营流程独立,不得写入场内交易表。
修改数据库文档或迁移前,必须对比基线并证明没有改变任何已有表名和已有字段定义。
---
## 📖 接手先读(按此顺序,只读这些就够)
> **⭐ 第 0 步先读这个**:`docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md`
> —— 客服 Agent + RAG 这条线的交接文档(**含合并完成后的第二次更新**):环境口径、交付内容与
> **可复现验证证据**、合并后修掉的 3 个真机故障、**已知问题清单(逐条标注当前状态)**、Git/PR 状态。
> **主集成分支是 `qyqy_develop`**(ZSY 的客服接入线已由 PR #7 合入,见 `docs/36`);
> 客服/RAG 那条线的个人分支是 **`NL_develop`**(个人分支 → PR 合回 `qyqy_develop`),**不要再用 `6516ccb`**。
> 它是对"当前状态"最准确的一份,读完它再读下面这些。
>
> **⚠️ 文档现状(2026-09-11 第二次修订)**:本文件原先声明"已删除 5 份编号文档",
> 那条**已作废** —— 经评审,`docs/04`/`06`/`10`/`13`/`99` **全部保留**(架构师明确要求保留:
> 删除收益为零,而保留成本同样为零)。它们的内容**未被核对过、可能过期**,
> 因此**列在下面的 D 类"不要用来判断当前进度"**里,只作历史参考。
> 被删除的只有 10 份**过程产物**,理由与清单见 `docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md`。
### A. 核心 7 份(无论接手哪条线都必读)
| 序 | 文档 | 承载的唯一权威内容 |
|---|---|---|
| 1 | `docs/00-新数据库基线设计.md` | **不可变业务基线**:表/字段业务语义的唯一来源 |
| 2 | `docs/05-接口文档.md` | **接口唯一权威**:信封/错误码/幂等/SSE、§8.3 知识库管理三端点、§8.4 四个只读工具索引与两段式白名单 |
| 3 | `docs/01-通用Agent平台开发设计.md` | MVC+S 分层约束、`BaseAgent` 执行骨架、`AgentFactory` |
| 4 | `docs/02-数据库建表设计.md` | 51 张业务表总览 + DDL + §8 幂等与 Outbox 语义 |
| 5 | `docs/03-平台端到端流程文档.md` | 一次请求从受理→Worker→审计→事件的全链路与降级矩阵 |
| 6 | `docs/08-数据库结构审计基线.md` | 三个审计工具 + `migration_state_check` 的职责;"证明未改基线"的证据出处 |
| 7 | `docs/07-测试问题修复记录.md` | **无替代**:P0-1/2/3 鉴权与 Worker 租约闭环、P1-1~P1-5(含 `api_request_receipt` 事务幂等) |
### B. 按角色补充
| 你接手的是 | 再读这些 |
|---|---|
| **客服 Agent + RAG 这条线** | `docs/14`(接入入口)→ `docs/18`(RAG 三集合路由方案)→ `docs/19`(可运行示例)→ `docs/09`(底座用法与四工具) |
| **整个底座** | 补 `docs/20`(第一版→当前的破坏性改动 + §5 四条尚未修复的偏差 + "跑验收前先停常驻 Worker") |
| **只改某个业务域** | `docs/00` → `docs/05` → `docs/02` → `docs/14` → `docs/19`;行情加 `docs/12`,前端/联调加 `docs/17` |
| **看"现在做到哪了"** | `docs/验收与审计/phase1-acceptance-report.md`(Phase 1 七条验收标准的逐条可复现证据)+ 同目录 `phase1-acceptance-criteria.md`(老师验收标准原文摘录) |
> 注:完整的过程台账(`progress.md`、各 Task 报告、审计报告)在 **`.superpowers/sdd/2026-09-10-客服Agent与RAG-qyqy版/`**,
> 但该目录**被 `.gitignore` 忽略**(属工作区过程产物)—— 因此**结论性文档已复制到 `docs/验收与审计/`** 以保证 git 可见。
> 若要查过程细节再去看 `.superpowers/`;日常接手**只需读本文档列出的这些**。
### C. 同主题的重复文档(读一份即可,避免信息冲突)
| 主题 | 唯一权威 | 重复品(仅历史参考) |
|---|---|---|
| Agent 组员接入 | **`docs/14`** | `docs/11`(旧版说明书)、`docs/15`(详细手册)、`docs/16`(入门易懂版)—— 三份已各自在开头标注"以 `14` 为准" |
| 接口说明 | **`docs/05`** | `docs/17`(易懂版,自述"不替代 05") |
### D. ⚠️ 不要用来判断"当前进度"
| 文件 | 为什么 |
|---|---|
| `TODO.md` | **自 2026-09-09 起未随 Phase 1 更新**:5 处"49 张表"(实为 51 张业务表)、T8.1 客服 Agent 整节未勾选但**已交付**、多处标"进行中"其实已完成。**当前进度一律以 `phase1-acceptance-report.md` 为准。** |
| `docs/04` / `06` / `10` / `13` / `99` | 内容**未核对过、可能过期**(自相矛盾 / 结论失效 / 清单不全)。经 2026-09-11 评审**保留**(不再删除),仅作历史参考。**不要用它们判断现状** |
### E. 环境与命令口径(易错点)
- 解释器:本机用 **`.\.venv\Scripts\python.exe`**;架构师环境用 `D:\conda\envs\jr_py313\python.exe`。
两者等价,**各用本机可用的那个**(`.venv` 被 `.gitignore` 忽略、不进仓库,不存在"需要统一"的问题)。
- 数据库现为 **91 张表**(含 `alembic_version`)= **90 张业务表** =
**场内 51 + 场外/推广 17 + 投顾 21 + 客户权益 1**。
后 39 张(`offsite_*` / `promotion_*` / `advisor_*` / `fin_customer_benefit`)**不进 `docs/00` 基线**(规则 8):
场外/推广那 17 张逐表登记见 `docs/28-场外与推广域数据表登记.md`;
**投顾那 21 张的登记文档待补**(按同样口径另立一份);
客户权益 1 张见 `docs/41-客户权益功能说明.md`(含基线合规证明)。
核验命令:`python tools/audit_schema.py`(若报 `unexpected` 先分清是"库里多表"还是"迁移没进来")。
- 已注册业务 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 拦下整次发布)。
- ⚠️ **Milvus 集合 schema 也因环境而异**:本机是 `knowledge_id`/`snippet`(无 `visibility`),
架构师环境是 `doc_id`/`content`/`visibility`/`chapter`…。**检索层已改为运行时探测字段名**
(`app/core/knowledge_schema.py`)——**不要在任何地方硬编码字段名**,那会把另一套环境打挂。
- ⚠️ **Docker Desktop 不会常驻**:它没运行时 Milvus 不可用(`docker` CLI 报连不上守护进程)。
跑真机验证前先确认 Docker Desktop 在运行。
- ⚠️ **`memory_sync_outbox` 的取值必须是小写英文**(`milvus`/`neo4j`、`upsert`、
`pending`/`failed`/`processed`/`dead`)。`docs/00` §6.4.6 那一栏曾写作大写
`MILVUS`/`NEO4J`、`UPSERT` + 中文 `待处理`,**与全仓实现从未对齐,照它写会静默失效**:
消费端按 `handlers.get(target_store)` 分派、且只领 `status in {"pending","failed"}`,
大写 + 中文两个条件都不满足 ⇒ **事件任何消费者都领不到、永久滞留且不报错**
(唯一键 `(event_uuid, target_store)` 对大小写无约束,MySQL 也不报错)。
取值口径以**主干既有读取方**为准(`projection_reconciliation_service.py`、
`graph_projection_worker.py`),不是文档。详见 `docs/37-记忆投影链路实现说明.md`。
- ⚠️ **一张表只能有一个 ORM 类**:`app/model/` 下曾出现**两个类都映射 `profile_snapshots`**
(`profile.py` 与 `risk_questionnaire.py`),各自单独导入都没事,**同时导入即抛**
`InvalidRequestError: Table 'profile_snapshots' is already defined for this MetaData instance`
—— Worker 既要重建画像又要处理投顾问卷,因此**真的被打挂过**(库里 `memory_sync_outbox`
留下 `last_error='InvalidRequestError'` 的行)。2026-09-12 已修为 re-export,见 `docs/37` §6.2。
**新增模型前先搜一遍 `__tablename__` 有没有被占用。**
- 测试基线(**2026-09-12 合并主干 PR #7 + 本线记忆投影链路之后实测**):
`mypy app` → **245 个文件 0 错**;
`pytest tests`(全量)→ `2 failed, 1424 passed, 1 skipped`;
`pytest tests/integration` → `102 passed, 1 skipped`。
⚠️ **用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**。
那 2 个失败**都不是代码缺陷**,接手时不要"修"它们:
`tests/unit/service/test_offsite_document_recognition_adapter.py` 的 2 个用例 —— **环境相关**:
它们断言请求体里是中文原文,而 httpx 会把中文序列化成 `\uXXXX`,字节序列自然不匹配。
功能无影响;若要修,正确做法是断言 `json.loads(body)` 后的字段值(字节级断言不该用来测 JSON)。
- **`ruff`:`ruff check app tests tools alembic` → 全部 `All checks passed`(0 错)。**
⚠️ **检查范围要写对**:直接 `ruff check .`(全仓)会报 40 个错,**全部来自仓库根目录的两个
散落脚本 `hq.py` / `nl2sql_yc.py`**(袁聪线的演示脚本,不属本项目包结构)。
所以门禁命令用上面那条;报"ruff 干净"时**必须带范围**,否则会和别人的脚本混在一起。
(`pyproject.toml` 的 `[tool.ruff.lint]` 选的是 `E,F,I,B,UP`、`line-length=100`;
`tools/*.py` 另配了 `E501/I001/B007` 的 per-file-ignores。)
- **mypy:`mypy app` → `Success: no issues found in 245 source files`(0 错)。**
⚠️ 曾在本机报 184 个错,**已查明是环境版本旧**,与代码质量无关 —— 复现矩阵:
| SQLAlchemy | mypy | 报错数 |
|---|---|---|
| 2.0.34(本机旧) | 1.14.1 | **173** |
| 2.0.34 | 1.20.2 | 173 |
| 2.0.52 | 1.14.1 | **6** |
| 2.0.52 | 1.20.2 | **0**(当前) |
⇒ 主因是 **SQLAlchemy 的补丁版本**(旧补丁版类型标注不完整,`BIGINT`/`DATETIME` 被判成未类型化
函数,`app/model/*.py` 每个列定义报一条)。出现"一边上百个错、另一边 0 错"时**先对版本**,
别当代码质量问题;根因是某一侧的虚拟环境没满足 `pyproject.toml` 的
`sqlalchemy>=2.0,<3` / `mypy>=1.14,<2`。
**不要装 `sqlalchemy2-stubs`** —— 那是给 SQLAlchemy **1.4** 用的,2.0 自带 `py.typed`,
装上会按 1.4 API 核对 2.0 代码、换一批新错(`mapped_column` / `DeclarativeBase` 不存在)。
`pyproject.toml` 的 `sqlalchemy>=2.0,<3` 允许范围内补丁版差异会造成量级差异;
若门禁数字要求稳定,需把 SQLAlchemy 钉到具体补丁版(属公共约定,改前先问)。
- ⚠️ **`MILVUS_LOCAL_URI` 配了就会"看着正常、查的是另一个库"**:一旦在 `.env` 里设置它,
健康检查与部分检索链路会指向本地 **Milvus Lite 文件**。团队/生产环境请**保持该变量为空**。
对应的 `milvus-lite` 属**本地开发依赖**,应放在 `pyproject.toml` 的
`optional-dependencies`,**不要进主 `dependencies`**。
- 集成测试前置(**不跑这两步,`tests/integration` 会有 13 个登录/RBAC 用例因 401 而红**,
容易被误判成代码缺陷):先 `python tools/seed_test_rbac.py`(角色/权限/演示账号),
再 `python tools/set_user_password.py`(演示口令,**非幂等**:重复执行等于重设密码)。
接口联调清单见 `docs/32-平台侧交接与联调准备.md`;主干 PR #7 合并的逐项证据见
`docs/36-PR7合并记录与权限号段修正.md`。