|
|
@@ -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` 冲突。
|