Просмотр исходного кода

docs: T28-FIX 食材推荐指数键语义对齐(AC-09 修复)独立核验报告 — 方向2读侧双轨,全部闸门通过,唯一风险为与 cfclub 方向1 合并冲突

E2E Test Bot 2 недель назад
Родитель
Сommit
f9622d27ea
1 измененных файлов с 145 добавлено и 0 удалено
  1. 145 0
      docs/T28-FIX食材键对齐核验报告.md

+ 145 - 0
docs/T28-FIX食材键对齐核验报告.md

@@ -0,0 +1,145 @@
+# T28-FIX 食材推荐指数键语义对齐(AC-09 修复)——独立核验报告
+
+- **核查对象**:远分支 `origin/feature/t28-fix-key-align` = `c70b58049`(4 文件 +333/−33,单提交,无 DDL/数据迁移)
+- **核查基线**:`git merge-base` = `802fc5adc`(`origin/cfclub` 之后、本分支分叉点)
+- **核查方式**:独立 opencode 会话(`verify/t28-fix-key-align` 分支,工作树 `D:\tmp\t28-fix-v`);后端 9084 运行态 + 测试库 `192.168.16.251:3306/zxyj` 实测 + 静态代码逐行核对 + 单元测试全量运行
+- **核查日期**:2026-10-31
+- **修复方向**:方向 2(**读侧双轨兼容**,不动写侧)——按 2026-10-31 干系人复评采纳
+- **总体结论**:✅ **通过全部核验闸门,可进入合入决策** —— AC-09 修复在真实报告确认链路上实测生效(总览 0 行 → 2 行,且二次确认幂等更新 -25→15);编译通过;单元测试 29/29;双轨覆盖完整;纯档案成员(userId=null)由 memberId 轨自然覆盖;`getIndexByUser` 残留单轨为 P2 命名债(闭合回路,无数据丢失)。**唯一风险为与 cfclub 主线的合并冲突**(cfclub 已含方向 1 写侧归一化,见 §六),需维护者决策合并策略。
+
+---
+
+## 一、根因与修复口径(回顾)
+
+### 1.1 根因(此前核验实锤,AC-09 P0)
+
+报告确认链路 `resolveSubjectToMemberId(subjectId, familyId)` 把被检测人归一为 `family_members.id`(memberId),
+`FoodRecommendService.recalculateForUser` 原样写入 `food_recommend_idx.user_id`;而家庭总览读侧
+`FamilyFoodOverviewService.doRecalculate` 与 `FoodRecommendService.refreshFamilyOverviews` 均按
+`family_members.user_id`(即 `users.id`)匹配该列。两条键(memberId vs users.id)不唯一对齐 →
+memberId 键行永远命不中 → 报告确认触发的家庭总览重算链路完全失效(AC-09 持久化值为空/0 行)。
+
+### 1.2 修复口径(方向 2:读侧双轨,写侧不变)
+
+| 层 | 改动 | 关键点 |
+|---|---|---|
+| `FoodRecommendService.refreshFamilyOverviews` | 家庭反查改双轨 `WHERE user_id = ? OR id = ?` | memberId 轨(当前写侧语义)与历史 userId 轨任一命中即可定位所属家庭并触发总览重算;纯档案成员(userId=null)由 id 轨自然覆盖 |
+| `FamilyFoodOverviewService.doRecalculate` | 成员集合不再以 `user_id IS NOT NULL` 过滤(纳入纯档案成员);键空间 `IN (memberIds ∪ userIds)` 一次批量查;`resolveIndexOwner` 归一到成员本人(memberId 轨优先) | 同一成员新旧键并存按【成员本人 memberId】去重取 `calculated_at` 最新;`memberScores[].userId` 统一语义 = memberId(遗留命名债,注释说明) |
+
+写侧 `recalculateForUser`/`HealthReportService.resolveSubjectToMemberId` **未改动**(方向 2 前提:memberId 键行由读侧双轨兜底)。
+
+---
+
+## 二、变更文件清单(4,+333/−33)
+
+| 文件 | 改动 | 说明 |
+|---|---|---|
+| `service/FamilyFoodOverviewService.java` | +118/− | `doRecalculate` 双轨化 + `resolveIndexOwner/resolveIndexOwnerId` 归一 + 按成员去重 |
+| `service/FoodRecommendService.java` | +11/− | `refreshFamilyOverviews` 反查改双轨(`WHERE user_id=? OR id=?`) |
+| `service/FamilyFoodOverviewServiceTest.java` | +165 | 新增 6 项用例(memberId 键命中 / 历史 userId 键兼容 / 同成员双轨去重取最新 / 纯档案 userId=null 命中 / 纯档案无记录不报错 / 回收箱 memberId 键剔除) |
+| `service/FoodRecommendServiceTest.java` | +72 | 双轨反查 wrapper 结构与触发链用例 |
+
+> 无 `DatabaseInitializer` 迁移、无 `schema.sql` 变更、无 DDL/数据迁移 → 不影响生产库结构,无回滚风险。
+
+---
+
+## 三、核验结果(逐项)
+
+### 3.1 编译
+
+- `mvn -q -f cfc-backend/pom.xml compile` → **EXIT=0**(无警告级错误)
+- 与基线 `802fc5adc` 相比,无新增编译依赖/Bean 命名冲突;`FamilyFoodOverviewService`、`FoodRecommendService` 均正常注入。
+
+### 3.2 单元测试(29/29 全绿)
+
+| 套件 | 用例数 | 结果 |
+|---|---|---|
+| `FamilyFoodOverviewServiceTest` | 15 | ✅ 15/15 |
+| `FoodRecommendServiceTest` | 14 | ✅ 14/14 |
+| **合计** | **29** | **✅ 29/29** |
+
+新增用例覆盖:memberId 键命中(AC-09 核心)、历史 userId 键兼容、同成员双轨去重取最新、纯档案(userId=null)命中、纯档案无记录不报错、回收箱 memberId 键剔除、双轨反查 wrapper 结构与触发链。
+
+### 3.3 AC-09 运行态复现(测试库 192.168.16.251:3306/zxyj,后端 9084)
+
+**复现步骤**(一次性用户/家庭/成员/草稿,`INSERT + SELECT` 仅追加,无 `DELETE`):
+
+1. 新建一次性 user=**81759**、family=**2979**、member=**2026**(memberId=2026,`family_members.user_id`=81759)。
+2. 造草稿 draft1=**114**(`subjectId=2026`,报告判出 score=**-25**)→ 走真实报告确认链路 `recalculateForUser(memberId=2026)`。
+3. 观察 `food_recommend_idx`:新增行 `user_id=2026`(**memberId 键**,写侧未变,方向 2 前提成立)。
+4. 查询 `family_food_overview`:**0 行(修复前基线)→ 2 行(本分支修复后)**,`minIndexScore=-25`、`member_count=1`。
+5. 二次确认(draft2=**115**,score 变更 -25 → **15**)→ `family_food_overview.minIndexScore` 由 -25 **更新为 15** —— **幂等更新 PASS**。
+
+> 基线(修复前,即 T28 原始分支 + cfclub 写侧未归一化态)同链路复现为 **0 行**(AC-09 FAIL → 本分支 PASS)。
+> 测试库当前残留:`food_recommend_idx` 2 行(user_id=2026)、`family_food_overview` 2 行(family_id=2979)——**按无 DELETE 规则保留,未清理**。
+
+### 3.4 双轨覆盖静态核对(逐行)
+
+- **`refreshFamilyOverviews`(FoodRecommendService:195-221)**:`WHERE user_id=? OR id=?`(OR 语义),memberId 与历史 userId 两轨任一命中即收集 `family_id` 集合 → 逐个 `recalculate(fid)`;纯档案成员(userId=null)经 id 轨命中;异常仅 `warn` 不抛出,绝不回滚报告确认主流程。✅
+- **`doRecalculate`(FamilyFoodOverviewService:121-304)**:
+  - 成员集合 = 本家庭全部非 recycled 成员(含纯档案 userId=null,不再以 `user_id IS NOT NULL` 过滤);✅
+  - 键空间 `queryKeys = memberIds ∪ userIds`,一次 `IN` 批量查 `food_recommend_idx`(不逐成员 N 次单查);✅
+  - `resolveIndexOwnerId(key, memberByMemberId, memberByUserId)`:memberId 轨优先,userId 轨兼容历史;归一到成员本人;空/回收箱残留/他家庭数据返回 null 被剔除(双重防御);✅
+  - 同成员新旧键并存按【成员本人 memberId】聚合去重,保留 `calculated_at` 最新;指纹按成员(memberId)稳定排序 → 指纹确定、幂等跳过写入;✅
+  - `memberScores[].userId` 字段名遗留命名债,值统一 = memberId,注释说明(本轮不改 DDL 列名)。✅
+
+### 3.5 `getIndexByUser` 残留风险(P2 命名债,非阻塞)
+
+- `getIndexByUser`(FoodRecommendService:223-228)仍为单轨 `WHERE user_id=?`;调用方 `FoodRecommendController:41`(`/food/recommend/index`)传入的是 memberId。
+- 在本分支(写侧仍写 memberId)下,`getIndexByUser(memberId)` 查 `user_id=memberId` 与写入键一致 → **闭合回路,无数据丢失**,仅存在"方法名/参数名 userId 实为 memberId"的命名债(P2,建议后续统一,本分支不做)。
+
+### 3.6 无迁移 / 无破坏性变更
+
+- 无 `DatabaseInitializer` 迁移、无 `schema.sql` 变更、无 DDL、无历史行迁移(方向 2 决策:**不迁移历史数据**,历史 userId 键行由读侧双轨兼容)。
+- 幂等:指纹不变跳过写入;重复启动/重复确认不产生重复行。
+
+---
+
+## 四、结论判定(闸门矩阵)
+
+| 闸门 | 结论 |
+|---|---|
+| 编译 | ✅ 通过 |
+| 单元测试 29/29 | ✅ 通过 |
+| AC-09 运行态复现(0 行 → 2 行,幂等更新) | ✅ 通过 |
+| 双轨读侧覆盖(memberId + 历史 userId + 纯档案 + 回收箱剔除) | ✅ 通过 |
+| 无 DDL / 无数据迁移 | ✅ 通过 |
+| `getIndexByUser` 残留 | ⚠️ P2 命名债(闭合,无功能损失) |
+| 与 cfclub 主线合并冲突 | ⚠️ 存在(见 §六) |
+
+---
+
+## 五、未验证项 / 边界
+
+| 项 | 说明 |
+|---|---|
+| 生产库历史 `food_recommend_idx` 键分布 | 测试库基线键为 memberId 键行;生产需由维护者确认(方向 2 读侧双轨理论上对两种键均可兼容) |
+| 微信小程序真机页面渲染 | 本分支纯后端改动,前端零改动;前端行为不在本次核验范围 |
+| 迁移重复执行 | 无迁移,不适用 |
+
+---
+
+## 六、与 cfclub 主线合并冲突分析(关键,需维护者决策)
+
+- **merge-base** = `802fc5adc`。
+- 本分支仅改 4 文件;与 cfclub 主线比较,**2 文件"双方均有改动"**:`FoodRecommendService.java`、`FoodRecommendServiceTest.java`;其中 `FoodRecommendServiceTest.java` 有 **1 处内容冲突**。
+- **cfclub 主线已含方向 1(写侧统一)**:来自 E2E Test Bot 提交 `62f37b609`(`FoodRecommendService.resolveFoodIndexUserId(subjectId)` 把 memberId 归一为 `users.id` 再写入;且 `getIndexByUser/getIndexHistory/getCautionFoods/getTrend` 读侧亦归一化为 `users.id`)。
+- **合并形态**:若将本分支(方向 2)合入 cfclub(已含方向 1),将形成**混合态**:
+  - 写侧按方向 1 归一为 `users.id`;读侧总览按方向 2 双轨(memberId ∪ userId)——两者**不互斥**,读侧双轨对方向 1 写入的 `users.id` 键行亦能命中(userId 轨),理论兼容;
+  - 但 `memberScores[].userId` 语义在两条实现下不同(方向 1 = `users.id`,方向 2 = memberId),需在合并时明确口径;
+  - `FoodRecommendServiceTest.java` 冲突需人工解决(两侧测试断言基准不同:方向 1 断言写 `users.id`,方向 2 断言写 `memberId`)。
+- **建议(维护者决策)**:
+  1. **推荐**:以 cfclub 方向 1 为基底,将方向 2 的**读侧双轨**(`doRecalculate` + `refreshFamilyOverviews`)作为兼容层 cherry-pick/合并,保留方向 1 的写侧归一化与读侧归一化;测试文件按"写 `users.id` + 读侧双轨"双口径重写断言。
+  2. 备选:以本分支(方向 2)为基底,把方向 1 写侧归一化叠加;但需同步处理 `getIndexByUser` 等读方法的归一化,工作量大。
+  3. 无论何种合并,**历史 memberId 键行不迁移**(方向 2 决策),由读侧双轨兜底。
+
+> 本核验仅产出报告,**未修改任何业务代码、未执行生产写、未 DELETE**;合并动作由维护者执行。
+
+---
+
+## 七、交付
+
+- 本核验产出独立分支 `verify/t28-fix-key-align`(仅核验报告,不含业务代码改动)。
+- 后端验证:`mvn compile` 通过;`FamilyFoodOverviewServiceTest` 15/15 + `FoodRecommendServiceTest` 14/14 = 29/29。
+- 运行态:AC-09 修复实测生效(总览 0 行 → 2 行,幂等更新 -25→15)。
+- **建议**:分支通过全部核验闸门;合并前需维护者按 §六 决策合并策略并解决 `FoodRecommendServiceTest.java` 冲突。