画像投影链路接通 + 合并主干(PR #7)并对齐两套投影实现 #9

Open
wangjianlong_0626 wants to merge 10 commits from NL_develop into qyqy_develop
10 Commits
Author SHA1 Message Date
wangjianlong_0626 1f62aca6f7 fix(seed): 当日行情重跑必须刷新 source_updated_at(修 503 FUND_QUOTE_UNAVAILABLE)
真机实测到的缺陷:`tools/seed_sim_account_demo.py` 的 `_upsert_market_price()`
在"当天已有行"时直接 `return`,于是同一天重跑种子**不刷新 `source_updated_at`**。
行情是时效数据,过期后 `FundQuoteService` 返回
`503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期`,
连带 `T001` 仪表盘与 `T006` 持仓一起不可用(`T010` 权益不查行情,仍 200)。

表现极具误导性:**刚灌完种子能用,过十几分钟就 503** ——
看起来像行情适配器或缓存故障,实际根因在种子脚本。归因过程:
`memory_sync` / Redis / Milvus 全部正常,`fin_market_price` 里当天那行
`source_updated_at` 停在首次灌入时刻。

修法:把列值抽成共享 `values` 字典,当天已有行时 `update` 刷新全部行情列
(含 `source_updated_at`),不存在才 `insert`。
**幂等的正确含义是"不产生重复行"(`(product_id, trade_date)` 唯一),
不是"不更新值"。** 账户/持仓的"已存在则跳过"保持不变 ——
那是业务数据,不该被种子覆盖。

验证(本机,测试客户 9001 `cust_t`):
- `python -X utf8 -m tools.seed_sim_account_demo --customer-id 9001` 正常
- `GET /api/v1/users/me/account/dashboard` → 200(修前 503)
- `GET /api/v1/users/me/holdings` → 200
- `GET /api/v1/users/me/entitlements` → 200

门禁:ruff `app tests tools alembic` 全过;`mypy app` 0 错(252 文件);
`audit_schema.py` 90 张业务表无差异;文档编号/端点编号/RBAC 种子一致性全过。
2026-09-12 17:32:24 +08:00
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
wangjianlong_0626 37926e1c88 Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 16:39:39 +08:00
wangjianlong_0626 db4dae2fe6 chore: 追平主干(advisor 演示引导/风控权限配置 3 提交);同步测试基线 1424
主干 `c200a61..5634fdc` 3 个提交(advisor 演示环境引导、风控登录权限与模型能力配置),
与本线 5 个提交**无文件重叠**,合并**无冲突**(`tools/seed_test_rbac.py` 双方都改过,
Git 自动合上;合并后 `check_rbac_seed_consistency.py` 仍通过)。

追平后本线相对主干恢复 **BEHIND 0**,PR #9 可重新 fast-forward 合并。

## 验证(合并后重跑)

- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1424 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `check_authoritative_docs.py` → 53 份文档无编号冲突
- `check_rbac_seed_consistency.py` → 通过(种子 id 唯一、各 grant 脚本与种子逐条一致)
- `check_docs_endpoint_ids.py` → §19 端点编号无重复
- `audit_schema.py` → 89 张业务表无缺失/意外
2026-09-12 14:50:08 +08:00
wangjianlong_0626 aa3f9f9b3d Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 14:48:45 +08:00
wangjianlong_0626 b2a2e4292b docs(37): 补「换到新环境必须先做」的前置清单(建集合/Milvus/embedding 端点/迁移) 2026-09-12 14:35:49 +08:00
wangjianlong_0626 c12f836d26 Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 14:28:51 +08:00
wangjianlong_0626 86d1cf1ffc docs: 补回合并时丢失的 ruff 基线;合并主干 6 提交(含架构师对我疏漏的修正)
## 1. 补回被我丢失的门禁条目

主干 `AGENTS.md` 原本有一条 `ruff` 干净,但我在解决 `AGENTS.md` 冲突时**只保留了自己的
mypy/pytest 基线,把这条丢了** —— 合并时丢信息,和代码冲突一样是缺陷。

本次补回,并把命令写准(这点很重要):

- `ruff check app tests tools alembic` → **`All checks passed`(0 错)**
- 直接 `ruff check .`(全仓)会报 **40 个错,全部来自仓库根目录的 `hq.py` / `nl2sql_yc.py`**
  (袁聪线的演示脚本,不属本项目包结构)

所以"ruff 干净"**必须带范围**,否则会和别人的脚本混在一起、把一个健康状态误报成 40 个错。

## 2. 我的一个真实疏漏(已由架构师修正,本次合并带入)

架构师提交 `ffbcc22`:**`fix: 删掉 NL 合并后残留的未使用变量 role_ids(ruff F841)`**

该疏漏是我引入的:改 `tools/seed_test_rbac.py` 时把
`zip(user_ids, role_ids, strict=True)` 换成显式配对表 `USER_ROLES`,删掉了 `user_ids`
却**没删 `role_ids`**。根因是**我全程没跑过 ruff** —— 项目门禁里有它,
只跑 mypy 和 pytest 是不够的。

已在本机复跑 `ruff check app tests tools alembic` 确认:我改过的文件全部干净
(`role_ids` 已随主干修正进来)。

## 3. 合并主干 6 提交

`fedbf5a..c8cdc06`,含上条修正与投顾线的行情双源、验收归档等,**无冲突**。

## 验证

- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1421 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
2026-09-12 14:26:43 +08:00
wangjianlong_0626 8cb75992e6 Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 14:21:34 +08:00
wangjianlong_0626 451aa4e915 fix(profile): ProfileAssemblyService 漏写 current_customer_id(唯一键失效的另一处)
## 现象(2026-09-12 端到端跑通后查数据时发现)

客户 9102 的画像快照状态**彼此矛盾**:

| version | is_current | current_customer_id |
|---|---|---|
| 2 | `0` | `9102` ← 清旧时没清空 |
| 3 | `1` | `NULL` ← 建新时没写入 |

## 根因

`ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成了生成列**:

- 方法 docstring 原文写着"唯一键 `uk_profile_snapshot_current` 建立在**生成列** `current_customer_id` 上"
- 因此两处都不赋值(以为 DB 会自动填)

**但该列不是生成列** —— `alembic/baseline_generated.sql` 与真实库都是**普通可空列 + 唯一键**,
`app/model/profile.py` 的模块 docstring 第 2 条已明确:"当前版本必须由写入方**显式写入**客户 ID
(历史版本写 NULL),才能保证「每个客户最多一条当前快照」"。

后果:唯一键**形同虚设**(多个 NULL 不冲突)⇒ 不变式失效;且旧版本残留的值
一旦与新版本补上的值相同,就会**直接撞唯一键**。

> 这与本线先前修的 `CustomerProfileCandidateService._write_profile_snapshot` 是**同一个缺陷的另一处**
> —— 当时只找到一处,这次是靠真实链路跑出数据后核对才暴露出来。

## 改动

`app/service/profile_assembly_service.py`:

- 旧版本:`is_current = False` 的同时 `current_customer_id = None`
- 新版本:`is_current=True` 的同时 `current_customer_id=customer_id`
- 订正方法 docstring 的错误认知("生成列"→ 普通可空列 + 唯一键),并写明后果

## 已有数据订正

新增 `tools/fix_profile_snapshot_current.py`(**默认 dry-run**、幂等、`--apply` 才提交):

1. 先清空 `is_current=0` 却残留值的行
2. 再补写 `is_current=1` 却是 NULL 的行
3. **顺序要紧**:反过来的话第 2 步会与残留值撞唯一键

本机实测:清空 1 行、补写 1 行,复核两类异常均归零。

## 验证

- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1418 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- 端到端:真实对话 → 记忆抽取 → `memory_unit` 落库已实测通过(客户 9102
  `preference:risk_level = "低风险"`,候选态)
2026-09-12 14:20:38 +08:00